From 8ee3f0bf7e01c8269389b331a54ae25992079aa8 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 8 Nov 2019 14:17:15 +0200 Subject: [PATCH] Add Import/Export service. --- ui-ngx/src/app/core/http/entity.service.ts | 18 + .../app/core/services/item-buffer.service.ts | 7 +- .../alias/entity-aliases-dialog.component.ts | 13 +- .../home/components/home-components.module.ts | 14 +- .../import-dialog.component.html | 60 +++ .../import-export/import-dialog.component.ts | 107 ++++ .../import-export/import-export.models.ts | 23 + .../import-export/import-export.service.ts | 457 ++++++++++++++++++ .../dashboard/dashboard-page.component.ts | 20 +- .../dashboards-table-config.resolver.ts | 17 +- .../components/file-input.component.html | 45 ++ .../components/file-input.component.scss | 80 +++ .../shared/components/file-input.component.ts | 150 ++++++ .../components/image-input.component.html | 2 +- ui-ngx/src/app/shared/models/alias.models.ts | 7 +- ui-ngx/src/app/shared/shared.module.ts | 3 + 16 files changed, 991 insertions(+), 32 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts create mode 100644 ui-ngx/src/app/shared/components/file-input.component.html create mode 100644 ui-ngx/src/app/shared/components/file-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/file-input.component.ts diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index b8a0e200dc..644bede83c 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -782,6 +782,24 @@ export class EntityService { } } + public checkEntityAlias(entityAlias: EntityAlias): Observable { + return this.resolveAliasFilter(entityAlias.filter, null, 1, true).pipe( + map((result) => { + if (result.stateEntity) { + return true; + } else { + const entities = result.entities; + if (entities && entities.length) { + return true; + } else { + return false; + } + } + }), + catchError(err => of(false)) + ); + } + private entitiesToEntitiesInfo(entities: Array>): Array { const entitiesInfo = []; if (entities) { diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index a7dd4db778..2c1e041176 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { Dashboard, DashboardLayoutId } from '@app/shared/models/dashboard.models'; -import { EntityAlias, EntityAliasFilter, EntityAliases, EntityAliasInfo } from '@shared/models/alias.models'; +import { EntityAlias, EntityAliasFilter, EntityAliases, EntityAliasInfo, AliasesInfo } from '@shared/models/alias.models'; import { DatasourceType, Widget, WidgetPosition, WidgetSize } from '@shared/models/widget.models'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { deepClone } from '@core/utils'; @@ -29,11 +29,6 @@ const WIDGET_ITEM = 'widget_item'; const WIDGET_REFERENCE = 'widget_reference'; const RULE_NODES = 'rule_nodes'; -export interface AliasesInfo { - datasourceAliases: {[datasourceIndex: number]: EntityAliasInfo}; - targetDeviceAliases: {[targetDeviceAliasIndex: number]: EntityAliasInfo}; -} - export interface WidgetItem { widget: Widget; aliasesInfo: AliasesInfo; diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts index 305b35e1f4..095435bfa3 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts @@ -37,14 +37,14 @@ import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { DialogService } from '@core/services/dialog.service'; -import { deepClone } from '@core/utils'; +import { deepClone, isUndefined } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { EntityAliasDialogComponent, EntityAliasDialogData } from './entity-alias-dialog.component'; export interface EntityAliasesDialogData { entityAliases: EntityAliases; widgets: Array; - isSingleEntityAlias: boolean; + isSingleEntityAlias?: boolean; isSingleWidget?: boolean; allowedEntityTypes?: Array; disableAdd?: boolean; @@ -125,14 +125,13 @@ export class EntityAliasesDialogComponent extends DialogComponent = []; for (const aliasId of Object.keys(this.data.entityAliases)) { const entityAlias = this.data.entityAliases[aliasId]; - let filter = entityAlias.filter; - if (!filter) { - filter = { + if (!entityAlias.filter) { + entityAlias.filter = { resolveMultiple: false }; } - if (!filter.resolveMultiple) { - filter.resolveMultiple = false; + if (isUndefined(entityAlias.filter.resolveMultiple)) { + entityAlias.filter.resolveMultiple = false; } entityAliasControls.push(this.createEntityAliasFormControl(aliasId, entityAlias)); } 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 3409a20a3c..85e708b6c0 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 @@ -58,6 +58,8 @@ import { CustomActionPrettyResourcesTabsComponent } from './widget/action/custom import { CustomActionPrettyEditorComponent } from './widget/action/custom-action-pretty-editor.component'; import { CustomDialogService } from './widget/dialog/custom-dialog.service'; import { CustomDialogContainerComponent } from './widget/dialog/custom-dialog-container.component'; +import { ImportExportService } from './import-export/import-export.service'; +import { ImportDialogComponent } from './import-export/import-dialog.component'; @NgModule({ entryComponents: [ @@ -75,7 +77,8 @@ import { CustomDialogContainerComponent } from './widget/dialog/custom-dialog-co DataKeyConfigDialogComponent, LegendConfigPanelComponent, WidgetActionDialogComponent, - CustomDialogContainerComponent + CustomDialogContainerComponent, + ImportDialogComponent ], declarations: [ @@ -117,7 +120,8 @@ import { CustomDialogContainerComponent } from './widget/dialog/custom-dialog-co WidgetActionDialogComponent, CustomActionPrettyResourcesTabsComponent, CustomActionPrettyEditorComponent, - CustomDialogContainerComponent + CustomDialogContainerComponent, + ImportDialogComponent ], imports: [ CommonModule, @@ -154,11 +158,13 @@ import { CustomDialogContainerComponent } from './widget/dialog/custom-dialog-co WidgetActionDialogComponent, CustomActionPrettyResourcesTabsComponent, CustomActionPrettyEditorComponent, - CustomDialogContainerComponent + CustomDialogContainerComponent, + ImportDialogComponent ], providers: [ WidgetComponentService, - CustomDialogService + CustomDialogService, + ImportExportService ] }) export class HomeComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.html b/ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.html new file mode 100644 index 0000000000..404ca24792 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.html @@ -0,0 +1,60 @@ + +
+ +

{{ importTitle }}

+ + +
+ + +
+
+
+ + +
+
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.ts b/ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.ts new file mode 100644 index 0000000000..1b15984776 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.ts @@ -0,0 +1,107 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgForm, Validators, ValidatorFn +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { AttributeData } from '@shared/models/telemetry/telemetry.models'; +import { EntityAlias, EntityAliases, EntityAliasFilter } from '@shared/models/alias.models'; +import { DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DialogService } from '@core/services/dialog.service'; +import { EntityService } from '@core/http/entity.service'; +import { Observable, of } from 'rxjs'; + +export interface ImportDialogData { + importTitle: string; + importFileLabel: string; +} + +@Component({ + selector: 'tb-import-dialog', + templateUrl: './import-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ImportDialogComponent}], + styleUrls: [] +}) +export class ImportDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + importTitle: string; + importFileLabel: string; + + importFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ImportDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + this.importTitle = data.importTitle; + this.importFileLabel = data.importFileLabel; + + this.importFormGroup = this.fb.group({ + jsonContent: [null, [Validators.required]] + }); + } + + ngOnInit(): void { + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + loadDataFromJsonContent(content: string): any { + try { + const importData = JSON.parse(content); + return importData; + } catch (err) { + this.store.dispatch(new ActionNotificationShow({message: err.message, type: 'error'})); + return null; + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + importFromJson(): void { + this.submitted = true; + const importData = this.importFormGroup.get('jsonContent').value; + this.dialogRef.close(importData); + } +} diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts new file mode 100644 index 0000000000..24e152cb29 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts @@ -0,0 +1,23 @@ +/// +/// 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 { Widget } from '@app/shared/models/widget.models'; +import { DashboardLayoutId } from '@shared/models/dashboard.models'; + +export interface ImportWidgetResult { + widget: Widget; + layoutId: DashboardLayoutId; +} diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts new file mode 100644 index 0000000000..59905423f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts @@ -0,0 +1,457 @@ +/// +/// 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 { Inject, Injectable } from '@angular/core'; +import { DashboardService } from '@core/http/dashboard.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { Dashboard, DashboardLayoutId } from '@shared/models/dashboard.models'; +import { deepClone, isDefined, isObject, isUndefined } from '@core/utils'; +import { WINDOW } from '@core/services/window.service'; +import { DOCUMENT } from '@angular/common'; +import { + AliasesInfo, + AliasFilterType, + EntityAlias, + EntityAliases, + EntityAliasFilter, + EntityAliasInfo +} from '@shared/models/alias.models'; +import { MatDialog } from '@angular/material/dialog'; +import { ImportDialogComponent, ImportDialogData } from '@home/components/import-export/import-dialog.component'; +import { forkJoin, Observable, of } from 'rxjs'; +import { catchError, map, mergeMap } from 'rxjs/operators'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; +import { EntityService } from '@core/http/entity.service'; +import { Widget, WidgetSize } from '@shared/models/widget.models'; +import { + EntityAliasesDialogComponent, + EntityAliasesDialogData +} from '@home/components/alias/entity-aliases-dialog.component'; +import { ItemBufferService, WidgetItem } from '@core/services/item-buffer.service'; +import { ImportWidgetResult } from './import-export.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { UtilsService } from '@core/services/utils.service'; + +@Injectable() +export class ImportExportService { + + constructor(@Inject(WINDOW) private window: Window, + @Inject(DOCUMENT) private document: Document, + private store: Store, + private translate: TranslateService, + private dashboardService: DashboardService, + private dashboardUtils: DashboardUtilsService, + private entityService: EntityService, + private utils: UtilsService, + private itembuffer: ItemBufferService, + private dialog: MatDialog) { + + } + + public exportDashboard(dashboardId: string) { + this.dashboardService.getDashboard(dashboardId).subscribe( + (dashboard) => { + let name = dashboard.title; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(this.prepareDashboardExport(dashboard), name + '.json'); + }, + (e) => { + let message = e; + if (!message) { + message = this.translate.instant('error.unknown-error'); + } + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('dashboard.export-failed-error', {error: message}), + type: 'error'})); + } + ); + } + + public importDashboard(): Observable { + return this.openImportDialog('dashboard.import', 'dashboard.dashboard-file').pipe( + mergeMap((dashboard: Dashboard) => { + if (!this.validateImportedDashboard(dashboard)) { + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('dashboard.invalid-dashboard-file-error'), + type: 'error'})); + throw new Error('Invalid dashboard file'); + } else { + dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard); + let aliasIds = null; + const entityAliases = dashboard.configuration.entityAliases; + if (entityAliases) { + aliasIds = Object.keys(entityAliases); + } + if (aliasIds && aliasIds.length > 0) { + return this.processEntityAliases(entityAliases, aliasIds).pipe( + mergeMap((missingEntityAliases) => { + if (Object.keys(missingEntityAliases).length > 0) { + return this.editMissingAliases(this.dashboardUtils.getWidgetsArray(dashboard), + false, 'dashboard.dashboard-import-missing-aliases-title', + missingEntityAliases).pipe( + mergeMap((updatedEntityAliases) => { + for (const aliasId of Object.keys(updatedEntityAliases)) { + entityAliases[aliasId] = updatedEntityAliases[aliasId]; + } + return this.saveImportedDashboard(dashboard); + }) + ); + } else { + return this.saveImportedDashboard(dashboard); + } + } + )); + } else { + return this.saveImportedDashboard(dashboard); + } + } + }), + catchError((err) => { + return of(null); + }) + ); + } + + public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget) { + const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget); + let name = widgetItem.widget.config.title; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(this.prepareExport(widgetItem), name + '.json'); + } + + public importWidget(dashboard: Dashboard, targetState: string, + targetLayoutFunction: () => Observable, + onAliasesUpdateFunction: () => void): Observable { + return this.openImportDialog('dashboard.import-widget', 'dashboard.widget-file').pipe( + mergeMap((widgetItem: WidgetItem) => { + if (!this.validateImportedWidget(widgetItem)) { + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('dashboard.invalid-widget-file-error'), + type: 'error'})); + throw new Error('Invalid widget file'); + } else { + let widget = widgetItem.widget; + widget = this.dashboardUtils.validateAndUpdateWidget(widget); + const aliasesInfo = this.prepareAliasesInfo(widgetItem.aliasesInfo); + const originalColumns = widgetItem.originalColumns; + const originalSize = widgetItem.originalSize; + + const datasourceAliases = aliasesInfo.datasourceAliases; + const targetDeviceAliases = aliasesInfo.targetDeviceAliases; + if (datasourceAliases || targetDeviceAliases) { + const entityAliases: EntityAliases = {}; + const datasourceAliasesMap: {[aliasId: string]: number} = {}; + const targetDeviceAliasesMap: {[aliasId: string]: number} = {}; + let aliasId: string; + let datasourceIndex: number; + if (datasourceAliases) { + for (const strIndex of Object.keys(datasourceAliases)) { + datasourceIndex = Number(strIndex); + aliasId = this.utils.guid(); + datasourceAliasesMap[aliasId] = datasourceIndex; + entityAliases[aliasId] = {id: aliasId, ...datasourceAliases[datasourceIndex]}; + } + } + if (targetDeviceAliases) { + for (const strIndex of Object.keys(targetDeviceAliases)) { + datasourceIndex = Number(strIndex); + aliasId = this.utils.guid(); + targetDeviceAliasesMap[aliasId] = datasourceIndex; + entityAliases[aliasId] = {id: aliasId, ...targetDeviceAliases[datasourceIndex]}; + } + } + const aliasIds = Object.keys(entityAliases); + if (aliasIds.length > 0) { + return this.processEntityAliases(entityAliases, aliasIds).pipe( + mergeMap((missingEntityAliases) => { + if (Object.keys(missingEntityAliases).length > 0) { + return this.editMissingAliases([widget], + false, 'dashboard.widget-import-missing-aliases-title', + missingEntityAliases).pipe( + mergeMap((updatedEntityAliases) => { + for (const id of Object.keys(updatedEntityAliases)) { + const entityAlias = updatedEntityAliases[id]; + let index; + if (isDefined(datasourceAliasesMap[id])) { + index = datasourceAliasesMap[id]; + datasourceAliases[index] = entityAlias; + } else if (isDefined(targetDeviceAliasesMap[id])) { + index = targetDeviceAliasesMap[id]; + targetDeviceAliases[index] = entityAlias; + } + } + return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, + aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + } + )); + } else { + return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, + aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + } + } + ) + ); + } else { + return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, + aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + } + } else { + return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, + aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + } + } + }), + catchError((err) => { + return of(null); + }) + ); + } + + private validateImportedDashboard(dashboard: Dashboard): boolean { + if (isUndefined(dashboard.title) || isUndefined(dashboard.configuration)) { + return false; + } + return true; + } + + private validateImportedWidget(widgetItem: WidgetItem): boolean { + if (isUndefined(widgetItem.widget) + || isUndefined(widgetItem.aliasesInfo) + || isUndefined(widgetItem.originalColumns)) { + return false; + } + const widget = widgetItem.widget; + if (isUndefined(widget.isSystemType) || + isUndefined(widget.bundleAlias) || + isUndefined(widget.typeAlias) || + isUndefined(widget.type)) { + return false; + } + return true; + } + + private saveImportedDashboard(dashboard: Dashboard): Observable { + return this.dashboardService.saveDashboard(dashboard); + } + + private addImportedWidget(dashboard: Dashboard, targetState: string, + targetLayoutFunction: () => Observable, + widget: Widget, aliasesInfo: AliasesInfo, onAliasesUpdateFunction: () => void, + originalColumns: number, originalSize: WidgetSize): Observable { + return targetLayoutFunction().pipe( + mergeMap((targetLayout) => { + return this.itembuffer.addWidgetToDashboard(dashboard, targetState, targetLayout, + widget, aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize, -1, -1).pipe( + map(() => ({widget, layoutId: targetLayout} as ImportWidgetResult)) + ); + } + )); + } + + private processEntityAliases(entityAliases: EntityAliases, aliasIds: string[]): Observable { + const tasks: Observable[] = []; + for (const aliasId of aliasIds) { + const entityAlias = entityAliases[aliasId]; + tasks.push( + this.entityService.checkEntityAlias(entityAlias).pipe( + map((result) => { + if (!result) { + const missingEntityAlias = deepClone(entityAlias); + missingEntityAlias.filter = null; + return missingEntityAlias; + } + return null; + } + ) + ) + ); + } + return forkJoin(tasks).pipe( + map((missingAliasesArray) => { + missingAliasesArray = missingAliasesArray.filter(alias => alias !== null); + const missingEntityAliases: EntityAliases = {}; + for (const missingAlias of missingAliasesArray) { + missingEntityAliases[missingAlias.id] = missingAlias; + } + return missingEntityAliases; + } + ) + ); + } + + private editMissingAliases(widgets: Array, isSingleWidget: boolean, + customTitle: string, missingEntityAliases: EntityAliases): Observable { + return this.dialog.open(EntityAliasesDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityAliases: missingEntityAliases, + widgets, + customTitle, + isSingleWidget, + disableAdd: true + } + }).afterClosed().pipe( + map((updatedEntityAliases) => { + if (updatedEntityAliases) { + return updatedEntityAliases; + } else { + throw new Error('Unable to resolve missing entity aliases!'); + } + } + )); + } + + private prepareAliasesInfo(aliasesInfo: AliasesInfo): AliasesInfo { + const datasourceAliases = aliasesInfo.datasourceAliases; + const targetDeviceAliases = aliasesInfo.targetDeviceAliases; + if (datasourceAliases || targetDeviceAliases) { + if (datasourceAliases) { + for (const strIndex of Object.keys(datasourceAliases)) { + const datasourceIndex = Number(strIndex); + datasourceAliases[datasourceIndex] = this.prepareEntityAlias(datasourceAliases[datasourceIndex]); + } + } + if (targetDeviceAliases) { + for (const strIndex of Object.keys(targetDeviceAliases)) { + const datasourceIndex = Number(strIndex); + targetDeviceAliases[datasourceIndex] = this.prepareEntityAlias(targetDeviceAliases[datasourceIndex]); + } + } + } + return aliasesInfo; + } + + private prepareEntityAlias(aliasInfo: EntityAliasInfo): EntityAliasInfo { + let alias: string; + let filter: EntityAliasFilter; + if (aliasInfo.deviceId) { + alias = aliasInfo.aliasName; + filter = { + type: AliasFilterType.entityList, + entityType: EntityType.DEVICE, + entityList: [aliasInfo.deviceId], + resolveMultiple: false + }; + } else if (aliasInfo.deviceFilter) { + alias = aliasInfo.aliasName; + filter = { + type: aliasInfo.deviceFilter.useFilter ? AliasFilterType.entityName : AliasFilterType.entityList, + entityType: EntityType.DEVICE, + resolveMultiple: false + }; + if (filter.type === AliasFilterType.entityList) { + filter.entityList = aliasInfo.deviceFilter.deviceList; + } else { + filter.entityNameFilter = aliasInfo.deviceFilter.deviceNameFilter; + } + } else if (aliasInfo.entityFilter) { + alias = aliasInfo.aliasName; + filter = { + type: aliasInfo.entityFilter.useFilter ? AliasFilterType.entityName : AliasFilterType.entityList, + entityType: aliasInfo.entityType, + resolveMultiple: false + }; + if (filter.type === AliasFilterType.entityList) { + filter.entityList = aliasInfo.entityFilter.entityList; + } else { + filter.entityNameFilter = aliasInfo.entityFilter.entityNameFilter; + } + } else { + alias = aliasInfo.alias; + filter = aliasInfo.filter; + } + return { + alias, + filter + }; + } + + private openImportDialog(importTitle: string, importFileLabel: string): Observable { + return this.dialog.open(ImportDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + importTitle, + importFileLabel + } + }).afterClosed().pipe( + map((importedData) => { + if (importedData) { + return importedData; + } else { + throw new Error('No file selected!'); + } + } + )); + } + + private exportToPc(data: any, filename: string) { + if (!data) { + console.error('No data'); + return; + } + if (!filename) { + filename = 'download.json'; + } + if (isObject(data)) { + data = JSON.stringify(data, null, 2); + } + const blob = new Blob([data], {type: 'text/json'}); + if (this.window.navigator && this.window.navigator.msSaveOrOpenBlob) { + this.window.navigator.msSaveOrOpenBlob(blob, filename); + } else { + const e = this.document.createEvent('MouseEvents'); + const a = this.document.createElement('a'); + a.download = filename; + a.href = this.window.URL.createObjectURL(blob); + a.dataset.downloadurl = ['text/json', a.download, a.href].join(':'); + // @ts-ignore + e.initEvent('click', true, false, this.window, + 0, 0, 0, 0, 0, false, false, false, false, 0, null); + a.dispatchEvent(e); + } + } + + private prepareDashboardExport(dashboard: Dashboard): Dashboard { + dashboard = this.prepareExport(dashboard); + delete dashboard.assignedCustomers; + return dashboard; + } + + private prepareExport(data: any): any { + const exportedData = deepClone(data); + if (isDefined(exportedData.id)) { + delete exportedData.id; + } + if (isDefined(exportedData.createdTime)) { + delete exportedData.createdTime; + } + if (isDefined(exportedData.tenantId)) { + delete exportedData.tenantId; + } + if (isDefined(exportedData.customerId)) { + delete exportedData.customerId; + } + return exportedData; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts index 29901bd857..bda1f6fa30 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts @@ -83,6 +83,7 @@ import { ManageDashboardStatesDialogComponent, ManageDashboardStatesDialogData } from '@home/pages/dashboard/states/manage-dashboard-states-dialog.component'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; @Component({ selector: 'tb-dashboard-page', @@ -222,6 +223,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC private widgetComponentService: WidgetComponentService, private dashboardService: DashboardService, private itembuffer: ItemBufferService, + private importExport: ImportExportService, private fb: FormBuilder, private dialog: MatDialog, private translate: TranslateService, @@ -459,8 +461,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC if ($event) { $event.stopPropagation(); } - // TODO: - this.dialogService.todo(); + this.importExport.exportDashboard(this.currentDashboardId); } public openEntityAliases($event: Event) { @@ -569,8 +570,16 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC if ($event) { $event.stopPropagation(); } - // TODO: - this.dialogService.todo(); + this.importExport.importWidget(this.dashboard, this.dashboardCtx.state, + this.selectTargetLayout.bind(this), this.entityAliasesUpdated.bind(this)).subscribe( + (importData) => { + if (importData) { + const widget = importData.widget; + const layoutId = importData.layoutId; + this.layouts[layoutId].layoutCtx.widgets.addWidgetId(widget.id); + } + } + ); } public currentDashboardIdChanged(dashboardId: string) { @@ -915,8 +924,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget) { $event.stopPropagation(); - // TODO: - this.dialogService.todo(); + this.importExport.exportWidget(this.dashboard, this.dashboardCtx.state, layoutCtx.id, widget); } widgetClicked($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget) { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts index 93335e7aa2..e7d2070d91 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts @@ -63,6 +63,7 @@ import { MakeDashboardPublicDialogData } from '@modules/home/pages/dashboard/make-dashboard-public-dialog.component'; import { DashboardTabsComponent } from '@home/pages/dashboard/dashboard-tabs.component'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; @Injectable() export class DashboardsTableConfigResolver implements Resolve> { @@ -73,6 +74,7 @@ export class DashboardsTableConfigResolver implements Resolve { + if (dashboard) { + this.config.table.updateData(); + } + } + ); } exportDashboard($event: Event, dashboard: DashboardInfo) { if ($event) { $event.stopPropagation(); } - // TODO: - this.dialogService.todo(); + this.importExport.exportDashboard(dashboard.id.id); } addDashboardsToCustomer($event: Event) { diff --git a/ui-ngx/src/app/shared/components/file-input.component.html b/ui-ngx/src/app/shared/components/file-input.component.html new file mode 100644 index 0000000000..0446834e92 --- /dev/null +++ b/ui-ngx/src/app/shared/components/file-input.component.html @@ -0,0 +1,45 @@ + +
+ + +
+
+ +
+
+ + +
+
+
+
+
+
import.no-file
+
{{ fileName }}
+
diff --git a/ui-ngx/src/app/shared/components/file-input.component.scss b/ui-ngx/src/app/shared/components/file-input.component.scss new file mode 100644 index 0000000000..d2e2d4f3c1 --- /dev/null +++ b/ui-ngx/src/app/shared/components/file-input.component.scss @@ -0,0 +1,80 @@ +/** + * 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"; + +$previewSize: 100px !default; + +:host { + + .tb-container { + margin-top: 0px; + label.tb-title { + display: block; + padding-bottom: 8px; + } + } + + .tb-file-select-container { + position: relative; + width: 100%; + height: $previewSize; + } + + .tb-file-preview { + width: auto; + max-width: $previewSize; + height: auto; + max-height: $previewSize; + } + + .tb-file-clear-container { + position: relative; + float: right; + width: 48px; + height: $previewSize; + } + + .tb-file-clear-btn { + position: absolute !important; + top: 50%; + transform: translate(0%, -50%) !important; + } + + .file-input { + display: none; + } + + .tb-flow-drop { + position: relative; + height: $previewSize; + overflow: hidden; + border: dashed 2px; + + label { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + font-size: 16px; + text-align: center; + + @media #{$mat-gt-sm} { + font-size: 24px; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts new file mode 100644 index 0000000000..c7275d6e46 --- /dev/null +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -0,0 +1,150 @@ +/// +/// 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 { AfterViewInit, Component, forwardRef, Input, OnDestroy, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { FlowDirective } from '@flowjs/ngx-flow'; + +@Component({ + selector: 'tb-file-input', + templateUrl: './file-input.component.html', + styleUrls: ['./file-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FileInputComponent), + multi: true + } + ] +}) +export class FileInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { + + @Input() + label: string; + + @Input() + accept = '*/*'; + + @Input() + allowedExtensions: string; + + @Input() + dropLabel: string; + + @Input() + contentConvertFunction: (content: string) => any; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + } + } + + @Input() + disabled: boolean; + + fileName: string; + fileContent: any; + + @ViewChild('flow', {static: true}) + flow: FlowDirective; + + autoUploadSubscription: Subscription; + + private propagateChange = null; + + constructor(protected store: Store) { + super(store); + } + + ngAfterViewInit() { + this.autoUploadSubscription = this.flow.events$.subscribe(event => { + if (event.type === 'fileAdded') { + const file = event.event[0] as flowjs.FlowFile; + if (this.filterFile(file)) { + const reader = new FileReader(); + reader.onload = (loadEvent) => { + if (typeof reader.result === 'string') { + const fileContent = reader.result; + if (fileContent && fileContent.length > 0) { + if (this.contentConvertFunction) { + this.fileContent = this.contentConvertFunction(fileContent); + } else { + this.fileContent = fileContent; + } + if (this.fileContent) { + this.fileName = file.name; + } else { + this.fileName = null; + } + this.updateModel(); + } + } + }; + reader.readAsText(file.file); + } + } + }); + } + + private filterFile(file: flowjs.FlowFile): boolean { + if (this.allowedExtensions) { + return this.allowedExtensions.split(',').indexOf(file.getExtension()) > -1; + } else { + return true; + } + } + + ngOnDestroy() { + this.autoUploadSubscription.unsubscribe(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + this.fileName = null; + } + + private updateModel() { + this.propagateChange(this.fileContent); + } + + clearFile() { + this.fileName = null; + this.fileContent = null; + this.updateModel(); + } +} diff --git a/ui-ngx/src/app/shared/components/image-input.component.html b/ui-ngx/src/app/shared/components/image-input.component.html index 6b306552ba..ab49bf213c 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.html +++ b/ui-ngx/src/app/shared/components/image-input.component.html @@ -18,7 +18,7 @@
+ [flowConfig]="{singleFile: true, allowDuplicateUploads: true}">
dashboard.no-image
diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index d6e356580a..419e01b9c0 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -135,11 +135,16 @@ export interface EntityAliasFilter extends EntityFilters { export interface EntityAliasInfo { alias: string; filter: EntityAliasFilter; + [key: string]: any; +} + +export interface AliasesInfo { + datasourceAliases: {[datasourceIndex: number]: EntityAliasInfo}; + targetDeviceAliases: {[targetDeviceAliasIndex: number]: EntityAliasInfo}; } export interface EntityAlias extends EntityAliasInfo { id: string; - [key: string]: any; } export interface EntityAliases { diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 9495b3b339..d5d4bd73e4 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -111,6 +111,7 @@ import { JsonFormComponent } from './components/json-form/json-form.component'; import { MaterialIconsDialogComponent } from '@shared/components/dialog/material-icons-dialog.component'; import { MaterialIconSelectComponent } from '@shared/components/material-icon-select.component'; import { ImageInputComponent } from './components/image-input.component'; +import { FileInputComponent } from './components/file-input.component'; @NgModule({ providers: [ @@ -181,6 +182,7 @@ import { ImageInputComponent } from './components/image-input.component'; MaterialIconSelectComponent, JsonFormComponent, ImageInputComponent, + FileInputComponent, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -318,6 +320,7 @@ import { ImageInputComponent } from './components/image-input.component'; MaterialIconSelectComponent, JsonFormComponent, ImageInputComponent, + FileInputComponent, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe,