diff options
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table')
4 files changed, 2348 insertions, 0 deletions
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html new file mode 100644 index 000000000..6212c95c8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -0,0 +1,327 @@ +<div class="dataTables_wrapper"> + + <div *ngIf="onlyActionHeader" + class="dataTables_header clearfix"> + <div class="cd-datatable-actions"> + <ng-content select=".only-table-actions"></ng-content> + </div> + </div> + <div class="dataTables_header clearfix" + *ngIf="toolHeader"> + <!-- actions --> + <div class="cd-datatable-actions"> + <ng-content select=".table-actions"></ng-content> + </div> + <!-- end actions --> + + <!-- column filters --> + <div *ngIf="columnFilters.length !== 0" + class="btn-group widget-toolbar"> + <div ngbDropdown + placement="bottom-right" + class="tc_filter_name"> + <button ngbDropdownToggle + class="btn btn-light"> + <i [ngClass]="[icons.large, icons.filter]"></i> + {{ selectedFilter.column.name }} + </button> + <div ngbDropdownMenu> + <ng-container *ngFor="let filter of columnFilters"> + <button ngbDropdownItem + (click)="onSelectFilter(filter); false">{{ filter.column.name }}</button> + </ng-container> + </div> + </div> + + <div ngbDropdown + placement="bottom-right" + class="tc_filter_option"> + <button ngbDropdownToggle + class="btn btn-light" + [class.disabled]="selectedFilter.options.length === 0"> + {{ selectedFilter.value ? selectedFilter.value.formatted: 'Any' }} + </button> + <div ngbDropdownMenu> + <ng-container *ngFor="let option of selectedFilter.options"> + <button ngbDropdownItem + (click)="onChangeFilter(selectedFilter, option); false"> + {{ option.formatted }} + <i *ngIf="selectedFilter.value !== undefined && (selectedFilter.value.raw === option.raw)" + [ngClass]="[icons.check]"></i> + </button> + </ng-container> + </div> + </div> + </div> + <!-- end column filters --> + + <!-- search --> + <div class="input-group search" + *ngIf="searchField"> + <span class="input-group-prepend"> + <span class="input-group-text"> + <i [ngClass]="[icons.search]"></i> + </span> + </span> + <input class="form-control" + type="text" + [(ngModel)]="search" + (keyup)="updateFilter()"> + <div class="input-group-append"> + <button type="button" + class="btn btn-light" + (click)="onClearSearch()"> + <i class="icon-prepend {{ icons.destroy }}"></i> + </button> + </div> + </div> + <!-- end search --> + + <!-- pagination limit --> + <div class="input-group dataTables_paginate" + *ngIf="limit"> + <input class="form-control" + type="number" + min="1" + max="9999" + [value]="userConfig.limit" + (click)="setLimit($event)" + (keyup)="setLimit($event)" + (blur)="setLimit($event)"> + </div> + <!-- end pagination limit--> + + <!-- show hide columns --> + <div class="widget-toolbar"> + <div ngbDropdown + autoClose="outside" + class="tc_menuitem"> + <button ngbDropdownToggle + class="btn btn-light tc_columnBtn"> + <i [ngClass]="[icons.large, icons.table]"></i> + </button> + <div ngbDropdownMenu> + <ng-container *ngFor="let column of columns"> + <button ngbDropdownItem + *ngIf="column.name !== ''" + (click)="toggleColumn(column); false;"> + <div class="custom-control custom-checkbox py-0"> + <input class="custom-control-input" + type="checkbox" + [name]="column.prop" + [id]="column.prop" + [checked]="!column.isHidden"> + <label class="custom-control-label" + [for]="column.prop">{{ column.name }}</label> + </div> + </button> + </ng-container> + </div> + </div> + </div> + <!-- end show hide columns --> + + <!-- refresh button --> + <div class="widget-toolbar tc_refreshBtn" + *ngIf="fetchData.observers.length > 0"> + + <button type="button" + [class]="'btn btn-' + status.type" + [ngbTooltip]="status.msg" + (click)="refreshBtn()"> + <i [ngClass]="[icons.large, icons.refresh]" + [class.fa-spin]="updating || loadingIndicator"></i> + </button> + </div> + <!-- end refresh button --> + </div> + <div class="dataTables_header clearfix" + *ngIf="toolHeader && columnFiltered"> + <!-- filter chips for column filters --> + <div class="filter-chips"> + <span *ngFor="let filter of columnFilters"> + <span *ngIf="filter.value" + class="badge badge-info mr-2"> + <span class="mr-2">{{ filter.column.name }}: {{ filter.value.formatted }}</span> + <a class="badge-remove" + (click)="onChangeFilter(filter); false"> + <i [ngClass]="[icons.destroy]" + aria-hidden="true"></i> + </a> + </span> + </span> + <a class="tc_clearSelections" + href="" + (click)="onClearFilters(); false"> + <ng-container i18n>Clear filters</ng-container> + </a> + </div> + <!-- end filter chips for column filters --> + </div> + <ngx-datatable #table + class="bootstrap cd-datatable" + [cssClasses]="paginationClasses" + [selectionType]="selectionType" + [selected]="selection.selected" + (select)="onSelect($event)" + [sorts]="userConfig.sorts" + (sort)="changeSorting($event)" + [columns]="tableColumns" + [columnMode]="columnMode" + [rows]="rows" + [rowClass]="getRowClass()" + [headerHeight]="header ? 'auto' : 0" + [footerHeight]="footer ? 'auto' : 0" + [count]="count" + [externalPaging]="serverSide" + [externalSorting]="serverSide" + [limit]="userConfig.limit > 0 ? userConfig.limit : undefined" + [offset]="userConfig.offset >= 0 ? userConfig.offset : 0" + (page)="changePage($event)" + [loadingIndicator]="loadingIndicator" + [rowIdentity]="rowIdentity()" + [rowHeight]="'auto'"> + + <!-- Row Detail Template --> + <ngx-datatable-row-detail rowHeight="auto" + #detailRow> + <ng-template let-row="row" + let-expanded="expanded" + ngx-datatable-row-detail-template> + <!-- Table Details --> + <ng-content select="[cdTableDetail]"></ng-content> + </ng-template> + </ngx-datatable-row-detail> + + <ngx-datatable-footer> + <ng-template ngx-datatable-footer-template + let-rowCount="rowCount" + let-pageSize="pageSize" + let-selectedCount="selectedCount" + let-curPage="curPage" + let-offset="offset" + let-isVisible="isVisible"> + <div class="page-count"> + <span *ngIf="selectionType"> + {{ selectedCount }} <ng-container i18n="X selected">selected</ng-container> / + </span> + + <!-- rowCount might have different semantics with or without serverSide. + We treat serverSide (backend-driven tables) as a specific case. + --> + <span *ngIf="!serverSide else serverSideTpl"> + <span *ngIf="rowCount != data?.length"> + {{ rowCount }} <ng-container i18n="X found">found</ng-container> / + </span> + {{ data?.length || 0 }} <ng-container i18n="X total">total</ng-container> + </span> + + <ng-template #serverSideTpl> + {{ data?.length || 0 }} <ng-container i18n="X found">found</ng-container> / + {{ rowCount }} <ng-container i18n="X total">total</ng-container> + </ng-template> + </div> + <datatable-pager [pagerLeftArrowIcon]="paginationClasses.pagerPrevious" + [pagerRightArrowIcon]="paginationClasses.pagerNext" + [pagerPreviousIcon]="paginationClasses.pagerLeftArrow" + [pagerNextIcon]="paginationClasses.pagerRightArrow" + [page]="curPage" + [size]="pageSize" + [count]="rowCount" + [hidden]="!((rowCount / pageSize) > 1)" + (change)="table.onFooterPage($event)"> + </datatable-pager> + </ng-template> + </ngx-datatable-footer> + </ngx-datatable> +</div> + +<!-- cell templates that can be accessed from outside --> +<ng-template #tableCellBoldTpl + let-value="value"> + <strong>{{ value }}</strong> +</ng-template> + +<ng-template #sparklineTpl + let-row="row" + let-value="value"> + <cd-sparkline [data]="value" + [isBinary]="row.cdIsBinary"></cd-sparkline> +</ng-template> + +<ng-template #routerLinkTpl + let-row="row" + let-value="value"> + <a [routerLink]="[row.cdLink]" + [queryParams]="row.cdParams">{{ value }}</a> +</ng-template> + +<ng-template #checkIconTpl + let-value="value"> + <i [ngClass]="[icons.check]" + [hidden]="!(value | boolean)"></i> +</ng-template> + +<ng-template #perSecondTpl + let-row="row" + let-value="value"> + {{ value | dimless }} /s +</ng-template> + +<ng-template #executingTpl + let-column="column" + let-row="row" + let-value="value"> + <i [ngClass]="[icons.spinner, icons.spin]" + *ngIf="row.cdExecuting"></i> + <span [ngClass]="column?.customTemplateConfig?.valueClass"> + {{ value }} + </span> + <span *ngIf="row.cdExecuting" + [ngClass]="column?.customTemplateConfig?.executingClass ? column.customTemplateConfig.executingClass : 'text-muted italic'">({{ row.cdExecuting }})</span> +</ng-template> + +<ng-template #classAddingTpl + let-value="value"> + <span class="{{ value | pipeFunction:useCustomClass:this }}">{{ value }}</span> +</ng-template> + +<ng-template #badgeTpl + let-column="column" + let-value="value"> + <span *ngFor="let item of (value | array); last as last"> + <span class="badge" + [ngClass]="(column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.class) ? column.customTemplateConfig.map[item].class : (column?.customTemplateConfig?.class ? column.customTemplateConfig.class : 'badge-primary')" + *ngIf="(column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.value) ? column.customTemplateConfig.map[item].value : column?.customTemplateConfig?.prefix ? column.customTemplateConfig.prefix + item : item"> + {{ (column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.value) ? column.customTemplateConfig.map[item].value : column?.customTemplateConfig?.prefix ? column.customTemplateConfig.prefix + item : item }} + </span> + <span *ngIf="!last"> </span> + </span> +</ng-template> + +<ng-template #mapTpl + let-column="column" + let-value="value"> + <span>{{ value | map:column?.customTemplateConfig }}</span> +</ng-template> + +<ng-template #truncateTpl + let-column="column" + let-value="value"> + <span data-toggle="tooltip" + [title]="value">{{ value | truncate:column?.customTemplateConfig?.length:column?.customTemplateConfig?.omission }}</span> +</ng-template> + +<ng-template #rowDetailsTpl + let-row="row" + let-isExpanded="expanded" + ngx-datatable-cell-template> + <a href="javascript:void(0)" + [class.expand-collapse-icon-right]="!isExpanded" + [class.expand-collapse-icon-down]="isExpanded" + class="expand-collapse-icon tc_expand-collapse" + title="Expand/Collapse Row" + i18n-title + (click)="toggleExpandRow(row, isExpanded, $event)"> + </a> +</ng-template> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss new file mode 100644 index 000000000..57b8e48de --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss @@ -0,0 +1,295 @@ +@use './src/styles/vendor/variables' as vv; +@use './src/styles/defaults/mixins'; + +@mixin row-details-icon { + color: vv.$gray-900; + font-family: 'ForkAwesome', sans-serif; + font-size: 1rem; + line-height: 1; +} + +.dataTables_wrapper { + margin-bottom: 25px; + // after bootstrap 8.0 the details table started to + // have an issue where the columns keep expanding to + // infinity. + // https://github.com/ceph/ceph/pull/40618#pullrequestreview-629010639 + // making the max-width to 99.9% solves the issue as a temporary fix + // until we get a conclusive fix, this needs to be kept. + max-width: 99.9%; + + .separator { + border-left: 1px solid vv.$datatable-divider-color; + display: inline-block; + height: 30px; + margin-left: 5px; + padding-left: 5px; + vertical-align: middle; + } + + .widget-toolbar { + border-left: 1px solid vv.$datatable-divider-color; + float: right; + padding: 0 8px; + + .form-check { + padding-left: 0; + } + } + + .dataTables_length > input { + line-height: 25px; + text-align: right; + } +} + +.dataTables_header { + background-color: vv.$gray-100; + border: 1px solid vv.$gray-400; + border-bottom: 0; + padding: 5px; + position: relative; + + .cd-datatable-actions { + float: left; + } + + .form-group { + padding-left: 8px; + } + + .input-group { + border-left: 1px solid vv.$datatable-divider-color; + float: right; + max-width: 250px; + padding-left: 8px; + padding-right: 8px; + width: 40%; + + .form-control { + height: 30px; + } + } + + .input-group.dataTables_paginate { + min-width: 85px; + padding-right: 8px; + width: 8%; + } + + .filter-chips { + float: right; + padding: 0 8px; + + .badge-remove { + color: vv.$white; + } + } +} + +::ng-deep cd-table .cd-datatable { + border: 1px solid vv.$gray-400; + margin-bottom: 0; + max-width: none !important; + + .progress-linear { + display: block; + height: 5px; + margin: 0; + padding: 0; + position: relative; + width: 100%; + + .container { + background-color: vv.$primary; + + .bar { + background-color: vv.$primary; + height: 100%; + left: 0; + overflow: hidden; + position: absolute; + width: 100%; + } + + .bar::before { + animation: progress-loading 3s linear infinite; + background-color: vv.$primary; + content: ''; + display: block; + height: 100%; + left: -200px; + position: absolute; + width: 200px; + } + } + } + + .datatable-header { + background-clip: padding-box; + background-color: vv.$gray-100; + background-image: linear-gradient(to bottom, vv.$gray-100 0, vv.$gray-200 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffafafa', endColorstr='#ffededed', GradientType=0); + + .sort-asc, + .sort-desc { + color: vv.$primary; + } + + .datatable-header-cell { + @include mixins.table-cell; + + font-weight: bold; + text-align: left; + + .datatable-header-cell-label { + &::after { + font-family: ForkAwesome; + 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: 0; + } + } + } + + .datatable-body { + margin-bottom: -6px; + + .empty-row { + background-color: lighten(vv.$primary, 45%); + font-style: italic; + font-weight: bold; + padding-bottom: 5px; + padding-top: 5px; + text-align: center; + } + + .datatable-body-row { + &.clickable:hover .datatable-row-group { + background-color: lighten(vv.$primary, 45%); + transition-duration: 0.3s; + transition-property: background; + transition-timing-function: linear; + } + + &.datatable-row-even { + background-color: vv.$white; + } + + &.datatable-row-odd { + background-color: vv.$gray-100; + } + + &.active, + &.active:hover { + background-color: lighten(vv.$primary, 35%); + } + + .datatable-body-cell { + @include mixins.table-cell; + + &:first-child { + border-left: 0; + } + + .datatable-body-cell-label { + display: block; + height: 100%; + } + } + } + + .datatable-row-detail { + border-bottom: 2px solid vv.$gray-400; + overflow-y: visible !important; + padding: 20px; + } + + .expand-collapse-icon { + display: block; + height: 100%; + text-align: center; + + &:hover { + text-decoration: none; + } + } + + .expand-collapse-icon-right::before { + @include row-details-icon; + content: '\f105'; + } + + .expand-collapse-icon-down::before { + @include row-details-icon; + content: '\f107'; + } + } + + .datatable-footer { + .selected-count, + .page-count { + font-style: italic; + min-height: 2rem; + padding-left: 0.3rem; + padding-top: 0.3rem; + } + } + + .cd-datatable-checkbox { + text-align: center; + } +} + +@keyframes progress-loading { + from { + left: -200px; + width: 15%; + } + + 50% { + width: 30%; + } + + 70% { + width: 70%; + } + + 80% { + left: 50%; + } + + 95% { + left: 120%; + } + + to { + left: 100%; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts new file mode 100644 index 000000000..f0f649780 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts @@ -0,0 +1,799 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgxDatatableModule } from '@swimlane/ngx-datatable'; +import _ from 'lodash'; +import { NgxPipeFunctionModule } from 'ngx-pipe-function'; + +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TableComponent } from './table.component'; + +describe('TableComponent', () => { + let component: TableComponent; + let fixture: ComponentFixture<TableComponent>; + + const createFakeData = (n: number) => { + const data = []; + for (let i = 0; i < n; i++) { + data.push({ + a: i, + b: i * 10, + c: !!(i % 2) + }); + } + return data; + }; + + const clearLocalStorage = () => { + component.localStorage.clear(); + }; + + configureTestBed({ + declarations: [TableComponent], + imports: [ + BrowserAnimationsModule, + NgxDatatableModule, + NgxPipeFunctionModule, + FormsModule, + ComponentsModule, + RouterTestingModule, + NgbDropdownModule, + PipesModule, + NgbTooltipModule + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TableComponent); + component = fixture.componentInstance; + + component.data = createFakeData(10); + component.localColumns = component.columns = [ + { prop: 'a', name: 'Index', filterable: true }, + { prop: 'b', name: 'Index times ten' }, + { prop: 'c', name: 'Odd?', filterable: true } + ]; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should force an identifier', () => { + component.identifier = 'x'; + component.forceIdentifier = true; + component.ngOnInit(); + expect(component.identifier).toBe('x'); + expect(component.sorts[0].prop).toBe('a'); + expect(component.sorts).toEqual(component.createSortingDefinition('a')); + }); + + it('should have rows', () => { + component.useData(); + expect(component.data.length).toBe(10); + 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.userConfig.limit).toBe(1); + expect(component.userConfig.limit).toEqual(jasmine.any(Number)); + e.target.value = '-20'; + component.setLimit(e); + expect(component.userConfig.limit).toBe(1); + }); + + it('should prevent propagation of mouseenter event', (done) => { + let wasCalled = false; + const mouseEvent = new MouseEvent('mouseenter'); + mouseEvent.stopPropagation = () => { + wasCalled = true; + }; + spyOn(component.table.element, 'addEventListener').and.callFake((eventName, fn) => { + fn(mouseEvent); + expect(eventName).toBe('mouseenter'); + expect(wasCalled).toBe(true); + done(); + }); + component.ngOnInit(); + }); + + it('should call updateSelection on init', () => { + component.updateSelection.subscribe((selection: CdTableSelection) => { + expect(selection.hasSelection).toBeFalsy(); + expect(selection.hasSingleSelection).toBeFalsy(); + expect(selection.hasMultiSelection).toBeFalsy(); + expect(selection.selected.length).toBe(0); + }); + component.ngOnInit(); + }); + + describe('test column filtering', () => { + let filterIndex: CdTableColumnFilter; + let filterOdd: CdTableColumnFilter; + let filterCustom: CdTableColumnFilter; + + const expectColumnFilterCreated = ( + filter: CdTableColumnFilter, + prop: string, + options: string[], + value?: { raw: string; formatted: string } + ) => { + expect(filter.column.prop).toBe(prop); + expect(_.map(filter.options, 'raw')).toEqual(options); + expect(filter.value).toEqual(value); + }; + + const expectColumnFiltered = ( + changes: { filter: CdTableColumnFilter; value?: string }[], + results: any[], + search: string = '' + ) => { + component.search = search; + _.forEach(changes, (change) => { + component.onChangeFilter( + change.filter, + change.value ? { raw: change.value, formatted: change.value } : undefined + ); + }); + expect(component.rows).toEqual(results); + component.onClearSearch(); + component.onClearFilters(); + }; + + describe('with visible columns', () => { + beforeEach(() => { + component.initColumnFilters(); + component.updateColumnFilterOptions(); + filterIndex = component.columnFilters[0]; + filterOdd = component.columnFilters[1]; + }); + + it('should have filters initialized', () => { + expect(component.columnFilters.length).toBe(2); + expectColumnFilterCreated( + filterIndex, + 'a', + _.map(component.data, (row) => _.toString(row.a)) + ); + expectColumnFilterCreated(filterOdd, 'c', ['false', 'true']); + }); + + it('should add filters', () => { + // single + expectColumnFiltered([{ filter: filterIndex, value: '1' }], [{ a: 1, b: 10, c: true }]); + + // multiple + expectColumnFiltered( + [ + { filter: filterOdd, value: 'false' }, + { filter: filterIndex, value: '2' } + ], + [{ a: 2, b: 20, c: false }] + ); + + // Clear should work + expect(component.rows).toEqual(component.data); + }); + + it('should remove filters', () => { + // single + expectColumnFiltered( + [ + { filter: filterOdd, value: 'true' }, + { filter: filterIndex, value: '1' }, + { filter: filterIndex, value: undefined } + ], + [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ] + ); + + // multiple + expectColumnFiltered( + [ + { filter: filterOdd, value: 'true' }, + { filter: filterIndex, value: '1' }, + { filter: filterIndex, value: undefined }, + { filter: filterOdd, value: undefined } + ], + component.data + ); + + // a selected filter should be removed if it's selected again + expectColumnFiltered( + [ + { filter: filterOdd, value: 'true' }, + { filter: filterIndex, value: '1' }, + { filter: filterIndex, value: '1' } + ], + [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ] + ); + }); + + it('should search from filtered rows', () => { + expectColumnFiltered( + [{ filter: filterOdd, value: 'true' }], + [{ a: 9, b: 90, c: true }], + '9' + ); + + // Clear should work + expect(component.rows).toEqual(component.data); + }); + }); + + describe('with custom columns', () => { + beforeEach(() => { + // create a new additional column in data + for (let i = 0; i < component.data.length; i++) { + const row = component.data[i]; + row['d'] = row.a; + } + // create a custom column filter + component.extraFilterableColumns = [ + { + name: 'd less than 5', + prop: 'd', + filterOptions: ['yes', 'no'], + filterInitValue: 'yes', + filterPredicate: (row, value) => { + if (value === 'yes') { + return row.d < 5; + } else { + return row.d >= 5; + } + } + } + ]; + component.initColumnFilters(); + component.updateColumnFilterOptions(); + filterIndex = component.columnFilters[0]; + filterOdd = component.columnFilters[1]; + filterCustom = component.columnFilters[2]; + }); + + it('should have filters initialized', () => { + expect(component.columnFilters.length).toBe(3); + expectColumnFilterCreated(filterCustom, 'd', ['yes', 'no'], { + raw: 'yes', + formatted: 'yes' + }); + component.useData(); + expect(component.rows).toEqual(_.slice(component.data, 0, 5)); + }); + + it('should remove filters', () => { + expectColumnFiltered([{ filter: filterCustom, value: 'no' }], _.slice(component.data, 5)); + }); + }); + }); + + describe('test search', () => { + const expectSearch = (keyword: string, expectedResult: object[]) => { + component.search = keyword; + component.updateFilter(); + expect(component.rows).toEqual(expectedResult); + component.onClearSearch(); + }; + + describe('searchableObjects', () => { + const testObject = { + obj: { + min: 8, + max: 123 + } + }; + + beforeEach(() => { + component.data = [testObject]; + component.localColumns = [{ prop: 'obj', name: 'Object' }]; + }); + + it('should not search through objects as default case', () => { + expect(component.searchableObjects).toBe(false); + expectSearch('8', []); + }); + + it('should search through objects if searchableObjects is set to true', () => { + component.searchableObjects = true; + expectSearch('28', []); + expectSearch('8', [testObject]); + expectSearch('123', [testObject]); + expectSearch('max', [testObject]); + }); + }); + + it('should find a particular number', () => { + expectSearch('5', [{ a: 5, b: 50, c: true }]); + expectSearch('9', [{ a: 9, b: 90, c: true }]); + }); + + it('should find boolean values', () => { + expectSearch('true', [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ]); + expectSearch('false', [ + { a: 0, b: 0, c: false }, + { a: 2, b: 20, c: false }, + { a: 4, b: 40, c: false }, + { a: 6, b: 60, c: false }, + { a: 8, b: 80, c: false } + ]); + }); + + it('should test search keyword preparation', () => { + const prepare = TableComponent.prepareSearch; + const expected = ['a', 'b', 'c']; + expect(prepare('a b c')).toEqual(expected); + expect(prepare('a,, b,, c')).toEqual(expected); + expect(prepare('a,,,, b,,, c')).toEqual(expected); + expect(prepare('a+b c')).toEqual(['a+b', 'c']); + expect(prepare('a,,,+++b,,, c')).toEqual(['a+++b', 'c']); + expect(prepare('"a b c" "d e f", "g, h i"')).toEqual(['a+b+c', 'd+e++f', 'g+h+i']); + }); + + it('should search for multiple values', () => { + expectSearch('2 20 false', [{ a: 2, b: 20, c: false }]); + expectSearch('false 2', [{ a: 2, b: 20, c: false }]); + }); + + it('should filter by column', () => { + expectSearch('index:5', [{ a: 5, b: 50, c: true }]); + expectSearch('times:50', [{ a: 5, b: 50, c: true }]); + expectSearch('times:50 index:5', [{ a: 5, b: 50, c: true }]); + expectSearch('Odd?:true', [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ]); + component.data = createFakeData(100); + expectSearch('index:1 odd:true times:110', [{ a: 11, b: 110, c: true }]); + }); + + it('should search through arrays', () => { + component.localColumns = [ + { prop: 'a', name: 'Index' }, + { prop: 'b', name: 'ArrayColumn' } + ]; + + component.data = [ + { a: 1, b: ['foo', 'bar'] }, + { a: 2, b: ['baz', 'bazinga'] } + ]; + expectSearch('bar', [{ a: 1, b: ['foo', 'bar'] }]); + expectSearch('arraycolumn:bar arraycolumn:foo', [{ a: 1, b: ['foo', 'bar'] }]); + expectSearch('arraycolumn:baz arraycolumn:inga', [{ a: 2, b: ['baz', 'bazinga'] }]); + + component.data = [ + { a: 1, b: [1, 2] }, + { a: 2, b: [3, 4] } + ]; + expectSearch('arraycolumn:1 arraycolumn:2', [{ a: 1, b: [1, 2] }]); + }); + + it('should search with spaces', () => { + const expectedResult = [{ a: 2, b: 20, c: false }]; + expectSearch(`'Index times ten':20`, expectedResult); + expectSearch('index+times+ten:20', expectedResult); + }); + + it('should filter results although column name is incomplete', () => { + component.data = createFakeData(3); + expectSearch(`'Index times ten'`, []); + expectSearch(`'Ind'`, []); + expectSearch(`'Ind:'`, [ + { a: 0, b: 0, c: false }, + { a: 1, b: 10, c: true }, + { a: 2, b: 20, c: false } + ]); + }); + + it('should search if column name is incomplete', () => { + const expectedData = [ + { a: 0, b: 0, c: false }, + { a: 1, b: 10, c: true }, + { a: 2, b: 20, c: false } + ]; + component.data = _.clone(expectedData); + expectSearch('inde', []); + expectSearch('index:', expectedData); + expectSearch('index times te', []); + }); + + it('should restore full table after search', () => { + component.useData(); + expect(component.rows.length).toBe(10); + component.search = '3'; + component.updateFilter(); + expect(component.rows.length).toBe(1); + component.onClearSearch(); + expect(component.rows.length).toBe(10); + }); + + it('should work with undefined data', () => { + component.data = undefined; + component.search = '3'; + component.updateFilter(); + expect(component.rows).toBeUndefined(); + }); + }); + + describe('after ngInit', () => { + const toggleColumn = (prop: string, checked: boolean) => { + component.toggleColumn({ + prop: prop, + isHidden: checked + }); + }; + + const equalStorageConfig = () => { + expect(JSON.stringify(component.userConfig)).toBe( + component.localStorage.getItem(component.tableName) + ); + }; + + beforeEach(() => { + component.ngOnInit(); + }); + + it('should have updated the column definitions', () => { + expect(component.localColumns[0].flexGrow).toBe(1); + expect(component.localColumns[1].flexGrow).toBe(2); + expect(component.localColumns[2].flexGrow).toBe(2); + expect(component.localColumns[2].resizeable).toBe(false); + }); + + it('should have table columns', () => { + expect(component.tableColumns.length).toBe(3); + expect(component.tableColumns).toEqual(component.localColumns); + }); + + it('should have a unique identifier which it searches for', () => { + expect(component.identifier).toBe('a'); + expect(component.userConfig.sorts[0].prop).toBe('a'); + expect(component.userConfig.sorts).toEqual(component.createSortingDefinition('a')); + equalStorageConfig(); + }); + + it('should remove column "a"', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); + toggleColumn('a', false); + expect(component.userConfig.sorts[0].prop).toBe('b'); + expect(component.tableColumns.length).toBe(2); + equalStorageConfig(); + }); + + it('should not be able to remove all columns', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); + toggleColumn('a', false); + toggleColumn('b', false); + toggleColumn('c', false); + expect(component.userConfig.sorts[0].prop).toBe('c'); + expect(component.tableColumns.length).toBe(1); + equalStorageConfig(); + }); + + it('should enable column "a" again', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); + toggleColumn('a', false); + toggleColumn('a', true); + expect(component.userConfig.sorts[0].prop).toBe('b'); + expect(component.tableColumns.length).toBe(3); + equalStorageConfig(); + }); + + it('should toggle on off columns', () => { + for (const column of component.columns) { + component.toggleColumn(column); + expect(column.isHidden).toBeTruthy(); + component.toggleColumn(column); + expect(column.isHidden).toBeFalsy(); + } + }); + + afterEach(() => { + clearLocalStorage(); + }); + }); + + describe('test cell transformations', () => { + interface ExecutingTemplateConfig { + valueClass?: string; + executingClass?: string; + } + + const testExecutingTemplate = (templateConfig?: ExecutingTemplateConfig) => { + const state = 'updating'; + const value = component.data[0].a; + + component.autoReload = -1; + component.columns[0].cellTransformation = CellTemplate.executing; + if (templateConfig) { + component.columns[0].customTemplateConfig = templateConfig; + } + component.data[0].cdExecuting = state; + fixture.detectChanges(); + + const elements = fixture.debugElement + .query(By.css('datatable-body-row datatable-body-cell')) + .queryAll(By.css('span')); + expect(elements.length).toBe(2); + + // Value + const valueElement = elements[0]; + if (templateConfig?.valueClass) { + templateConfig.valueClass.split(' ').forEach((clz) => { + expect(valueElement.classes).toHaveProperty(clz); + }); + } + expect(valueElement.nativeElement.textContent.trim()).toBe(`${value}`); + // Executing state + const executingElement = elements[1]; + if (templateConfig?.executingClass) { + templateConfig.executingClass.split(' ').forEach((clz) => { + expect(executingElement.classes).toHaveProperty(clz); + }); + } + expect(executingElement.nativeElement.textContent.trim()).toBe(`(${state})`); + }; + + it('should display executing template', () => { + testExecutingTemplate(); + }); + + it('should display executing template with custom classes', () => { + testExecutingTemplate({ valueClass: 'a b', executingClass: 'c d' }); + }); + }); + + describe('test unselect functionality of rows', () => { + beforeEach(() => { + component.autoReload = -1; + component.selectionType = 'single'; + fixture.detectChanges(); + }); + + it('should unselect row on clicking on it again', () => { + const rowCellDebugElement = fixture.debugElement.query(By.css('datatable-body-cell')); + + rowCellDebugElement.triggerEventHandler('click', null); + expect(component.selection.selected.length).toEqual(1); + + rowCellDebugElement.triggerEventHandler('click', null); + expect(component.selection.selected.length).toEqual(0); + }); + }); + + describe('reload data', () => { + beforeEach(() => { + component.ngOnInit(); + component.data = []; + component['updating'] = false; + }); + + it('should call fetchData callback function', () => { + component.fetchData.subscribe((context: any) => { + expect(context instanceof CdTableFetchDataContext).toBeTruthy(); + }); + component.reloadData(); + }); + + it('should call error function', () => { + component.data = createFakeData(5); + component.fetchData.subscribe((context: any) => { + context.error(); + expect(component.status.type).toBe('danger'); + expect(component.data.length).toBe(0); + expect(component.loadingIndicator).toBeFalsy(); + expect(component['updating']).toBeFalsy(); + }); + component.reloadData(); + }); + + it('should call error function with custom config', () => { + component.data = createFakeData(10); + component.fetchData.subscribe((context: any) => { + context.errorConfig.resetData = false; + context.errorConfig.displayError = false; + context.error(); + expect(component.status.type).toBe('danger'); + expect(component.data.length).toBe(10); + expect(component.loadingIndicator).toBeFalsy(); + expect(component['updating']).toBeFalsy(); + }); + component.reloadData(); + }); + + it('should update selection on refresh - "onChange"', () => { + spyOn(component, 'onSelect').and.callThrough(); + component.data = createFakeData(10); + component.selection.selected = [_.clone(component.data[1])]; + component.updateSelectionOnRefresh = 'onChange'; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalledTimes(0); + component.data[1].d = !component.data[1].d; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalled(); + }); + + it('should update selection on refresh - "always"', () => { + spyOn(component, 'onSelect').and.callThrough(); + component.data = createFakeData(10); + component.selection.selected = [_.clone(component.data[1])]; + component.updateSelectionOnRefresh = 'always'; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalled(); + component.data[1].d = !component.data[1].d; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalled(); + }); + + it('should update selection on refresh - "never"', () => { + spyOn(component, 'onSelect').and.callThrough(); + component.data = createFakeData(10); + component.selection.selected = [_.clone(component.data[1])]; + component.updateSelectionOnRefresh = 'never'; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalledTimes(0); + component.data[1].d = !component.data[1].d; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalledTimes(0); + }); + + afterEach(() => { + clearLocalStorage(); + }); + }); + + describe('useCustomClass', () => { + beforeEach(() => { + component.customCss = { + 'badge badge-danger': 'active', + 'secret secret-number': 123.456, + btn: (v) => _.isString(v) && v.startsWith('http'), + secure: (v) => _.isString(v) && v.startsWith('https') + }; + }); + + it('should throw an error if custom classes are not set', () => { + component.customCss = undefined; + expect(() => component.useCustomClass('active')).toThrowError('Custom classes are not set!'); + }); + + it('should not return any class', () => { + ['', 'something', 123, { complex: 1 }, [1, 2, 3]].forEach((value) => + expect(component.useCustomClass(value)).toBe(undefined) + ); + }); + + it('should match a string and return the corresponding class', () => { + expect(component.useCustomClass('active')).toBe('badge badge-danger'); + }); + + it('should match a number and return the corresponding class', () => { + expect(component.useCustomClass(123.456)).toBe('secret secret-number'); + }); + + it('should match against a function and return the corresponding class', () => { + expect(component.useCustomClass('http://no.ssl')).toBe('btn'); + }); + + it('should match against multiple functions and return the corresponding classes', () => { + expect(component.useCustomClass('https://secure.it')).toBe('btn secure'); + }); + }); + + describe('test expand and collapse feature', () => { + beforeEach(() => { + spyOn(component.setExpandedRow, 'emit'); + component.table = { + rowDetail: { collapseAllRows: jest.fn(), toggleExpandRow: jest.fn() } + } as any; + + // Setup table + component.identifier = 'a'; + component.data = createFakeData(10); + + // Select item + component.expanded = _.clone(component.data[1]); + }); + + describe('update expanded on refresh', () => { + const updateExpendedOnState = (state: 'always' | 'never' | 'onChange') => { + component.updateExpandedOnRefresh = state; + component.updateExpanded(); + }; + + beforeEach(() => { + // Mock change + component.data[1].b = 'test'; + }); + + it('refreshes "always"', () => { + updateExpendedOnState('always'); + expect(component.expanded.b).toBe('test'); + expect(component.setExpandedRow.emit).toHaveBeenCalled(); + }); + + it('refreshes "onChange"', () => { + updateExpendedOnState('onChange'); + expect(component.expanded.b).toBe('test'); + expect(component.setExpandedRow.emit).toHaveBeenCalled(); + }); + + it('does not refresh "onChange" if data is equal', () => { + component.data[1].b = 10; // Reverts change + updateExpendedOnState('onChange'); + expect(component.expanded.b).toBe(10); + expect(component.setExpandedRow.emit).not.toHaveBeenCalled(); + }); + + it('"never" refreshes', () => { + updateExpendedOnState('never'); + expect(component.expanded.b).toBe(10); + expect(component.setExpandedRow.emit).not.toHaveBeenCalled(); + }); + }); + + it('should open the table details and close other expanded rows', () => { + component.toggleExpandRow(component.expanded, false, new Event('click')); + expect(component.expanded).toEqual({ a: 1, b: 10, c: true }); + expect(component.table.rowDetail.collapseAllRows).toHaveBeenCalled(); + expect(component.setExpandedRow.emit).toHaveBeenCalledWith(component.expanded); + expect(component.table.rowDetail.toggleExpandRow).toHaveBeenCalled(); + }); + + it('should close the current table details expansion', () => { + component.toggleExpandRow(component.expanded, true, new Event('click')); + expect(component.expanded).toBeUndefined(); + expect(component.setExpandedRow.emit).toHaveBeenCalledWith(undefined); + expect(component.table.rowDetail.toggleExpandRow).toHaveBeenCalled(); + }); + + it('should not select the row when the row is expanded', () => { + expect(component.selection.selected).toEqual([]); + component.toggleExpandRow(component.data[1], false, new Event('click')); + expect(component.selection.selected).toEqual([]); + }); + + it('should not change selection when expanding different row', () => { + expect(component.selection.selected).toEqual([]); + expect(component.expanded).toEqual(component.data[1]); + component.selection.selected = [component.data[2]]; + component.toggleExpandRow(component.data[3], false, new Event('click')); + expect(component.selection.selected).toEqual([component.data[2]]); + expect(component.expanded).toEqual(component.data[3]); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts new file mode 100644 index 000000000..6a37468c2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -0,0 +1,927 @@ +import { + AfterContentChecked, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + PipeTransform, + SimpleChanges, + TemplateRef, + ViewChild +} from '@angular/core'; + +import { + DatatableComponent, + getterForProp, + SortDirection, + SortPropDir, + TableColumnProp +} from '@swimlane/ngx-datatable'; +import _ from 'lodash'; +import { Observable, of, Subject, Subscription } from 'rxjs'; + +import { TableStatus } from '~/app/shared/classes/table-status'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter'; +import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { PageInfo } from '~/app/shared/models/cd-table-paging'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { CdUserConfig } from '~/app/shared/models/cd-user-config'; +import { TimerService } from '~/app/shared/services/timer.service'; + +@Component({ + selector: 'cd-table', + templateUrl: './table.component.html', + styleUrls: ['./table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy { + @ViewChild(DatatableComponent, { static: true }) + table: DatatableComponent; + @ViewChild('tableCellBoldTpl', { static: true }) + tableCellBoldTpl: TemplateRef<any>; + @ViewChild('sparklineTpl', { static: true }) + sparklineTpl: TemplateRef<any>; + @ViewChild('routerLinkTpl', { static: true }) + routerLinkTpl: TemplateRef<any>; + @ViewChild('checkIconTpl', { static: true }) + checkIconTpl: TemplateRef<any>; + @ViewChild('perSecondTpl', { static: true }) + perSecondTpl: TemplateRef<any>; + @ViewChild('executingTpl', { static: true }) + executingTpl: TemplateRef<any>; + @ViewChild('classAddingTpl', { static: true }) + classAddingTpl: TemplateRef<any>; + @ViewChild('badgeTpl', { static: true }) + badgeTpl: TemplateRef<any>; + @ViewChild('mapTpl', { static: true }) + mapTpl: TemplateRef<any>; + @ViewChild('truncateTpl', { static: true }) + truncateTpl: TemplateRef<any>; + @ViewChild('rowDetailsTpl', { static: true }) + rowDetailsTpl: TemplateRef<any>; + + // This is the array with the items to be shown. + @Input() + data: any[]; + // Each item -> { prop: 'attribute name', name: 'display name' } + @Input() + columns: CdTableColumn[]; + // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'} + @Input() + sorts?: SortPropDir[]; + // Method used for setting column widths. + @Input() + columnMode? = 'flex'; + // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions" + @Input() + onlyActionHeader? = false; + // Display the tool header, including reload button, pagination and search fields? + @Input() + toolHeader? = true; + // Display search field inside tool header? + @Input() + searchField? = true; + // Display the table header? + @Input() + header? = true; + // Display the table footer? + @Input() + footer? = true; + // Page size to show. Set to 0 to show unlimited number of rows. + @Input() + limit? = 10; + @Input() + maxLimit? = 9999; + // Has the row details? + @Input() + hasDetails = false; + + /** + * Auto reload time in ms - per default every 5s + * You can set it to 0, undefined or false to disable the auto reload feature in order to + * trigger 'fetchData' if the reload button is clicked. + * You can set it to a negative number to, on top of disabling the auto reload, + * prevent triggering fetchData when initializing the table. + */ + @Input() + autoReload = 5000; + + // Which row property is unique for a row. If the identifier is not specified in any + // column, then the property name of the first column is used. Defaults to 'id'. + @Input() + identifier = 'id'; + // If 'true', then the specified identifier is used anyway, although it is not specified + // in any column. Defaults to 'false'. + @Input() + forceIdentifier = false; + // Allows other components to specify which type of selection they want, + // e.g. 'single' or 'multi'. + @Input() + selectionType: string = undefined; + // By default selected item details will be updated on table refresh, if data has changed + @Input() + updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange'; + // By default expanded item details will be updated on table refresh, if data has changed + @Input() + updateExpandedOnRefresh: 'always' | 'never' | 'onChange' = 'onChange'; + + @Input() + autoSave = true; + + // Enable this in order to search through the JSON of any used object. + @Input() + searchableObjects = false; + + // Only needed to set if the classAddingTpl is used + @Input() + customCss?: { [css: string]: number | string | ((any: any) => boolean) }; + + // Columns that aren't displayed but can be used as filters + @Input() + extraFilterableColumns: CdTableColumn[] = []; + + @Input() + status = new TableStatus(); + + // Support server-side pagination/sorting/etc. + @Input() + serverSide = false; + + /* + Only required when serverSide is enabled. + It should be provided by the server via "X-Total-Count" HTTP Header + */ + @Input() + count = 0; + + /** + * Should be a function to update the input data if undefined nothing will be triggered + * + * Sometimes it's useful to only define fetchData once. + * Example: + * Usage of multiple tables with data which is updated by the same function + * What happens: + * The function is triggered through one table and all tables will update + */ + @Output() + fetchData = new EventEmitter<CdTableFetchDataContext>(); + + /** + * This should be defined if you need access to the selection object. + * + * Each time the table selection changes, this will be triggered and + * the new selection object will be sent. + * + * @memberof TableComponent + */ + @Output() + updateSelection = new EventEmitter(); + + @Output() + setExpandedRow = new EventEmitter(); + + /** + * This should be defined if you need access to the applied column filters. + * + * Each time the column filters changes, this will be triggered and + * the column filters change event will be sent. + * + * @memberof TableComponent + */ + @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>(); + + /** + * Use this variable to access the selected row(s). + */ + selection = new CdTableSelection(); + + /** + * Use this variable to access the expanded row + */ + expanded: any = undefined; + + /** + * To prevent making changes to the original columns list, that might change + * how the table is renderer a second time, we now clone that list into a + * local variable and only use the clone. + */ + localColumns: CdTableColumn[]; + tableColumns: CdTableColumn[]; + icons = Icons; + cellTemplates: { + [key: string]: TemplateRef<any>; + } = {}; + search = ''; + rows: any[] = []; + loadingIndicator = true; + paginationClasses = { + pagerLeftArrow: Icons.leftArrowDouble, + pagerRightArrow: Icons.rightArrowDouble, + pagerPrevious: Icons.leftArrow, + pagerNext: Icons.rightArrow + }; + userConfig: CdUserConfig = {}; + tableName: string; + localStorage = window.localStorage; + private saveSubscriber: Subscription; + private reloadSubscriber: Subscription; + private updating = false; + + // Internal variable to check if it is necessary to recalculate the + // table columns after the browser window has been resized. + private currentWidth: number; + + columnFilters: CdTableColumnFilter[] = []; + selectedFilter: CdTableColumnFilter; + get columnFiltered(): boolean { + return _.some(this.columnFilters, (filter) => { + return filter.value !== undefined; + }); + } + + constructor( + // private ngZone: NgZone, + private cdRef: ChangeDetectorRef, + private timerService: TimerService + ) {} + + static prepareSearch(search: string) { + search = search.toLowerCase().replace(/,/g, ''); + if (search.match(/['"][^'"]+['"]/)) { + search = search.replace(/['"][^'"]+['"]/g, (match: string) => { + return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+'); + }); + } + return search.split(' ').filter((word) => word); + } + + ngOnInit() { + this.localColumns = _.clone(this.columns); + // debounce reloadData method so that search doesn't run api requests + // for every keystroke + if (this.serverSide) { + this.reloadData = _.debounce(this.reloadData, 1000); + } + + // ngx-datatable triggers calculations each time mouse enters a row, + // this will prevent that. + this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation()); + this._addTemplates(); + if (!this.sorts) { + // Check whether the specified identifier exists. + const exists = _.findIndex(this.localColumns, ['prop', this.identifier]) !== -1; + // Auto-build the sorting configuration. If the specified identifier doesn't exist, + // then use the property of the first column. + this.sorts = this.createSortingDefinition( + exists ? this.identifier : this.localColumns[0].prop + '' + ); + // If the specified identifier doesn't exist and it is not forced to use it anyway, + // then use the property of the first column. + if (!exists && !this.forceIdentifier) { + this.identifier = this.localColumns[0].prop + ''; + } + } + + this.initUserConfig(); + this.localColumns.forEach((c) => { + if (c.cellTransformation) { + c.cellTemplate = this.cellTemplates[c.cellTransformation]; + } + if (!c.flexGrow) { + c.flexGrow = c.prop + '' === this.identifier ? 1 : 2; + } + if (!c.resizeable) { + c.resizeable = false; + } + }); + + this.initExpandCollapseColumn(); // If rows have details, add a column to expand or collapse the rows + this.initCheckboxColumn(); + this.filterHiddenColumns(); + this.initColumnFilters(); + this.updateColumnFilterOptions(); + // Notify all subscribers to reset their current selection. + this.updateSelection.emit(new CdTableSelection()); + // Load the data table content every N ms or at least once. + // Force showing the loading indicator if there are subscribers to the fetchData + // event. This is necessary because it has been set to False in useData() when + // this method was triggered by ngOnChanges(). + if (this.fetchData.observers.length > 0) { + this.loadingIndicator = true; + } + if (_.isInteger(this.autoReload) && this.autoReload > 0) { + this.reloadSubscriber = this.timerService + .get(() => of(0), this.autoReload) + .subscribe(() => { + this.reloadData(); + }); + } else if (!this.autoReload) { + this.reloadData(); + } else { + this.useData(); + } + + if (this.selectionType === 'single') { + this.table.selectCheck = this.singleSelectCheck.bind(this); + } + } + + initUserConfig() { + if (this.autoSave) { + this.tableName = this._calculateUniqueTableName(this.localColumns); + this._loadUserConfig(); + this._initUserConfigAutoSave(); + } + if (!this.userConfig.limit) { + this.userConfig.limit = this.limit; + } + if (!(this.userConfig.offset >= 0)) { + this.userConfig.offset = this.table.offset; + } + if (!this.userConfig.search) { + this.userConfig.search = this.search; + } + if (!this.userConfig.sorts) { + this.userConfig.sorts = this.sorts; + } + if (!this.userConfig.columns) { + this.updateUserColumns(); + } else { + this.userConfig.columns.forEach((col) => { + for (let i = 0; i < this.localColumns.length; i++) { + if (this.localColumns[i].prop === col.prop) { + this.localColumns[i].isHidden = col.isHidden; + } + } + }); + } + } + + _calculateUniqueTableName(columns: any[]) { + const stringToNumber = (s: string) => { + if (!_.isString(s)) { + return 0; + } + let result = 0; + for (let i = 0; i < s.length; i++) { + result += s.charCodeAt(i) * i; + } + return result; + }; + return columns + .reduce( + (result, value, index) => + (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result, + 0 + ) + .toString(); + } + + _loadUserConfig() { + const loaded = this.localStorage.getItem(this.tableName); + if (loaded) { + this.userConfig = JSON.parse(loaded); + } + } + + _initUserConfigAutoSave() { + const source: Observable<any> = new Observable(this._initUserConfigProxy.bind(this)); + this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this)); + } + + _initUserConfigProxy(observer: Subject<any>) { + this.userConfig = new Proxy(this.userConfig, { + set(config, prop: string, value) { + config[prop] = value; + observer.next(config); + return true; + } + }); + } + + _saveUserConfig(config: any) { + this.localStorage.setItem(this.tableName, JSON.stringify(config)); + } + + updateUserColumns() { + this.userConfig.columns = this.localColumns.map((c) => ({ + prop: c.prop, + name: c.name, + isHidden: !!c.isHidden + })); + } + + /** + * Add a column containing a checkbox if selectionType is 'multiClick'. + */ + initCheckboxColumn() { + if (this.selectionType === 'multiClick') { + this.localColumns.unshift({ + prop: undefined, + resizeable: false, + sortable: false, + draggable: false, + checkboxable: true, + canAutoResize: false, + cellClass: 'cd-datatable-checkbox', + width: 30 + }); + } + } + + /** + * Add a column to expand and collapse the table row if it 'hasDetails' + */ + initExpandCollapseColumn() { + if (this.hasDetails) { + this.localColumns.unshift({ + prop: undefined, + resizeable: false, + sortable: false, + draggable: false, + isHidden: false, + canAutoResize: false, + cellClass: 'cd-datatable-expand-collapse', + width: 40, + cellTemplate: this.rowDetailsTpl + }); + } + } + + filterHiddenColumns() { + this.tableColumns = this.localColumns.filter((c) => !c.isHidden); + } + + initColumnFilters() { + let filterableColumns = _.filter(this.localColumns, { filterable: true }); + filterableColumns = [...filterableColumns, ...this.extraFilterableColumns]; + this.columnFilters = filterableColumns.map((col: CdTableColumn) => { + return { + column: col, + options: [], + value: col.filterInitValue + ? this.createColumnFilterOption(col.filterInitValue, col.pipe) + : undefined + }; + }); + this.selectedFilter = _.first(this.columnFilters); + } + + private createColumnFilterOption( + value: any, + pipe?: PipeTransform + ): { raw: string; formatted: string } { + return { + raw: _.toString(value), + formatted: pipe ? pipe.transform(value) : _.toString(value) + }; + } + + updateColumnFilterOptions() { + // update all possible values in a column + this.columnFilters.forEach((filter) => { + let values: any[] = []; + + if (_.isUndefined(filter.column.filterOptions)) { + // only allow types that can be easily converted into string + const pre = _.filter(_.map(this.data, filter.column.prop), (v) => { + return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v); + }); + values = _.sortedUniq(pre.sort()); + } else { + values = filter.column.filterOptions; + } + + const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe)); + + // In case a previous value is not available anymore + if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) { + filter.value = undefined; + } + + filter.options = options; + }); + } + + onSelectFilter(filter: CdTableColumnFilter) { + this.selectedFilter = filter; + } + + onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) { + filter.value = _.isEqual(filter.value, option) ? undefined : option; + this.updateFilter(); + } + + doColumnFiltering() { + const appliedFilters: CdTableColumnFiltersChange['filters'] = []; + let data = [...this.data]; + let dataOut: any[] = []; + this.columnFilters.forEach((filter) => { + if (filter.value === undefined) { + return; + } + appliedFilters.push({ + name: filter.column.name, + prop: filter.column.prop, + value: filter.value + }); + // Separate data to filtered and filtered-out parts. + const parts = _.partition(data, (row) => { + // Use getter from ngx-datatable to handle props like 'sys_api.size' + const valueGetter = getterForProp(filter.column.prop); + const value = valueGetter(row, filter.column.prop); + if (_.isUndefined(filter.column.filterPredicate)) { + // By default, test string equal + return `${value}` === filter.value.raw; + } else { + // Use custom function to filter + return filter.column.filterPredicate(row, filter.value.raw); + } + }); + data = parts[0]; + dataOut = [...dataOut, ...parts[1]]; + }); + + this.columnFiltersChanged.emit({ + filters: appliedFilters, + data: data, + dataOut: dataOut + }); + + // Remove the selection if previously-selected rows are filtered out. + _.forEach(this.selection.selected, (selectedItem) => { + if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) { + this.selection = new CdTableSelection(); + this.onSelect(this.selection); + } + }); + return data; + } + + ngOnDestroy() { + if (this.reloadSubscriber) { + this.reloadSubscriber.unsubscribe(); + } + if (this.saveSubscriber) { + this.saveSubscriber.unsubscribe(); + } + } + + ngAfterContentChecked() { + // If the data table is not visible, e.g. another tab is active, and the + // browser window gets resized, the table and its columns won't get resized + // automatically if the tab gets visible again. + // https://github.com/swimlane/ngx-datatable/issues/193 + // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543 + if (this.table && this.table.element.clientWidth !== this.currentWidth) { + this.currentWidth = this.table.element.clientWidth; + // Recalculate the sizes of the grid. + this.table.recalculate(); + // Mark the datatable as changed, Angular's change-detection will + // do the rest for us => the grid will be redrawn. + // Note, the ChangeDetectorRef variable is private, so we need to + // use this workaround to access it and make TypeScript happy. + const cdRef = _.get(this.table, 'cd'); + cdRef.markForCheck(); + } + } + + _addTemplates() { + this.cellTemplates.bold = this.tableCellBoldTpl; + this.cellTemplates.checkIcon = this.checkIconTpl; + this.cellTemplates.sparkline = this.sparklineTpl; + this.cellTemplates.routerLink = this.routerLinkTpl; + this.cellTemplates.perSecond = this.perSecondTpl; + this.cellTemplates.executing = this.executingTpl; + this.cellTemplates.classAdding = this.classAddingTpl; + this.cellTemplates.badge = this.badgeTpl; + this.cellTemplates.map = this.mapTpl; + this.cellTemplates.truncate = this.truncateTpl; + } + + useCustomClass(value: any): string { + if (!this.customCss) { + throw new Error('Custom classes are not set!'); + } + const classes = Object.keys(this.customCss); + const css = Object.values(this.customCss) + .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i]) + .filter((x) => x) + .join(' '); + return _.isEmpty(css) ? undefined : css; + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.data && changes.data.currentValue) { + this.useData(); + } + } + + setLimit(e: any) { + const value = Number(e.target.value); + if (value > 0) { + if (this.maxLimit && value > this.maxLimit) { + this.userConfig.limit = this.maxLimit; + // change input field to maxLimit + e.srcElement.value = this.maxLimit; + } else { + this.userConfig.limit = value; + } + } + if (this.serverSide) { + this.reloadData(); + } + } + + reloadData() { + if (!this.updating) { + this.status = new TableStatus(); + const context = new CdTableFetchDataContext(() => { + // Do we have to display the error panel? + if (!!context.errorConfig.displayError) { + this.status = new TableStatus('danger', $localize`Failed to load data.`); + } + // Force data table to show no data? + if (context.errorConfig.resetData) { + this.data = []; + } + // Stop the loading indicator and reset the data table + // to the correct state. + this.useData(); + }); + context.pageInfo.offset = this.userConfig.offset; + context.pageInfo.limit = this.userConfig.limit; + context.search = this.userConfig.search; + if (this.userConfig.sorts?.length) { + const sort = this.userConfig.sorts[0]; + context.sort = `${sort.dir === 'desc' ? '-' : '+'}${sort.prop}`; + } + this.fetchData.emit(context); + this.updating = true; + } + } + + refreshBtn() { + this.loadingIndicator = true; + this.reloadData(); + } + + changePage(pageInfo: PageInfo) { + this.userConfig.offset = pageInfo.offset; + this.userConfig.limit = pageInfo.limit; + if (this.serverSide) { + this.reloadData(); + } + } + rowIdentity() { + return (row: any) => { + const id = row[this.identifier]; + if (_.isUndefined(id)) { + throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`); + } + return id; + }; + } + + useData() { + if (!this.data) { + return; // Wait for data + } + this.updateColumnFilterOptions(); + this.updateFilter(); + this.reset(); + this.updateSelected(); + this.updateExpanded(); + } + + /** + * Reset the data table to correct state. This includes: + * - Disable loading indicator + * - Reset 'Updating' flag + */ + reset() { + this.loadingIndicator = false; + this.updating = false; + } + + /** + * After updating the data, we have to update the selected items + * because details may have changed, + * or some selected items may have been removed. + */ + updateSelected() { + if (this.updateSelectionOnRefresh === 'never') { + return; + } + const newSelected = new Set(); + this.selection.selected.forEach((selectedItem) => { + for (const row of this.data) { + if (selectedItem[this.identifier] === row[this.identifier]) { + newSelected.add(row); + } + } + }); + const newSelectedArray = Array.from(newSelected.values()); + if ( + this.updateSelectionOnRefresh === 'onChange' && + _.isEqual(this.selection.selected, newSelectedArray) + ) { + return; + } + this.selection.selected = newSelectedArray; + this.onSelect(this.selection); + } + + updateExpanded() { + if (_.isUndefined(this.expanded) || this.updateExpandedOnRefresh === 'never') { + return; + } + + const expandedId = this.expanded[this.identifier]; + const newExpanded = _.find(this.data, (row) => expandedId === row[this.identifier]); + + if (this.updateExpandedOnRefresh === 'onChange' && _.isEqual(this.expanded, newExpanded)) { + return; + } + + this.expanded = newExpanded; + this.setExpandedRow.emit(newExpanded); + } + + onSelect($event: any) { + // Ensure we do not process DOM 'select' events. + // https://github.com/swimlane/ngx-datatable/issues/899 + if (_.has($event, 'selected')) { + this.selection.selected = $event['selected']; + } + this.updateSelection.emit(_.clone(this.selection)); + } + + private singleSelectCheck(row: any) { + return this.selection.selected.indexOf(row) === -1; + } + + toggleColumn(column: CdTableColumn) { + const prop: TableColumnProp = column.prop; + const hide = !column.isHidden; + if (hide && this.tableColumns.length === 1) { + column.isHidden = true; + return; + } + _.find(this.localColumns, (c: CdTableColumn) => c.prop === prop).isHidden = hide; + this.updateColumns(); + } + + updateColumns() { + this.updateUserColumns(); + this.filterHiddenColumns(); + const sortProp = this.userConfig.sorts[0].prop; + if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) { + this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop); + } + this.table.recalculate(); + this.cdRef.detectChanges(); + } + + createSortingDefinition(prop: TableColumnProp): SortPropDir[] { + return [ + { + prop: prop, + dir: SortDirection.asc + } + ]; + } + + changeSorting({ sorts }: any) { + this.userConfig.sorts = sorts; + if (this.serverSide) { + this.userConfig.offset = 0; + this.reloadData(); + } + } + + onClearSearch() { + this.search = ''; + this.updateFilter(); + } + + onClearFilters() { + this.columnFilters.forEach((filter) => { + filter.value = undefined; + }); + this.selectedFilter = _.first(this.columnFilters); + this.updateFilter(); + } + + updateFilter() { + if (this.serverSide) { + if (this.userConfig.search !== this.search) { + // if we don't go back to the first page it will try load + // a page which could not exists with an especific search + this.userConfig.offset = 0; + this.userConfig.limit = this.limit; + this.userConfig.search = this.search; + this.updating = false; + this.reloadData(); + } + this.rows = this.data; + } else { + let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data; + + if (this.search.length > 0 && rows) { + const columns = this.localColumns.filter( + (c) => c.cellTransformation !== CellTemplate.sparkline + ); + // update the rows + rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns); + // Whenever the filter changes, always go back to the first page + this.table.offset = 0; + } + + this.rows = rows; + } + } + + subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] { + if (currentSearch.length === 0 || data.length === 0) { + return data; + } + const searchTerms: string[] = currentSearch.pop().replace(/\+/g, ' ').split(':'); + const columnsClone = [...columns]; + if (searchTerms.length === 2) { + columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1); + } + data = this.basicDataSearch(_.last(searchTerms), data, columns); + // Checks if user searches for column but he is still typing + return this.subSearch(data, currentSearch, columnsClone); + } + + basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) { + if (searchTerm.length === 0) { + return rows; + } + return rows.filter((row) => { + return ( + columns.filter((col) => { + let cellValue: any = _.get(row, col.prop); + + if (!_.isUndefined(col.pipe)) { + cellValue = col.pipe.transform(cellValue); + } + if (_.isUndefined(cellValue) || _.isNull(cellValue)) { + return false; + } + + if (_.isArray(cellValue)) { + cellValue = cellValue.join(' '); + } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) { + cellValue = cellValue.toString(); + } + + if (_.isObjectLike(cellValue)) { + if (this.searchableObjects) { + cellValue = JSON.stringify(cellValue); + } else { + return false; + } + } + + return cellValue.toLowerCase().indexOf(searchTerm) !== -1; + }).length > 0 + ); + }); + } + + getRowClass() { + // Return the function used to populate a row's CSS classes. + return () => { + return { + clickable: !_.isUndefined(this.selectionType) + }; + }; + } + + toggleExpandRow(row: any, isExpanded: boolean, event: any) { + event.stopPropagation(); + if (!isExpanded) { + // If current row isn't expanded, collapse others + this.expanded = row; + this.table.rowDetail.collapseAllRows(); + this.setExpandedRow.emit(row); + } else { + // If all rows are closed, emit undefined + this.expanded = undefined; + this.setExpandedRow.emit(undefined); + } + this.table.rowDetail.toggleExpandRow(row); + } +} |