From 2e7070a9039b5bbaaa5f598feea88b7a3df62b26 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 3 Sep 2019 19:31:16 +0300 Subject: [PATCH] Dashboard component implementation. --- ui-ngx/package-lock.json | 8 + ui-ngx/package.json | 1 + ui-ngx/src/app/core/http/widget.service.ts | 7 + ui-ngx/src/app/core/services/time.service.ts | 9 +- ui-ngx/src/app/core/utils.ts | 43 +++ .../dashboard/dashboard.component.html | 111 +++++++ .../dashboard/dashboard.component.scss | 125 +++++++ .../dashboard/dashboard.component.ts | 312 ++++++++++++++++++ .../home/components/home-components.module.ts | 7 +- ui-ngx/src/app/modules/home/home.component.ts | 3 - .../home/models/dashboard-component.models.ts | 310 +++++++++++++++++ .../home/models/widget-component.models.ts} | 30 +- .../widget/widget-library-routing.module.ts | 44 ++- .../widget/widget-library.component.html | 32 ++ .../widget/widget-library.component.scss | 24 ++ .../pages/widget/widget-library.component.ts | 194 +++++++++++ .../pages/widget/widget-library.module.ts | 4 +- .../widgets-bundles-table-config.resolver.ts | 4 +- .../animations/speed-dial-fab.animations.ts | 67 ++++ .../components/breadcrumb.component.html | 8 +- .../shared/components/breadcrumb.component.ts | 23 +- .../src/app/shared/components/breadcrumb.ts | 12 +- .../footer-fab-buttons.component.html | 39 +++ .../footer-fab-buttons.component.scss | 51 +++ .../footer-fab-buttons.component.ts | 85 +++++ .../components/time/timeinterval.component.ts | 2 + .../components/time/timewindow.component.scss | 4 + .../src/app/shared/models/dashboard.models.ts | 13 + ui-ngx/src/app/shared/models/widget.models.ts | 171 ++++++++++ ui-ngx/src/app/shared/shared.module.ts | 6 + ui-ngx/src/theme.scss | 10 + ui/package-lock.json | 41 ++- 32 files changed, 1753 insertions(+), 47 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts create mode 100644 ui-ngx/src/app/modules/home/models/dashboard-component.models.ts rename ui-ngx/src/app/{shared/models/widget-type.models.ts => modules/home/models/widget-component.models.ts} (58%) create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts create mode 100644 ui-ngx/src/app/shared/animations/speed-dial-fab.animations.ts create mode 100644 ui-ngx/src/app/shared/components/footer-fab-buttons.component.html create mode 100644 ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss create mode 100644 ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts create mode 100644 ui-ngx/src/app/shared/models/widget.models.ts diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json index 20cfca4c3f..26c4516940 100644 --- a/ui-ngx/package-lock.json +++ b/ui-ngx/package-lock.json @@ -1486,6 +1486,14 @@ "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", "dev": true }, + "angular-gridster2": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/angular-gridster2/-/angular-gridster2-8.1.0.tgz", + "integrity": "sha512-O3VrHj5iq2HwqQDlh/8LfPy4aLIRK1Kxm5iCCNOVFmnBLa6F//D5habPsiHRMTEkQ9vD+lFnU7+tORhf/y9LAg==", + "requires": { + "tslib": "^1.9.0" + } + }, "ansi-colors": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz", diff --git a/ui-ngx/package.json b/ui-ngx/package.json index b180dcfdc6..2d8a1a5d2b 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -32,6 +32,7 @@ "@ngx-translate/core": "^11.0.1", "@ngx-translate/http-loader": "^4.0.0", "ace-builds": "^1.4.5", + "angular-gridster2": "^8.1.0", "compass-sass-mixins": "^0.12.7", "core-js": "^3.1.4", "deep-equal": "^1.0.1", diff --git a/ui-ngx/src/app/core/http/widget.service.ts b/ui-ngx/src/app/core/http/widget.service.ts index efa85f5516..b6b1ec008c 100644 --- a/ui-ngx/src/app/core/http/widget.service.ts +++ b/ui-ngx/src/app/core/http/widget.service.ts @@ -21,6 +21,7 @@ import {HttpClient} from '@angular/common/http'; import {PageLink} from '@shared/models/page/page-link'; import {PageData} from '@shared/models/page/page-data'; import {WidgetsBundle} from '@shared/models/widgets-bundle.model'; +import { WidgetType } from '@shared/models/widget.models'; @Injectable({ providedIn: 'root' @@ -51,4 +52,10 @@ export class WidgetService { return this.http.delete(`/api/widgetsBundle/${widgetsBundleId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); } + public getBundleWidgetTypes(bundleAlias: string, isSystem: boolean, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/widgetTypes?isSystem=${isSystem}&bundleAlias=${bundleAlias}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + } diff --git a/ui-ngx/src/app/core/services/time.service.ts b/ui-ngx/src/app/core/services/time.service.ts index 070356abbc..69bd1f681c 100644 --- a/ui-ngx/src/app/core/services/time.service.ts +++ b/ui-ngx/src/app/core/services/time.service.ts @@ -15,7 +15,7 @@ /// import { Injectable } from '@angular/core'; -import { DAY, defaultTimeIntervals, SECOND } from '@shared/models/time/time.models'; +import { DAY, defaultTimeIntervals, MINUTE, SECOND, Timewindow } from '@shared/models/time/time.models'; import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; import {defaultHttpOptions} from '@core/http/http-utils'; @@ -81,6 +81,9 @@ export class TimeService { const intervals = this.getIntervals(min, max); let minDelta = MAX_INTERVAL; const boundedInterval = intervalMs || min; + if (!intervals.length) { + return boundedInterval; + } let matchedInterval: TimeInterval = intervals[0]; intervals.forEach((interval) => { const delta = Math.abs(interval.value - boundedInterval); @@ -110,6 +113,10 @@ export class TimeService { return this.boundMaxInterval(max); } + public defaultTimewindow(): Timewindow { + return Timewindow.defaultTimewindow(this); + } + private toBound(value: number, min: number, max: number, defValue: number): number { if (typeof value !== 'undefined') { value = Math.max(value, min); diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index bf848e6ceb..6df5484c7d 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -52,6 +52,32 @@ export function isLocalUrl(url: string): boolean { } } +export function animatedScroll(element: HTMLElement, scrollTop: number, delay?: number) { + let currentTime = 0; + const increment = 20; + const start = element.scrollTop; + const to = scrollTop; + const duration = delay ? delay : 0; + const remaining = to - start; + const animateScroll = () => { + currentTime += increment; + const val = easeInOut(currentTime, start, remaining, duration); + element.scrollTop = val; + if (currentTime < duration) { + setTimeout(animateScroll, increment); + } + }; + animateScroll(); +} + +export function isUndefined(value: any): boolean { + return typeof value === 'undefined'; +} + +export function isDefined(value: any): boolean { + return typeof value !== 'undefined'; +} + const scrollRegex = /(auto|scroll)/; function parentNodes(node: Node, nodes: Node[]): Node[] { @@ -95,3 +121,20 @@ function scrollParents(node: Node): Node[] { } return scrollParentNodes; } + +function easeInOut( + currentTime: number, + startTime: number, + remainingTime: number, + duration: number) { + currentTime /= duration / 2; + + if (currentTime < 1) { + return (remainingTime / 2) * currentTime * currentTime + startTime; + } + + currentTime--; + return ( + (-remainingTime / 2) * (currentTime * (currentTime - 2) - 1) + startTime + ); +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html new file mode 100644 index 0000000000..6017068f6a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -0,0 +1,111 @@ + +
+ + +
+
+
+ + +
+
+
+
+ TODO: +
+ + {{widget.titleIcon}} + {{widget.title}} + + +
+
+ + + + + + +
+
+
+ +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss new file mode 100644 index 0000000000..642cec4192 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + + .tb-progress-cover { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 6; + opacity: 1; + } + + .tb-dashboard-content { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: none; + outline: none; + + gridster-item { + transition: none; + overflow: visible; + } + } + + #gridster-child { + background: none; + } +} + +div.tb-widget { + position: relative; + height: 100%; + margin: 0; + overflow: hidden; + outline: none; + + transition: all .2s ease-in-out; + + .tb-widget-title { + max-height: 65px; + padding-top: 5px; + padding-left: 5px; + overflow: hidden; + + tb-timewindow { + font-size: 14px; + opacity: .85; + } + + .title { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + line-height: 24px; + letter-spacing: .01em; + margin: 0; + } + } + + .tb-widget-actions { + z-index: 19; + margin: 5px 0 0; + + &-absolute { + position: absolute; + top: 3px; + right: 8px; + } + + button.mat-icon-button { + width: 32px; + min-width: 32px; + height: 32px; + min-height: 32px; + padding: 0 !important; + margin: 0 !important; + line-height: 20px; + + mat-icon { + width: 20px; + min-width: 20px; + height: 20px; + min-height: 20px; + font-size: 20px; + line-height: 20px; + } + } + } + + .tb-widget-content { + tb-widget { + position: relative; + width: 100%; + } + } + + &.tb-highlighted { + border: 1px solid #039be5; + box-shadow: 0 0 20px #039be5; + } + + &.tb-not-highlighted { + opacity: .5; + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts new file mode 100644 index 0000000000..9c60de90e4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -0,0 +1,312 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit, Input, ViewChild, AfterViewInit, ViewChildren, QueryList, ElementRef } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { AuthUser } from '@shared/models/user.model'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Timewindow } from '@shared/models/time/time.models'; +import { TimeService } from '@core/services/time.service'; +import { GridsterComponent, GridsterConfig, GridsterItemComponent } from 'angular-gridster2'; +import { GridsterResizable } from 'angular-gridster2/lib/gridsterResizable.service'; +import { IDashboardComponent, DashboardConfig, DashboardWidget } from '../../models/dashboard-component.models'; +import { MatSort } from '@angular/material/sort'; +import { Observable, ReplaySubject, merge } from 'rxjs'; +import { map, share, tap } from 'rxjs/operators'; +import { WidgetLayout } from '@shared/models/dashboard.models'; +import { DialogService } from '@core/services/dialog.service'; +import { Widget } from '@app/shared/models/widget.models'; +import { MatTab } from '@angular/material/tabs'; +import { animatedScroll, isDefined } from '@app/core/utils'; +import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; +import { MediaBreakpoints } from '@shared/models/constants'; + +@Component({ + selector: 'tb-dashboard', + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.scss'] +}) +export class DashboardComponent extends PageComponent implements IDashboardComponent, OnInit, AfterViewInit { + + authUser: AuthUser; + + @Input() + options: DashboardConfig; + + gridsterOpts: GridsterConfig; + + dashboardLoading = true; + + highlightedMode = false; + highlightedWidget: DashboardWidget = null; + selectedWidget: DashboardWidget = null; + + isWidgetExpanded = false; + isMobileSize = false; + + @ViewChild('gridster', {static: true}) gridster: GridsterComponent; + + @ViewChildren(GridsterItemComponent) gridsterItems: QueryList; + + widgets$: Observable>; + + widgets: Array; + + constructor(protected store: Store, + private timeService: TimeService, + private dialogService: DialogService, + private breakpointObserver: BreakpointObserver) { + super(store); + this.authUser = getCurrentAuthUser(store); + } + + ngOnInit(): void { + if (!this.options.dashboardTimewindow) { + this.options.dashboardTimewindow = this.timeService.defaultTimewindow(); + } + this.gridsterOpts = { + gridType: 'scrollVertical', + keepFixedHeightInMobile: true, + pushItems: false, + swap: false, + maxRows: 100, + minCols: this.options.columns ? this.options.columns : 24, + outerMargin: true, + outerMarginLeft: this.options.margins ? this.options.margins[0] : 10, + outerMarginRight: this.options.margins ? this.options.margins[0] : 10, + outerMarginTop: this.options.margins ? this.options.margins[1] : 10, + outerMarginBottom: this.options.margins ? this.options.margins[1] : 10, + minItemCols: 1, + minItemRows: 1, + defaultItemCols: 8, + defaultItemRows: 6, + resizable: {enabled: this.options.isEdit}, + draggable: {enabled: this.options.isEdit} + }; + + this.updateGridsterOpts(); + + this.loadDashboard(); + + merge(this.breakpointObserver + .observe(MediaBreakpoints['gt-sm']), this.options.layoutChange$).subscribe( + () => { + this.updateGridsterOpts(); + this.sortWidgets(this.widgets); + } + ); + } + + loadDashboard() { + this.widgets$ = this.options.widgetsData.pipe( + map(widgetsData => { + const dashboardWidgets = new Array(); + let maxRows = this.gridsterOpts.maxRows; + widgetsData.widgets.forEach( + (widget) => { + let widgetLayout: WidgetLayout; + if (widgetsData.widgetLayouts && widget.id) { + widgetLayout = widgetsData.widgetLayouts[widget.id]; + } + const dashboardWidget = new DashboardWidget(this, widget, widgetLayout); + const bottom = dashboardWidget.y + dashboardWidget.rows; + maxRows = Math.max(maxRows, bottom); + dashboardWidgets.push(dashboardWidget); + } + ); + this.sortWidgets(dashboardWidgets); + this.gridsterOpts.maxRows = maxRows; + return dashboardWidgets; + }), + tap((widgets) => { + this.widgets = widgets; + this.dashboardLoading = false; + }) + ); + } + + reload() { + this.loadDashboard(); + } + + sortWidgets(widgets?: Array) { + if (widgets) { + widgets.sort((widget1, widget2) => { + const row1 = widget1.widgetOrder; + const row2 = widget2.widgetOrder; + let res = row1 - row2; + if (res === 0) { + res = widget1.x - widget2.x; + } + return res; + }); + } + } + + ngAfterViewInit(): void { + } + + isAutofillHeight(): boolean { + if (this.isMobileSize) { + return isDefined(this.options.mobileAutofillHeight) ? this.options.mobileAutofillHeight : false; + } else { + return isDefined(this.options.autofillHeight) ? this.options.autofillHeight : false; + } + } + + loading(): Observable { + return this.isLoading$.pipe( + map(loading => (!this.options.ignoreLoading && loading) || this.dashboardLoading), + share() + ); + } + + openDashboardContextMenu($event: Event) { + // TODO: + // this.dialogService.todo(); + } + + openWidgetContextMenu($event: Event, widget: DashboardWidget) { + // TODO: + // this.dialogService.todo(); + } + + onWidgetFullscreenChanged(expanded: boolean, widget: DashboardWidget) { + this.isWidgetExpanded = expanded; + } + + widgetMouseDown($event: Event, widget: DashboardWidget) { + if (this.options.onWidgetMouseDown) { + this.options.onWidgetMouseDown($event, widget.widget); + } + } + + widgetClicked($event: Event, widget: DashboardWidget) { + if (this.options.onWidgetClicked) { + this.options.onWidgetClicked($event, widget.widget); + } + } + + editWidget($event: Event, widget: DashboardWidget) { + if ($event) { + $event.stopPropagation(); + } + if (this.options.isEditActionEnabled && this.options.onEditWidget) { + this.options.onEditWidget($event, widget.widget); + } + } + + exportWidget($event: Event, widget: DashboardWidget) { + if ($event) { + $event.stopPropagation(); + } + if (this.options.isExportActionEnabled && this.options.onExportWidget) { + this.options.onExportWidget($event, widget.widget); + } + } + + removeWidget($event: Event, widget: DashboardWidget) { + if ($event) { + $event.stopPropagation(); + } + if (this.options.isRemoveActionEnabled && this.options.onRemoveWidget) { + this.options.onRemoveWidget($event, widget.widget); + } + } + + highlightWidget(widget: DashboardWidget, delay?: number) { + if (!this.highlightedMode || this.highlightedWidget !== widget) { + this.highlightedMode = true; + this.highlightedWidget = widget; + this.scrollToWidget(widget, delay); + } + } + + selectWidget(widget: DashboardWidget, delay?: number) { + if (this.selectedWidget !== widget) { + this.selectedWidget = widget; + this.scrollToWidget(widget, delay); + } + } + + resetHighlight() { + this.highlightedMode = false; + this.highlightedWidget = null; + this.selectedWidget = null; + } + + isHighlighted(widget: DashboardWidget) { + return (this.highlightedMode && this.highlightedWidget === widget) || (this.selectedWidget === widget); + } + + isNotHighlighted(widget: DashboardWidget) { + return this.highlightedMode && this.highlightedWidget !== widget; + } + + scrollToWidget(widget: DashboardWidget, delay?: number) { + if (this.gridsterItems) { + const gridsterItem = this.gridsterItems.find((item => item.item === widget)); + const offset = (this.gridster.curHeight - gridsterItem.height) / 2; + let scrollTop = gridsterItem.top; + if (offset > 0) { + scrollTop -= offset; + } + const parentElement = this.gridster.el as HTMLElement; + animatedScroll(parentElement, scrollTop, delay); + } + } + + private updateGridsterOpts() { + this.isMobileSize = this.checkIsMobileSize(); + const mobileBreakPoint = this.isMobileSize ? 20000 : 0; + this.gridsterOpts.mobileBreakpoint = mobileBreakPoint; + const rowSize = this.detectRowSize(this.isMobileSize); + if (this.gridsterOpts.fixedRowHeight !== rowSize) { + this.gridsterOpts.fixedRowHeight = rowSize; + } + if (this.isAutofillHeight()) { + this.gridsterOpts.gridType = 'fit'; + } else { + this.gridsterOpts.gridType = this.isMobileSize ? 'fixed' : 'scrollVertical'; + } + if (this.gridster && this.gridster.options) { + this.gridster.optionsChanged(); + } + } + + private detectRowSize(isMobile: boolean): number | null { + let rowHeight = null; + if (!this.isAutofillHeight()) { + if (isMobile) { + rowHeight = isDefined(this.options.mobileRowHeight) ? this.options.mobileRowHeight : 70; + } + } + return rowHeight; + } + + private checkIsMobileSize(): boolean { + const isMobileDisabled = this.options.isMobileDisabled === true; + let isMobileSize = this.options.isMobile === true && !isMobileDisabled; + if (!isMobileSize && !isMobileDisabled) { + isMobileSize = !this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm']); + } + return isMobileSize; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index d10e52dec8..18970ea7ad 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -34,6 +34,7 @@ import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-detail import { AttributeTableComponent } from '@home/components/attribute/attribute-table.component'; import { AddAttributeDialogComponent } from './attribute/add-attribute-dialog.component'; import { EditAttributeValuePanelComponent } from './attribute/edit-attribute-value-panel.component'; +import { DashboardComponent } from '@home/components/dashboard/dashboard.component'; @NgModule({ entryComponents: [ @@ -64,7 +65,8 @@ import { EditAttributeValuePanelComponent } from './attribute/edit-attribute-val AlarmDetailsDialogComponent, AttributeTableComponent, AddAttributeDialogComponent, - EditAttributeValuePanelComponent + EditAttributeValuePanelComponent, + DashboardComponent ], imports: [ CommonModule, @@ -81,7 +83,8 @@ import { EditAttributeValuePanelComponent } from './attribute/edit-attribute-val RelationTableComponent, AlarmTableComponent, AlarmDetailsDialogComponent, - AttributeTableComponent + AttributeTableComponent, + DashboardComponent ] }) export class HomeComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/home.component.ts b/ui-ngx/src/app/modules/home/home.component.ts index 13462e42e2..4d83abb163 100644 --- a/ui-ngx/src/app/modules/home/home.component.ts +++ b/ui-ngx/src/app/modules/home/home.component.ts @@ -54,9 +54,6 @@ export class HomeComponent extends PageComponent implements OnInit { authUser$: Observable; userDetails$: Observable; userDetailsString: Observable; - testUser1$: Observable; - testUser2$: Observable; - testUser3$: Observable; constructor(protected store: Store, private authService: AuthService, diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts new file mode 100644 index 0000000000..d7e7b664bd --- /dev/null +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -0,0 +1,310 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { GridsterConfig, GridsterItem, GridsterComponent } from 'angular-gridster2'; +import { Widget, widgetType } from '@app/shared/models/widget.models'; +import { WidgetLayout, WidgetLayouts } from '@app/shared/models/dashboard.models'; +import { WidgetAction, WidgetContext, WidgetHeaderAction } from './widget-component.models'; +import { Timewindow } from '@shared/models/time/time.models'; +import { Observable } from 'rxjs'; +import { isDefined, isUndefined } from '@app/core/utils'; +import { EventEmitter } from '@angular/core'; + +export interface IAliasController { + [key: string]: any | null; + // TODO: +} + +export interface WidgetsData { + widgets: Array; + widgetLayouts?: WidgetLayouts; +} + +export class DashboardConfig { + widgetsData?: Observable; + isEdit: boolean; + isEditActionEnabled: boolean; + isExportActionEnabled: boolean; + isRemoveActionEnabled: boolean; + onEditWidget?: ($event: Event, widget: Widget) => void; + onExportWidget?: ($event: Event, widget: Widget) => void; + onRemoveWidget?: ($event: Event, widget: Widget) => void; + onWidgetMouseDown?: ($event: Event, widget: Widget) => void; + onWidgetClicked?: ($event: Event, widget: Widget) => void; + aliasController?: IAliasController; + autofillHeight?: boolean; + mobileAutofillHeight?: boolean; + dashboardStyle?: {[klass: string]: any} | null; + columns?: number; + margins?: [number, number]; + dashboardTimewindow?: Timewindow; + ignoreLoading?: boolean; + dashboardClass?: string; + mobileRowHeight?: number; + + private isMobileValue: boolean; + private isMobileDisabledValue: boolean; + + private layoutChange = new EventEmitter(); + layoutChange$ = this.layoutChange.asObservable(); + layoutChangeTimeout = null; + + set isMobile(isMobile: boolean) { + if (this.isMobileValue !== isMobile) { + const changed = isDefined(this.isMobileValue); + this.isMobileValue = isMobile; + if (changed) { + this.notifyLayoutChanged(); + } + } + } + get isMobile(): boolean { + return this.isMobileValue; + } + + set isMobileDisabled(isMobileDisabled: boolean) { + if (this.isMobileDisabledValue !== isMobileDisabled) { + const changed = isDefined(this.isMobileDisabledValue); + this.isMobileDisabledValue = isMobileDisabled; + if (changed) { + this.notifyLayoutChanged(); + } + } + } + get isMobileDisabled(): boolean { + return this.isMobileDisabledValue; + } + + private notifyLayoutChanged() { + if (this.layoutChangeTimeout) { + clearTimeout(this.layoutChangeTimeout); + } + this.layoutChangeTimeout = setTimeout(() => { + this.doNotifyLayoutChanged(); + }, 0); + } + + private doNotifyLayoutChanged() { + this.layoutChange.emit(); + this.layoutChangeTimeout = null; + } +} + +export interface IDashboardComponent { + options: DashboardConfig; + gridsterOpts: GridsterConfig; + gridster: GridsterComponent; + isMobileSize: boolean; +} + +export class DashboardWidget implements GridsterItem { + + isFullscreen = false; + + color: string; + backgroundColor: string; + padding: string; + margin: string; + + title: string; + showTitle: boolean; + titleStyle: {[klass: string]: any}; + + titleIcon: string; + showTitleIcon: boolean; + titleIconStyle: {[klass: string]: any}; + + dropShadow: boolean; + enableFullscreen: boolean; + + hasTimewindow: boolean; + + hasAggregation: boolean; + + style: {[klass: string]: any}; + + hasWidgetTitleTemplate: boolean; + widgetTitleTemplate: string; + + showWidgetTitlePanel: boolean; + showWidgetActions: boolean; + + customHeaderActions: Array; + widgetActions: Array; + + widgetContext: WidgetContext = {}; + + constructor( + private dashboard: IDashboardComponent, + public widget: Widget, + private widgetLayout?: WidgetLayout) { + this.updateWidgetParams(); + } + + updateWidgetParams() { + this.color = this.widget.config.color || 'rgba(0, 0, 0, 0.87)'; + this.backgroundColor = this.widget.config.backgroundColor || '#fff'; + this.padding = this.widget.config.padding || '8px'; + this.margin = this.widget.config.margin || '0px'; + + this.title = isDefined(this.widgetContext.widgetTitle) + && this.widgetContext.widgetTitle.length ? this.widgetContext.widgetTitle : this.widget.config.title; + this.showTitle = isDefined(this.widget.config.showTitle) ? this.widget.config.showTitle : true; + this.titleStyle = this.widget.config.titleStyle ? this.widget.config.titleStyle : {}; + + this.titleIcon = isDefined(this.widget.config.titleIcon) ? this.widget.config.titleIcon : ''; + this.showTitleIcon = isDefined(this.widget.config.showTitleIcon) ? this.widget.config.showTitleIcon : false; + this.titleIconStyle = {}; + if (this.widget.config.iconColor) { + this.titleIconStyle.color = this.widget.config.iconColor; + } + if (this.widget.config.iconSize) { + this.titleIconStyle.fontSize = this.widget.config.iconSize; + } + + this.dropShadow = isDefined(this.widget.config.dropShadow) ? this.widget.config.dropShadow : true; + this.enableFullscreen = isDefined(this.widget.config.enableFullscreen) ? this.widget.config.enableFullscreen : true; + + this.hasTimewindow = (this.widget.type === widgetType.timeseries || this.widget.type === widgetType.alarm) ? + (isDefined(this.widget.config.useDashboardTimewindow) ? + (!this.widget.config.useDashboardTimewindow && (isUndefined(this.widget.config.displayTimewindow) + || this.widget.config.displayTimewindow)) : false) + : false; + + this.hasAggregation = this.widget.type === widgetType.timeseries; + + this.style = {cursor: 'pointer', + color: this.color, + backgroundColor: this.backgroundColor, + padding: this.padding, + margin: this.margin}; + if (this.widget.config.widgetStyle) { + this.style = {...this.widget.config.widgetStyle, ...this.style}; + } + + this.hasWidgetTitleTemplate = this.widgetContext.widgetTitleTemplate ? true : false; + this.widgetTitleTemplate = this.widgetContext.widgetTitleTemplate ? this.widgetContext.widgetTitleTemplate : ''; + + this.showWidgetTitlePanel = this.widgetContext.hideTitlePanel ? false : + this.hasWidgetTitleTemplate || this.showTitle || this.hasTimewindow; + + this.showWidgetActions = this.widgetContext.hideTitlePanel ? false : true; + + this.customHeaderActions = this.widgetContext.customHeaderActions ? this.widgetContext.customHeaderActions : []; + this.widgetActions = this.widgetContext.widgetActions ? this.widgetContext.widgetActions : []; + } + + get x(): number { + if (this.widgetLayout) { + return this.widgetLayout.col; + } else { + return this.widget.col; + } + } + + set x(x: number) { + if (!this.dashboard.isMobileSize) { + if (this.widgetLayout) { + this.widgetLayout.col = x; + } else { + this.widget.col = x; + } + } + } + + get y(): number { + if (this.widgetLayout) { + return this.widgetLayout.row; + } else { + return this.widget.row; + } + } + + set y(y: number) { + if (!this.dashboard.isMobileSize) { + if (this.widgetLayout) { + this.widgetLayout.row = y; + } else { + this.widget.row = y; + } + } + } + + get cols(): number { + if (this.widgetLayout) { + return this.widgetLayout.sizeX; + } else { + return this.widget.sizeX; + } + } + + set cols(cols: number) { + if (!this.dashboard.isMobileSize) { + if (this.widgetLayout) { + this.widgetLayout.sizeX = cols; + } else { + this.widget.sizeX = cols; + } + } + } + + get rows(): number { + if (this.dashboard.isMobileSize && !this.dashboard.options.mobileAutofillHeight) { + let mobileHeight; + if (this.widgetLayout) { + mobileHeight = this.widgetLayout.mobileHeight; + } + if (!mobileHeight && this.widget.config.mobileHeight) { + mobileHeight = this.widget.config.mobileHeight; + } + if (mobileHeight) { + return mobileHeight; + } else { + return this.widget.sizeY * 24 / this.dashboard.gridsterOpts.minCols; + } + } else { + if (this.widgetLayout) { + return this.widgetLayout.sizeY; + } else { + return this.widget.sizeY; + } + } + } + + set rows(rows: number) { + if (!this.dashboard.isMobileSize && !this.dashboard.options.autofillHeight) { + if (this.widgetLayout) { + this.widgetLayout.sizeY = rows; + } else { + this.widget.sizeY = rows; + } + } + } + + get widgetOrder(): number { + let order; + if (this.widgetLayout && isDefined(this.widgetLayout.mobileOrder) && this.widgetLayout.mobileOrder >= 0) { + order = this.widgetLayout.mobileOrder; + } else if (isDefined(this.widget.config.mobileOrder) && this.widget.config.mobileOrder >= 0) { + order = this.widget.config.mobileOrder; + } else if (this.widgetLayout) { + order = this.widgetLayout.row; + } else { + order = this.widget.row; + } + return order; + } +} diff --git a/ui-ngx/src/app/shared/models/widget-type.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts similarity index 58% rename from ui-ngx/src/app/shared/models/widget-type.models.ts rename to ui-ngx/src/app/modules/home/models/widget-component.models.ts index f081e0942f..4edc6101c3 100644 --- a/ui-ngx/src/app/shared/models/widget-type.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -14,20 +14,24 @@ /// limitations under the License. /// -import {BaseData} from '@shared/models/base-data'; -import {TenantId} from '@shared/models/id/tenant-id'; -import {WidgetsBundleId} from '@shared/models/id/widgets-bundle-id'; -import {WidgetTypeId} from '@shared/models/id/widget-type-id'; - -export interface WidgetTypeDescriptor { - todo: Array; - // TODO: +export interface IWidgetAction { + icon: string; + onAction: ($event: Event) => void; } -export interface WidgetType extends BaseData { - tenantId: TenantId; - bundleAlias: string; - alias: string; +export interface WidgetHeaderAction extends IWidgetAction { + displayName: string; +} + +export interface WidgetAction extends IWidgetAction { name: string; - descriptor: WidgetTypeDescriptor; + show: boolean; +} + +export interface WidgetContext { + widgetTitleTemplate?: string; + hideTitlePanel?: boolean; + widgetTitle?: string; + customHeaderActions?: Array; + widgetActions?: Array; } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts index f5915c8019..01c9784034 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts @@ -14,13 +14,35 @@ /// limitations under the License. /// -import {NgModule} from '@angular/core'; -import {RouterModule, Routes} from '@angular/router'; +import { Injectable, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterModule, Routes } from '@angular/router'; import {EntitiesTableComponent} from '../../components/entity/entities-table.component'; import {Authority} from '@shared/models/authority.enum'; import {RuleChainsTableConfigResolver} from '@modules/home/pages/rulechain/rulechains-table-config.resolver'; import {WidgetsBundlesTableConfigResolver} from '@modules/home/pages/widget/widgets-bundles-table-config.resolver'; +import { WidgetLibraryComponent } from '@home/pages/widget/widget-library.component'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { User } from '@shared/models/user.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UserService } from '@core/http/user.service'; +import { Observable } from 'rxjs'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { WidgetService } from '@core/http/widget.service'; + +@Injectable() +export class WidgetsBundleResolver implements Resolve { + + constructor(private widgetsService: WidgetService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + const widgetsBundleId = route.params.widgetsBundleId; + return this.widgetsService.getWidgetsBundle(widgetsBundleId); + } +} const routes: Routes = [ { @@ -42,6 +64,21 @@ const routes: Routes = [ resolve: { entitiesTableConfig: WidgetsBundlesTableConfigResolver } + }, + { + path: ':widgetsBundleId/widgetTypes', + component: WidgetLibraryComponent, + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widget.widget-library', + breadcrumb: { + labelFunction: ((route, translate) => route.data.widgetsBundle.title), + icon: 'now_widgets' + } as BreadCrumbConfig + }, + resolve: { + widgetsBundle: WidgetsBundleResolver + } } ] } @@ -51,7 +88,8 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule], providers: [ - WidgetsBundlesTableConfigResolver + WidgetsBundlesTableConfigResolver, + WidgetsBundleResolver ] }) export class WidgetLibraryRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html new file mode 100644 index 0000000000..c37b47d155 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html @@ -0,0 +1,32 @@ + +
+ + widgets-bundle.empty +
+ + + diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss new file mode 100644 index 0000000000..a4bed664d4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + button.tb-add-new-widget { + padding-right: 12px; + font-size: 24px; + border-style: dashed; + border-width: 2px; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts new file mode 100644 index 0000000000..86f15e6b93 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts @@ -0,0 +1,194 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { AuthUser } from '@shared/models/user.model'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { ActivatedRoute } from '@angular/router'; +import { Authority } from '@shared/models/authority.enum'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { Observable, of } from 'rxjs'; +import { toWidgetInfo, Widget, widgetType } from '@app/shared/models/widget.models'; +import { WidgetService } from '@core/http/widget.service'; +import { map, share } from 'rxjs/operators'; +import { DialogService } from '@core/services/dialog.service'; +import { speedDialFabAnimations } from '@shared/animations/speed-dial-fab.animations'; +import { FooterFabButtons } from '@app/shared/components/footer-fab-buttons.component'; +import { DashboardConfig } from '@home/models/dashboard-component.models'; + +@Component({ + selector: 'tb-widget-library', + templateUrl: './widget-library.component.html', + styleUrls: ['./widget-library.component.scss'] +}) +export class WidgetLibraryComponent extends PageComponent implements OnInit { + + authUser: AuthUser; + + isReadOnly: boolean; + + widgetsBundle: WidgetsBundle; + + widgetTypes$: Observable>; + + footerFabButtons: FooterFabButtons = { + fabTogglerName: 'widget.add-widget-type', + fabTogglerIcon: 'add', + buttons: [ + { + name: 'widget-type.create-new-widget-type', + icon: 'insert_drive_file', + onAction: ($event) => { + this.addWidgetType($event); + } + }, + { + name: 'widget-type.import', + icon: 'file_upload', + onAction: ($event) => { + this.importWidgetType($event); + } + } + ] + }; + + dashboardOptions: DashboardConfig = new DashboardConfig(); + + constructor(protected store: Store, + private route: ActivatedRoute, + private widgetService: WidgetService, + private dialogService: DialogService) { + super(store); + this.dashboardOptions.isEdit = false; + this.dashboardOptions.isEditActionEnabled = true; + this.dashboardOptions.isExportActionEnabled = true; + this.dashboardOptions.onEditWidget = ($event, widget) => { this.openWidgetType($event, widget); }; + this.dashboardOptions.onExportWidget = ($event, widget) => { this.exportWidgetType($event, widget); }; + this.dashboardOptions.onRemoveWidget = ($event, widget) => { this.removeWidgetType($event, widget); }; + + this.authUser = getCurrentAuthUser(store); + this.widgetsBundle = this.route.snapshot.data.widgetsBundle; + if (this.authUser.authority === Authority.TENANT_ADMIN) { + this.isReadOnly = !this.widgetsBundle || this.widgetsBundle.tenantId.id === NULL_UUID; + } else { + this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN; + } + this.dashboardOptions.isRemoveActionEnabled = !this.isReadOnly; + this.loadWidgetTypes(); + this.dashboardOptions.widgetsData = this.widgetTypes$.pipe( + map(widgets => ({ widgets }))); + } + + loadWidgetTypes() { + const bundleAlias = this.widgetsBundle.alias; + const isSystem = this.widgetsBundle.tenantId.id === NULL_UUID; + this.widgetTypes$ = this.widgetService.getBundleWidgetTypes(bundleAlias, + isSystem).pipe( + map((types) => { + types = types.sort((a, b) => { + let result = widgetType[b.descriptor.type].localeCompare(widgetType[a.descriptor.type]); + if (result === 0) { + result = b.createdTime - a.createdTime; + } + return result; + }); + const widgetTypes = new Array(types.length); + let top = 0; + const lastTop = [0, 0, 0]; + let col = 0; + let column = 0; + types.forEach((type) => { + const widgetTypeInfo = toWidgetInfo(type); + const sizeX = 8; + const sizeY = Math.floor(widgetTypeInfo.sizeY); + const widget: Widget = { + typeId: type.id, + isSystemType: isSystem, + bundleAlias, + typeAlias: widgetTypeInfo.alias, + type: widgetTypeInfo.type, + title: widgetTypeInfo.widgetName, + sizeX, + sizeY, + row: top, + col, + config: JSON.parse(widgetTypeInfo.defaultConfig) + }; + + widget.config.title = widgetTypeInfo.widgetName; + + widgetTypes.push(widget); + top += sizeY; + if (top > lastTop[column] + 10) { + lastTop[column] = top; + column++; + if (column > 2) { + column = 0; + } + top = lastTop[column]; + col = column * 8; + } + }); + return widgetTypes; + } + ), + share()); + } + + ngOnInit(): void { + } + + addWidgetType($event: Event): void { + this.openWidgetType($event); + } + + importWidgetType($event: Event): void { + if (event) { + event.stopPropagation(); + } + this.dialogService.todo(); + } + + openWidgetType($event: Event, widget?: Widget): void { + if (event) { + event.stopPropagation(); + } + if (widget) { + this.dialogService.todo(); + } else { + this.dialogService.todo(); + } + } + + exportWidgetType($event: Event, widget: Widget): void { + if (event) { + event.stopPropagation(); + } + this.dialogService.todo(); + } + + removeWidgetType($event: Event, widget: Widget): void { + if (event) { + event.stopPropagation(); + } + this.dialogService.todo(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts index 713d414dae..e0a1234fae 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts @@ -20,13 +20,15 @@ import {SharedModule} from '@shared/shared.module'; import {WidgetsBundleComponent} from '@modules/home/pages/widget/widgets-bundle.component'; import {WidgetLibraryRoutingModule} from '@modules/home/pages/widget/widget-library-routing.module'; import {HomeComponentsModule} from '@modules/home/components/home-components.module'; +import { WidgetLibraryComponent } from './widget-library.component'; @NgModule({ entryComponents: [ WidgetsBundleComponent ], declarations: [ - WidgetsBundleComponent + WidgetsBundleComponent, + WidgetLibraryComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts index cd35f42c19..47f7128af9 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts @@ -135,9 +135,7 @@ export class WidgetsBundlesTableConfigResolver implements Resolve *', animate('200ms cubic-bezier(0.4, 0.0, 0.2, 1)')), + ]), + trigger('speedDialStagger', [ + transition('* => *', [ + + query(':enter', style({ opacity: 0 }), {optional: true}), + + query(':enter', stagger('40ms', + [ + animate('200ms cubic-bezier(0.4, 0.0, 0.2, 1)', + keyframes( + [ + style({opacity: 0, transform: 'translateY(10px)'}), + style({opacity: 1, transform: 'translateY(0)'}), + ] + ) + ) + ] + ), {optional: true}), + + query(':leave', + animate('200ms cubic-bezier(0.4, 0.0, 0.2, 1)', + keyframes([ + style({opacity: 1}), + style({opacity: 0}), + ]) + ), {optional: true} + ) + + ]) + ]) +]; diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.html b/ui-ngx/src/app/shared/components/breadcrumb.component.html index 2a2de92b2b..7f895270b4 100644 --- a/ui-ngx/src/app/shared/components/breadcrumb.component.html +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.html @@ -16,7 +16,9 @@ -->
-

{{ (lastBreadcrumb$ | async).label | translate }}

+

+ {{ breadcrumb.ignoreTranslate ? breadcrumb.label : (breadcrumb.label | translate) }} +

@@ -24,7 +26,7 @@ {{ breadcrumb.icon }} - {{ breadcrumb.label | translate }} + {{ breadcrumb.ignoreTranslate ? breadcrumb.label : (breadcrumb.label | translate) }} @@ -32,7 +34,7 @@ {{ breadcrumb.icon }} - {{ breadcrumb.label | translate }} + {{ breadcrumb.ignoreTranslate ? breadcrumb.label : (breadcrumb.label | translate) }} > diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.ts b/ui-ngx/src/app/shared/components/breadcrumb.component.ts index 3e7fb18adc..1ea092d637 100644 --- a/ui-ngx/src/app/shared/components/breadcrumb.component.ts +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.ts @@ -16,9 +16,10 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { BehaviorSubject, Subject } from 'rxjs'; -import { BreadCrumb } from './breadcrumb'; +import { BreadCrumb, BreadCrumbConfig } from './breadcrumb'; import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: '[tb-breadcrumb]', @@ -40,7 +41,8 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { ); constructor(private router: Router, - private activatedRoute: ActivatedRoute) { + private activatedRoute: ActivatedRoute, + private translate: TranslateService) { } ngOnInit(): void { @@ -56,15 +58,24 @@ export class BreadcrumbComponent implements OnInit, OnDestroy { buildBreadCrumbs(route: ActivatedRouteSnapshot, breadcrumbs: Array = []): Array { let newBreadcrumbs = breadcrumbs; if (route.routeConfig && route.routeConfig.data) { - const breadcrumbData = route.routeConfig.data.breadcrumb; - if (breadcrumbData && !breadcrumbData.skip) { - const label = breadcrumbData.label || 'home.home'; - const icon = breadcrumbData.icon || 'home'; + const breadcrumbConfig = route.routeConfig.data.breadcrumb as BreadCrumbConfig; + if (breadcrumbConfig && !breadcrumbConfig.skip) { + let label; + let ignoreTranslate; + if (breadcrumbConfig.labelFunction) { + label = breadcrumbConfig.labelFunction(route, this.translate); + ignoreTranslate = true; + } else { + label = breadcrumbConfig.label || 'home.home'; + ignoreTranslate = false; + } + const icon = breadcrumbConfig.icon || 'home'; const isMdiIcon = icon.startsWith('mdi:'); const link = [ '/' + route.url.join('') ]; const queryParams = route.queryParams; const breadcrumb = { label, + ignoreTranslate, icon, isMdiIcon, link, diff --git a/ui-ngx/src/app/shared/components/breadcrumb.ts b/ui-ngx/src/app/shared/components/breadcrumb.ts index 0986bb4715..5db7b7b4c4 100644 --- a/ui-ngx/src/app/shared/components/breadcrumb.ts +++ b/ui-ngx/src/app/shared/components/breadcrumb.ts @@ -14,13 +14,23 @@ /// limitations under the License. /// -import { Params } from '@angular/router'; +import { ActivatedRouteSnapshot, Params } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; export interface BreadCrumb { label: string; + ignoreTranslate: boolean; icon: string; isMdiIcon: boolean; link: any[]; queryParams: Params; } +export type BreadCrumbLabelFunction = (route: ActivatedRouteSnapshot, translate: TranslateService) => string; + +export interface BreadCrumbConfig { + labelFunction: BreadCrumbLabelFunction; + label: string; + icon: string; + skip: boolean; +} diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html new file mode 100644 index 0000000000..5d98611cc6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html @@ -0,0 +1,39 @@ + + diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss new file mode 100644 index 0000000000..711dccd246 --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + section.tb-footer-buttons { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 30; + pointer-events: none; + + .fab-container { + display: flex; + flex-direction: column-reverse; + align-items: center; + > div { + display: flex; + flex-direction: column-reverse; + align-items: center; + margin-bottom: 5px; + + button { + margin-bottom: 17px; + } + } + } + + .tb-btn-footer { + position: relative !important; + display: inline-block !important; + animation: tbMoveFromBottomFade .3s ease both; + + &.tb-hide { + animation: tbMoveToBottomFade .3s ease both; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts new file mode 100644 index 0000000000..65b20b4f11 --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts @@ -0,0 +1,85 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, HostListener } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { speedDialFabAnimations } from '@shared/animations/speed-dial-fab.animations'; + +export interface FooterFabButton { + name: string; + icon: string; + onAction: ($event: Event) => void; +} + +export interface FooterFabButtons { + fabTogglerName: string; + fabTogglerIcon: string; + buttons: Array; +} + +@Component({ + selector: 'tb-footer-fab-buttons', + templateUrl: './footer-fab-buttons.component.html', + styleUrls: ['./footer-fab-buttons.component.scss'], + animations: speedDialFabAnimations +}) +export class FooterFabButtonsComponent extends PageComponent { + + @Input() + footerFabButtons: FooterFabButtons; + + buttons: Array = []; + fabTogglerState = 'inactive'; + + closeTimeout = null; + + @HostListener('focusout', ['$event']) + onFocusOut($event) { + if (!this.closeTimeout) { + this.closeTimeout = setTimeout(() => { + this.hideItems(); + }, 100); + } + } + + @HostListener('focusin', ['$event']) + onFocusIn($event) { + if (this.closeTimeout) { + clearTimeout(this.closeTimeout); + this.closeTimeout = null; + } + } + + constructor(protected store: Store) { + super(store); + } + + showItems() { + this.fabTogglerState = 'active'; + this.buttons = this.footerFabButtons.buttons; + } + + hideItems() { + this.fabTogglerState = 'inactive'; + this.buttons = []; + } + + onToggleFab() { + this.buttons.length ? this.hideItems() : this.showItems(); + } +} diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts index 16d72cea85..f6fa4ac54d 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts @@ -46,6 +46,7 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { set min(min: number) { if (typeof min !== 'undefined' && min !== this.minValue) { this.minValue = min; + this.maxValue = Math.max(this.maxValue, this.minValue); this.updateView(); } } @@ -54,6 +55,7 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { set max(max: number) { if (typeof max !== 'undefined' && max !== this.maxValue) { this.maxValue = max; + this.minValue = Math.min(this.minValue, this.maxValue); this.updateView(); } } diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.scss b/ui-ngx/src/app/shared/components/time/timewindow.component.scss index 0e267d0657..5c8b3eb56d 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.scss +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.scss @@ -14,7 +14,11 @@ * limitations under the License. */ :host { + min-width: 52px; section.tb-timewindow { + min-height: 32px; + padding: 0 6px; + span { overflow: hidden; text-overflow: ellipsis; diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts index 6204818ca4..5de95e2a1f 100644 --- a/ui-ngx/src/app/shared/models/dashboard.models.ts +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -25,6 +25,19 @@ export interface DashboardInfo extends BaseData { assignedCustomers: Array; } +export interface WidgetLayout { + sizeX: number; + sizeY: number; + mobileHeight: number; + mobileOrder: number; + col: number; + row: number; +} + +export interface WidgetLayouts { + [id: string]: WidgetLayout; +} + export interface DashboardConfiguration { [key: string]: any; // TODO: diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts new file mode 100644 index 0000000000..85516edf00 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -0,0 +1,171 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import {BaseData} from '@shared/models/base-data'; +import {TenantId} from '@shared/models/id/tenant-id'; +import {WidgetsBundleId} from '@shared/models/id/widgets-bundle-id'; +import {WidgetTypeId} from '@shared/models/id/widget-type-id'; +import { AliasEntityType, EntityType, EntityTypeTranslation } from '@shared/models/entity-type.models'; +import { Timewindow } from '@shared/models/time/time.models'; + +export enum widgetType { + timeseries = 'timeseries', + latest = 'latest', + rpc = 'rpc', + alarm = 'alarm' +} + +export interface WidgetTypeTemplate { + bundleAlias: string; + alias: string; +} + +export interface WidgetTypeData { + name: string; + template: WidgetTypeTemplate; +} + +export const widgetTypesData = new Map( + [ + [ + widgetType.timeseries, + { + name: 'widget.timeseries', + template: { + bundleAlias: 'charts', + alias: 'basic_timeseries' + } + } + ], + [ + widgetType.latest, + { + name: 'widget.latest-values', + template: { + bundleAlias: 'cards', + alias: 'attributes_card' + } + } + ], + [ + widgetType.rpc, + { + name: 'widget.rpc', + template: { + bundleAlias: 'gpio_widgets', + alias: 'basic_gpio_control' + } + } + ], + [ + widgetType.alarm, + { + name: 'widget.alarm', + template: { + bundleAlias: 'alarm_widgets', + alias: 'alarms_table' + } + } + ] + ] +); + +export interface WidgetResource { + url: string; +} + +export interface WidgetTypeDescriptor { + type: widgetType; + resources: Array; + templateHtml: string; + templateCss: string; + controllerScript: string; + settingsSchema: string; + dataKeySettingsSchema: string; + defaultConfig: string; + sizeX: number; + sizeY: number; +} + +export interface WidgetType extends BaseData { + tenantId: TenantId; + bundleAlias: string; + alias: string; + name: string; + descriptor: WidgetTypeDescriptor; +} + +export interface WidgetInfo extends WidgetTypeDescriptor { + widgetName: string; + alias: string; +} + +export function toWidgetInfo(widgetTypeEntity: WidgetType): WidgetInfo { + return { + widgetName: widgetTypeEntity.name, + alias: widgetTypeEntity.alias, + type: widgetTypeEntity.descriptor.type, + sizeX: widgetTypeEntity.descriptor.sizeX, + sizeY: widgetTypeEntity.descriptor.sizeY, + resources: widgetTypeEntity.descriptor.resources, + templateHtml: widgetTypeEntity.descriptor.templateHtml, + templateCss: widgetTypeEntity.descriptor.templateCss, + controllerScript: widgetTypeEntity.descriptor.controllerScript, + settingsSchema: widgetTypeEntity.descriptor.settingsSchema, + dataKeySettingsSchema: widgetTypeEntity.descriptor.dataKeySettingsSchema, + defaultConfig: widgetTypeEntity.descriptor.defaultConfig + }; +} + +export interface WidgetConfig { + title?: string; + titleIcon?: string; + showTitle?: boolean; + showTitleIcon?: boolean; + iconColor?: string; + iconSize?: number; + dropShadow?: boolean; + enableFullscreen?: boolean; + useDashboardTimewindow?: boolean; + displayTimewindow?: boolean; + timewindow?: Timewindow; + mobileHeight?: number; + mobileOrder?: number; + color?: string; + backgroundColor?: string; + padding?: string; + margin?: string; + widgetStyle?: {[klass: string]: any}; + titleStyle?: {[klass: string]: any}; + [key: string]: any; + + // TODO: +} + +export interface Widget { + id?: string; + typeId: WidgetTypeId; + isSystemType: boolean; + bundleAlias: string; + typeAlias: string; + type: widgetType; + title: string; + sizeX: number; + sizeY: number; + row: number; + col: number; + config: WidgetConfig; +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 44c5b27ac8..051d17c936 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -52,6 +52,7 @@ import { MatTooltipModule } from '@angular/material'; import {MatDatetimepickerModule, MatNativeDatetimeModule} from '@mat-datetimepicker/core'; +import {GridsterModule} from 'angular-gridster2'; import {FlexLayoutModule} from '@angular/flex-layout'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {RouterModule} from '@angular/router'; @@ -86,6 +87,7 @@ import {SocialSharePanelComponent} from './components/socialshare-panel.componen import { RelationTypeAutocompleteComponent } from '@shared/components/relation/relation-type-autocomplete.component'; import { EntityListSelectComponent } from './components/entity/entity-list-select.component'; import { JsonObjectEditComponent } from './components/json-object-edit.component'; +import { FooterFabButtonsComponent } from '@shared/components/footer-fab-buttons.component'; @NgModule({ providers: [ @@ -102,6 +104,7 @@ import { JsonObjectEditComponent } from './components/json-object-edit.component declarations: [ FooterComponent, LogoComponent, + FooterFabButtonsComponent, ToastDirective, FullscreenDirective, TbAnchorComponent, @@ -167,6 +170,7 @@ import { JsonObjectEditComponent } from './components/json-object-edit.component MatStepperModule, MatAutocompleteModule, MatChipsModule, + GridsterModule, ClipboardModule, FlexLayoutModule.withConfig({addFlexToParent: false}), FormsModule, @@ -177,6 +181,7 @@ import { JsonObjectEditComponent } from './components/json-object-edit.component exports: [ FooterComponent, LogoComponent, + FooterFabButtonsComponent, ToastDirective, FullscreenDirective, TbAnchorComponent, @@ -232,6 +237,7 @@ import { JsonObjectEditComponent } from './components/json-object-edit.component MatStepperModule, MatAutocompleteModule, MatChipsModule, + GridsterModule, ClipboardModule, FlexLayoutModule, FormsModule, diff --git a/ui-ngx/src/theme.scss b/ui-ngx/src/theme.scss index 861b998f35..ed82d0f56b 100644 --- a/ui-ngx/src/theme.scss +++ b/ui-ngx/src/theme.scss @@ -351,6 +351,16 @@ $tb-dark-theme: get-tb-dark-theme( .mat-icon { vertical-align: middle; + &.tb-mat-20 { + width: 20px; + height: 20px; + font-size: 20px; + svg { + width: 24px; + height: 24px; + transform: scale(0.83); + } + } &.tb-mat-32 { width: 32px; height: 32px; diff --git a/ui/package-lock.json b/ui/package-lock.json index e2fc919489..f2f85079de 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -6075,7 +6075,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -6096,12 +6097,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6116,17 +6119,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6243,7 +6249,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6255,6 +6262,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6269,6 +6277,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -6276,12 +6285,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6300,6 +6311,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6380,7 +6392,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6392,6 +6405,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6477,7 +6491,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6513,6 +6528,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6532,6 +6548,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6575,12 +6592,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } },