diff --git a/ui-ngx/src/app/core/http/image.service.ts b/ui-ngx/src/app/core/http/image.service.ts index e13acb0b3e..cde43b0d8b 100644 --- a/ui-ngx/src/app/core/http/image.service.ts +++ b/ui-ngx/src/app/core/http/image.service.ts @@ -25,7 +25,7 @@ import { ImageResourceInfo, imageResourceType, ImageResourceType, - IMAGES_URL_PREFIX, isImageResourceUrl + IMAGES_URL_PREFIX, isImageResourceUrl, ImageExportData } from '@shared/models/resource.models'; import { catchError, map, switchMap } from 'rxjs/operators'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; @@ -133,8 +133,18 @@ export class ImageService { ); } - public deleteImage(type: ImageResourceType, key: string, config?: RequestConfig) { - return this.http.delete(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, defaultHttpOptionsFromConfig(config)); + public deleteImage(type: ImageResourceType, key: string, force = false, config?: RequestConfig) { + return this.http.delete(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}?force=${force}`, defaultHttpOptionsFromConfig(config)); + } + + public exportImage(type: ImageResourceType, key: string, config?: RequestConfig): Observable { + return this.http.get(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}/export`, + defaultHttpOptionsFromConfig(config)); + } + + public importImage(imageData: ImageExportData, config?: RequestConfig): Observable { + return this.http.put('/api/image/import', + imageData, defaultHttpOptionsFromConfig(config)); } } diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.html b/ui-ngx/src/app/modules/home/components/details-panel.component.html index 748723197f..bbb0e2006e 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.html @@ -56,7 +56,7 @@ - - + + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/image/images-in-use-dialog.component.scss b/ui-ngx/src/app/modules/home/components/image/images-in-use-dialog.component.scss new file mode 100644 index 0000000000..a53c97404d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/images-in-use-dialog.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2023 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 { + .tb-images-in-use-content { + display: flex; + flex-direction: column; + &.multiple { + gap: 16px; + } + .table-container { + overflow: auto; + .mat-mdc-cell.mat-column-preview { + width: 50px; + height: 50px; + padding: 2px 12px; + display: block; + } + } + .tb-image-preview { + width: 50px; + height: 50px; + object-fit: contain; + border-radius: 4px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/image/images-in-use-dialog.component.ts b/ui-ngx/src/app/modules/home/components/image/images-in-use-dialog.component.ts new file mode 100644 index 0000000000..9e1e7708c9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/image/images-in-use-dialog.component.ts @@ -0,0 +1,112 @@ +/// +/// Copyright © 2016-2023 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, Renderer2, ViewContainerRef } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { ImageReferences, ImageResourceInfo, ImageResourceInfoWithReferences } from '@shared/models/resource.models'; +import { ImagesDatasource } from '@home/components/image/images-datasource'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { ImageReferencesComponent } from '@home/components/image/image-references.component'; +import { TranslateService } from '@ngx-translate/core'; + +export interface ImagesInUseDialogData { + multiple: boolean; + images: ImageResourceInfoWithReferences[]; +} + +@Component({ + selector: 'tb-images-in-use-dialog', + templateUrl: './images-in-use-dialog.component.html', + styleUrls: ['./images-in-use-dialog.component.scss'] +}) +export class ImagesInUseDialogComponent extends + DialogComponent implements OnInit { + + title: string; + message: string; + + references: ImageReferences; + + dataSource: ImagesDatasource; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ImagesInUseDialogData, + public dialogRef: MatDialogRef, + public translate: TranslateService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private popoverService: TbPopoverService) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + if (this.data.multiple) { + this.title = 'image.images-are-in-use'; + this.message = 'image.images-are-in-use-text'; + this.dataSource = new ImagesDatasource(null, this.data.images, entity => true); + } else { + this.title = 'image.image-is-in-use'; + this.message = 'image.image-is-in-use-text'; + this.references = this.data.images[0].references; + } + } + + cancel() { + this.dialogRef.close(null); + } + + delete() { + if (this.data.multiple) { + this.dialogRef.close(this.dataSource.selection.selected); + } else { + this.dialogRef.close(this.data.images); + } + } + + toggleShowReferences($event: Event, image: ImageResourceInfoWithReferences, referencesButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = referencesButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const referencesPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ImageReferencesComponent, 'top', true, null, + { + references: image.references + }, {}, {}, {}, + false, + visible => { + const addClasses = visible ? 'mdc-button--unelevated mat-mdc-unelevated-button' : 'mdc-button--outlined mat-mdc-outlined-button'; + const removeClasses = visible ? 'mdc-button--outlined mat-mdc-outlined-button' : 'mdc-button--unelevated mat-mdc-unelevated-button'; + addClasses.split(' ').forEach(clazz => { + this.renderer.addClass(trigger, clazz); + }); + removeClasses.split(' ').forEach(clazz => { + this.renderer.removeClass(trigger, clazz); + }); + }); + referencesPopover.tbComponentRef.instance.popoverComponent = referencesPopover; + } + } +} 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 index 620c1aaab3..1409be7c1a 100644 --- 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 @@ -21,7 +21,7 @@ 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, guid, isDefined, isObject, isString, isUndefined } from '@core/utils'; +import { deepClone, guid, isDefined, isNotEmptyStr, isObject, isString, isUndefined } from '@core/utils'; import { WINDOW } from '@core/services/window.service'; import { DOCUMENT } from '@angular/common'; import { @@ -79,6 +79,8 @@ import { ExportWidgetsBundleDialogData, ExportWidgetsBundleDialogResult } from '@home/components/import-export/export-widgets-bundle-dialog.component'; +import { ImageService } from '@core/http/image.service'; +import { ImageExportData, ImageResourceInfo, ImageResourceType } from '@shared/models/resource.models'; // @dynamic @Injectable() @@ -99,12 +101,44 @@ export class ImportExportService { private deviceService: DeviceService, private assetService: AssetService, private edgeService: EdgeService, + private imageService: ImageService, private utils: UtilsService, private itembuffer: ItemBufferService, private dialog: MatDialog) { } + public exportImage(type: ImageResourceType, key: string) { + this.imageService.exportImage(type, key).subscribe( + { + next: (imageData) => { + let name = imageData.title; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(imageData, name); + }, + error: (e) => { + this.handleExportError(e, 'image.export-failed-error'); + } + } + ); + } + + public importImage(): Observable { + return this.openImportDialog('image.import-image', 'image.image-json-file').pipe( + mergeMap((imageData: ImageExportData) => { + if (!this.validateImportedImage(imageData)) { + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('image.invalid-image-json-file-error'), + type: 'error'})); + throw new Error('Invalid image JSON file'); + } else { + return this.imageService.importImage(imageData); + } + }), + catchError(() => of(null)) + ); + } + public exportDashboard(dashboardId: string) { this.dashboardService.getDashboard(dashboardId).subscribe( (dashboard) => { @@ -818,6 +852,14 @@ export class ImportExportService { type: 'error'})); } + private validateImportedImage(image: ImageExportData): boolean { + return !(!isNotEmptyStr(image.data) + || !isNotEmptyStr(image.title) + || !isNotEmptyStr(image.fileName) + || !isNotEmptyStr(image.mediaType) + || !isNotEmptyStr(image.resourceKey)); + } + private validateImportedDashboard(dashboard: Dashboard): boolean { if (isUndefined(dashboard.title) || isUndefined(dashboard.configuration)) { return false; 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 8519b9d4d0..9b14558086 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 @@ -31,6 +31,8 @@ import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { RouterTabsComponent } from '@home/components/router-tabs.component'; import { WidgetTypesTableConfigResolver } from '@home/pages/widget/widget-types-table-config.resolver'; import { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; export interface WidgetEditorData { widgetTypeDetails: WidgetTypeDetails; @@ -122,6 +124,29 @@ const widgetTypesRoutes: Routes = [ entitiesTableConfig: WidgetTypesTableConfigResolver } }, + { + path: 'details', + children: [ + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'now_widgets' + } as BreadCrumbConfig, + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widget.widgets', + hideTabs: true, + backNavigationCommands: ['../..'] + }, + resolve: { + entitiesTableConfig: WidgetTypesTableConfigResolver + } + } + ] + }, ...widgetEditorRouter ] }, @@ -148,6 +173,29 @@ const widgetsBundlesRoutes: Routes = [ entitiesTableConfig: WidgetsBundlesTableConfigResolver } }, + { + path: 'details', + children: [ + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'now_widgets' + } as BreadCrumbConfig, + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widgets-bundle.widgets-bundles', + hideTabs: true, + backNavigationCommands: ['../..'] + }, + resolve: { + entitiesTableConfig: WidgetsBundlesTableConfigResolver + } + } + ] + }, { path: ':widgetsBundleId', data: { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html index 9db9780b70..46068bf9f8 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html @@ -22,6 +22,12 @@ [fxShow]="!isEdit"> {{ 'widget.edit' | translate }} + +