diff --git a/ui-ngx/src/app/app.module.ts b/ui-ngx/src/app/app.module.ts index e18c909c9a..a51fe8205a 100644 --- a/ui-ngx/src/app/app.module.ts +++ b/ui-ngx/src/app/app.module.ts @@ -26,6 +26,8 @@ import { HomeModule } from '@home/home.module'; import { AppComponent } from './app.component'; import { DashboardRoutingModule } from '@modules/dashboard/dashboard-routing.module'; import { RouterModule, Routes } from '@angular/router'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { EntityConflictInterceptor } from '@shared/interceptors/entity-conflict.interceptor'; const routes: Routes = [ { path: '**', @@ -55,7 +57,9 @@ export class PageNotFoundRoutingModule { } DashboardRoutingModule, PageNotFoundRoutingModule ], - providers: [], + providers: [ + { provide: HTTP_INTERCEPTORS, useClass: EntityConflictInterceptor, multi: true } + ], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.html new file mode 100644 index 0000000000..c500923723 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.html @@ -0,0 +1,53 @@ + + +

{{ 'entity.version-conflict.label' | translate }}

+ + +
+
+
+ {{ data.message }}. + + {{ 'entity.version-conflict.link' | translate: + { entityType: (entityTypeTranslations.get(data.entityId.entityType).type | translate) } + }} + {{ 'entity.link' | translate }}. + + {{ 'entity.version-conflict.message' | translate }} +
+
+
+ + +
diff --git a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.scss new file mode 100644 index 0000000000..93673a1803 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.scss @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2024 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. + */ +$conflict-dialog-width: 700px; + +:host { + .main-label { + padding-left: 8px; + } + + .message-container { + max-width: #{$conflict-dialog-width}; + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts new file mode 100644 index 0000000000..32c0a45bdc --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts @@ -0,0 +1,63 @@ +/// +/// Copyright © 2016-2024 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 } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { SharedModule } from '@shared/shared.module'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { ExportableEntityTypes } from '@shared/import-export/import-export.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { CommonModule } from '@angular/common'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; + +interface EntityConflictDialogData { + message: string; + entityId: EntityId & {entityType: EntityType}; +} + +@Component({ + selector: 'tb-entity-conflict-dialog', + templateUrl: 'entity-conflict-dialog.component.html', + styleUrls: ['./entity-conflict-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + SharedModule, + ], +}) +export class EntityConflictDialogComponent { + readonly ExportableEntityTypes = ExportableEntityTypes; + readonly entityTypeTranslations = entityTypeTranslations; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: EntityConflictDialogData, + private dialogRef: MatDialogRef, + private importExportService: ImportExportService, + ) {} + + onCancel(): void { + this.dialogRef.close(false); + } + + onConfirm(): void { + this.dialogRef.close(true); + } + + onLinkClick(event: MouseEvent): void { + event.preventDefault(); + this.importExportService.exportEntity(this.data.entityId); + } +} diff --git a/ui-ngx/src/app/shared/import-export/import-export.models.ts b/ui-ngx/src/app/shared/import-export/import-export.models.ts index 4933377d80..09c1bb3226 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.models.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.models.ts @@ -17,6 +17,16 @@ import { Widget, WidgetTypeDetails } from '@app/shared/models/widget.models'; import { DashboardLayoutId } from '@shared/models/dashboard.models'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { EntityType } from '@shared/models/entity-type.models'; + +export const ExportableEntityTypes = [ + EntityType.DEVICE_PROFILE, + EntityType.ASSET_PROFILE, + EntityType.RULE_CHAIN, + EntityType.DASHBOARD, + EntityType.WIDGET_TYPE, + EntityType.WIDGETS_BUNDLE +]; export interface ImportWidgetResult { widget: Widget; diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 9902177ebd..c4fa2f4049 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -34,7 +34,7 @@ import { } from '@shared/models/alias.models'; import { MatDialog } from '@angular/material/dialog'; import { ImportDialogComponent, ImportDialogData } from '@shared/import-export/import-dialog.component'; -import { forkJoin, Observable, of, Subject } from 'rxjs'; +import { forkJoin, Observable, of, Subject, throwError } from 'rxjs'; import { catchError, map, mergeMap, switchMap, take, tap } from 'rxjs/operators'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { EntityService } from '@core/http/entity.service'; @@ -380,6 +380,31 @@ export class ImportExportService { }); } + public exportEntity(entityId: EntityId): void { + switch (entityId.entityType) { + case EntityType.DEVICE_PROFILE: + this.exportDeviceProfile(entityId.id); + break; + case EntityType.ASSET_PROFILE: + this.exportAssetProfile(entityId.id); + break; + case EntityType.RULE_CHAIN: + this.exportRuleChain(entityId.id); + break; + case EntityType.DASHBOARD: + this.exportDashboard(entityId.id); + break; + case EntityType.WIDGET_TYPE: + this.exportWidgetType(entityId.id); + break; + case EntityType.WIDGETS_BUNDLE: + this.exportWidgetsBundle(entityId.id); + break; + default: + throwError(() => 'Not supported Entity Type'); + } + } + private exportWidgetsBundleWithWidgetTypes(widgetsBundle: WidgetsBundle) { this.widgetService.exportBundleWidgetTypesDetails(widgetsBundle.id.id).subscribe({ next: (widgetTypesDetails) => { diff --git a/ui-ngx/src/app/shared/interceptors/entity-conflict.interceptor.ts b/ui-ngx/src/app/shared/interceptors/entity-conflict.interceptor.ts new file mode 100644 index 0000000000..a999aabb2a --- /dev/null +++ b/ui-ngx/src/app/shared/interceptors/entity-conflict.interceptor.ts @@ -0,0 +1,63 @@ +/// +/// Copyright © 2016-2024 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 { Injectable } from '@angular/core'; +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpErrorResponse, + HttpStatusCode +} from '@angular/common/http'; +import { Observable, throwError, of } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityConflictDialogComponent } from '@shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component'; +import { EntityId } from '@shared/models/id/entity-id'; + +interface ConflictedEntity { version: number; id: EntityId } + +@Injectable() +export class EntityConflictInterceptor implements HttpInterceptor { + constructor(private dialog: MatDialog) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + return next.handle(request).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === HttpStatusCode.Conflict) { + return this.resolveConflictRequest(request, error.error.message) + .pipe(switchMap(httpRequest => next.handle(httpRequest))); + } else { + return throwError(() => error); + } + }) + ); + } + + private resolveConflictRequest(request: HttpRequest, message: string): Observable> { + const dialogRef = this.dialog.open(EntityConflictDialogComponent, {data: {message, entityId: request.body.id}}); + + return dialogRef.afterClosed().pipe( + switchMap(result => { + if (result) { + request.body.version = null; + } + return of(request); + }) + ); + } +} diff --git a/ui-ngx/src/app/shared/interceptors/public-api.ts b/ui-ngx/src/app/shared/interceptors/public-api.ts new file mode 100644 index 0000000000..17cbcd22cd --- /dev/null +++ b/ui-ngx/src/app/shared/interceptors/public-api.ts @@ -0,0 +1,17 @@ +/// +/// Copyright © 2016-2024 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. +/// + +export * from './entity-conflict.interceptor'; diff --git a/ui-ngx/src/app/shared/public-api.ts b/ui-ngx/src/app/shared/public-api.ts index bd3d565da3..8207b2e955 100644 --- a/ui-ngx/src/app/shared/public-api.ts +++ b/ui-ngx/src/app/shared/public-api.ts @@ -19,3 +19,4 @@ export * from './decorators/public-api'; export * from './models/public-api'; export * from './pipe/public-api'; export * from './shared.module'; +export * from './interceptors/public-api'; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 4eb0679b92..4ae1c3a45a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2282,6 +2282,13 @@ "type-edges": "Edges", "list-of-edges": "{ count, plural, =1 {One edge} other {List of # edges} }", "edge-name-starts-with": "Edges whose names start with '{{prefix}}'", + "version-conflict": { + "label": "Version conflict", + "message": "Do you want to cancel your changes or overwrite existing version?", + "link": "You can download your version of the {{entityType}} using this", + "overwrite": "Overwrite version", + "cancel": "Cancel changes" + }, "type-tb-resource": "Resource", "type-tb-resources": "Resources", "list-of-tb-resources": "{ count, plural, =1 {One resource} other {List of # resources} }", @@ -2300,7 +2307,8 @@ "type-notification-request": "Notification request", "type-notification-template": "Notification template", "type-notification-templates": "Notification templates", - "list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }" + "list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }", + "link": "link" }, "entity-field": { "created-time": "Created time",