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",