diff --git a/ui-ngx/src/app/core/http/widget.service.ts b/ui-ngx/src/app/core/http/widget.service.ts index b172b61a1d..8b57feb488 100644 --- a/ui-ngx/src/app/core/http/widget.service.ts +++ b/ui-ngx/src/app/core/http/widget.service.ts @@ -185,8 +185,9 @@ export class WidgetService { public saveWidgetTypeDetails(widgetInfo: WidgetInfo, id: WidgetTypeId, createdTime: number, + version: number, config?: RequestConfig): Observable { - const widgetTypeDetails = toWidgetTypeDetails(widgetInfo, id, undefined, createdTime); + const widgetTypeDetails = toWidgetTypeDetails(widgetInfo, id, undefined, createdTime, version); return this.http.post('/api/widgetType', widgetTypeDetails, defaultHttpOptionsFromConfig(config)).pipe( tap((savedWidgetType) => { diff --git a/ui-ngx/src/app/core/interceptors/entity-conflict.interceptor.ts b/ui-ngx/src/app/core/interceptors/entity-conflict.interceptor.ts index 57ee28c779..e4db59ec66 100644 --- a/ui-ngx/src/app/core/interceptors/entity-conflict.interceptor.ts +++ b/ui-ngx/src/app/core/interceptors/entity-conflict.interceptor.ts @@ -20,6 +20,7 @@ import { HttpEvent, HttpHandler, HttpInterceptor, + HttpParams, HttpRequest, HttpStatusCode } from '@angular/common/http'; @@ -32,6 +33,8 @@ import { import { HasId } from '@shared/models/base-data'; import { HasVersion } from '@shared/models/entity.models'; import { getInterceptorConfig } from './interceptor.util'; +import { isDefined } from '@core/utils'; +import { InterceptorConfig } from '@core/interceptors/interceptor-config'; @Injectable() export class EntityConflictInterceptor implements HttpInterceptor { @@ -67,8 +70,12 @@ export class EntityConflictInterceptor implements HttpInterceptor { return this.openConflictDialog(request.body, error.error.message).pipe( switchMap(result => { - if (result) { - return next.handle(this.updateRequestVersion(request)); + if (isDefined(result)) { + if (result) { + return next.handle(this.updateRequestVersion(request)); + } + (request.params as HttpParams & { interceptorConfig: InterceptorConfig }).interceptorConfig.ignoreErrors = true; + return throwError(() => error); } return of(null); }) @@ -82,7 +89,9 @@ export class EntityConflictInterceptor implements HttpInterceptor { private openConflictDialog(entity: unknown & HasId & HasVersion, message: string): Observable { const dialogRef = this.dialog.open(EntityConflictDialogComponent, { - data: { message, entity } + disableClose: true, + data: { message, entity }, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], }); return dialogRef.afterClosed(); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 9c7ddd9db5..5af1833794 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -86,7 +86,7 @@ import { Authority } from '@shared/models/authority.enum'; import { DialogService } from '@core/services/dialog.service'; import { EntityService } from '@core/http/entity.service'; import { AliasController } from '@core/api/alias-controller'; -import { BehaviorSubject, Observable, of, Subject, Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, of, Subject, Subscription, throwError } from 'rxjs'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { DashboardService } from '@core/http/dashboard.service'; import { @@ -147,7 +147,7 @@ import { IAliasController } from '@core/api/widget-api.models'; import { MatButton } from '@angular/material/button'; import { VersionControlComponent } from '@home/components/vc/version-control.component'; import { TbPopoverService } from '@shared/components/popover.service'; -import { distinctUntilChanged, map, skip, tap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, skip, tap } from 'rxjs/operators'; import { LayoutFixedSize, LayoutWidthType } from '@home/components/dashboard-page/layout/layout.models'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { ResizeObserver } from '@juggle/resize-observer'; @@ -156,6 +156,7 @@ import { MoveWidgetsDialogComponent, MoveWidgetsDialogResult } from '@home/components/dashboard-page/layout/move-widgets-dialog.component'; +import { HttpStatusCode } from '@angular/common/http'; // @dynamic @Component({ @@ -1092,7 +1093,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC public saveDashboard() { this.translatedDashboardTitle = this.getTranslatedDashboardTitle(); - this.setEditMode(false, false); this.notifyDashboardUpdated(); } @@ -1204,8 +1204,33 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC data: widget }; this.window.parent.postMessage(JSON.stringify(message), '*'); + this.setEditMode(false, false); } else { - this.dashboardService.saveDashboard(this.dashboard).subscribe(); + let reInitDashboard = false; + this.dashboardService.saveDashboard(this.dashboard).pipe( + catchError((err) => { + if (err.status === HttpStatusCode.Conflict) { + reInitDashboard = true; + return this.dashboardService.getDashboard(this.dashboard.id.id).pipe( + map(dashboard => this.dashboardUtils.validateAndUpdateDashboard(dashboard)) + ); + } + return throwError(() => err); + }) + ).subscribe((dashboard) => { + if (reInitDashboard) { + const dashboardPageInitData: DashboardPageInitData = { + dashboard, + currentDashboardId: dashboard.id ? dashboard.id.id : null, + widgetEditMode: this.widgetEditMode, + singlePageMode: this.singlePageMode + }; + this.init(dashboardPageInitData); + } else { + this.dashboard = dashboard; + this.setEditMode(false, false); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index dd3e846d92..99b6275dfe 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -402,7 +402,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa this.cd.detectChanges(); } - updateData(closeDetails: boolean = true) { + updateData(closeDetails: boolean = true, reloadEntity: boolean = true) { if (closeDetails) { this.isDetailsOpen = false; } @@ -427,7 +427,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa timePageLink.endTime = interval.endTime; } this.dataSource.loadEntities(this.pageLink); - if (this.isDetailsOpen && this.entityDetailsPanel) { + if (reloadEntity && this.isDetailsOpen && this.entityDetailsPanel) { this.entityDetailsPanel.reloadEntity(); } } @@ -511,7 +511,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa } onEntityUpdated(entity: BaseData) { - this.updateData(false); + this.updateData(false, false); this.entitiesTableConfig.entityUpdated(entity); } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts index b0a4998a08..1ec56028af 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts @@ -40,11 +40,12 @@ import { UntypedFormGroup } from '@angular/forms'; import { EntityComponent } from './entity.component'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { EntityAction } from '@home/models/entity/entity-component.models'; -import { Observable, ReplaySubject, Subscription } from 'rxjs'; +import { Observable, ReplaySubject, Subscription, throwError } from 'rxjs'; import { MatTab, MatTabGroup } from '@angular/material/tabs'; import { EntityTabsComponent } from '@home/components/entity/entity-tabs.component'; import { deepClone, mergeDeep } from '@core/utils'; -import { entityIdEquals } from '@shared/models/id/entity-id'; +import { catchError } from 'rxjs/operators'; +import { HttpStatusCode } from '@angular/common/http'; @Component({ selector: 'tb-entity-details-panel', @@ -288,7 +289,16 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV editingEntity.additionalInfo = mergeDeep((this.editingEntity as any).additionalInfo, this.entityComponent.entityFormValue()?.additionalInfo); } - this.entitiesTableConfig.saveEntity(editingEntity, this.editingEntity).subscribe( + this.entitiesTableConfig.saveEntity(editingEntity, this.editingEntity) + .pipe( + catchError((err) => { + if (err.status === HttpStatusCode.Conflict) { + return this.entitiesTableConfig.loadEntity(this.currentEntityId); + } + return throwError(() => err); + }) + ) + .subscribe( (entity) => { this.entity = entity; this.entityComponent.entity = entity; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index 23d0403a6e..508d07ea0d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -113,7 +113,7 @@ export class WidgetComponentService { hasBasicMode: this.utils.editWidgetInfo.hasBasicMode, basicModeDirective: this.utils.editWidgetInfo.basicModeDirective, defaultConfig: this.utils.editWidgetInfo.defaultConfig - }, new WidgetTypeId('1'), new TenantId( NULL_UUID ), undefined + }, new WidgetTypeId('1'), new TenantId( NULL_UUID ), undefined, undefined ); } const initSubject = new ReplaySubject(); diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index bad8615363..fa4d367959 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -669,7 +669,7 @@ export const detailsToWidgetInfo = (widgetTypeDetailsEntity: WidgetTypeDetails): }; export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: TenantId, - createdTime: number): WidgetType => { + createdTime: number, version: number): WidgetType => { const descriptor: WidgetTypeDescriptor = { type: widgetInfo.type, sizeX: widgetInfo.sizeX, @@ -692,6 +692,7 @@ export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: id, tenantId, createdTime, + version, fqn: widgetTypeFqn(widgetInfo.fullFqn), name: widgetInfo.widgetName, deprecated: widgetInfo.deprecated, @@ -701,8 +702,8 @@ export const toWidgetType = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: }; export const toWidgetTypeDetails = (widgetInfo: WidgetInfo, id: WidgetTypeId, tenantId: TenantId, - createdTime: number): WidgetTypeDetails => { - const widgetTypeEntity = toWidgetType(widgetInfo, id, tenantId, createdTime); + createdTime: number, version: number): WidgetTypeDetails => { + const widgetTypeEntity = toWidgetType(widgetInfo, id, tenantId, createdTime, version); return { ...widgetTypeEntity, description: widgetInfo.description, diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts index 4e470d03da..3ca2036369 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts @@ -59,12 +59,13 @@ import { SaveWidgetTypeAsDialogComponent, SaveWidgetTypeAsDialogResult } from '@home/pages/widget/save-widget-type-as-dialog.component'; -import { forkJoin, mergeMap, of, Subscription } from 'rxjs'; +import { forkJoin, mergeMap, of, Subscription, throwError } from 'rxjs'; import { ResizeObserver } from '@juggle/resize-observer'; import { widgetEditorCompleter } from '@home/pages/widget/widget-editor.models'; import { Observable } from 'rxjs/internal/Observable'; -import { map, tap } from 'rxjs/operators'; +import { catchError, map, tap } from 'rxjs/operators'; import { beautifyCss, beautifyHtml, beautifyJs } from '@shared/models/beautify.models'; +import { HttpStatusCode } from '@angular/common/http'; import Timeout = NodeJS.Timeout; // @dynamic @@ -569,9 +570,12 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe private commitSaveWidget() { const id = (this.widgetTypeDetails && this.widgetTypeDetails.id) ? this.widgetTypeDetails.id : undefined; + const version = this.widgetTypeDetails?.version ?? null; const createdTime = (this.widgetTypeDetails && this.widgetTypeDetails.createdTime) ? this.widgetTypeDetails.createdTime : undefined; - this.widgetService.saveWidgetTypeDetails(this.widget, id, createdTime).pipe( + this.saveWidgetPending = false; + this.widgetService.saveWidgetTypeDetails(this.widget, id, createdTime, version).pipe( mergeMap((widgetTypeDetails) => { + this.saveWidgetPending = true; const widgetsBundleId = this.route.snapshot.params.widgetsBundleId as string; if (widgetsBundleId && !id) { return this.widgetService.addWidgetFqnToWidgetBundle(widgetsBundleId, widgetTypeDetails.fqn).pipe( @@ -579,7 +583,13 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe ); } return of(widgetTypeDetails); - }) + }), + catchError((err) => { + if (id && err.status === HttpStatusCode.Conflict) { + return this.widgetService.getWidgetTypeById(id.id); + } + return throwError(() => err); + }), ).subscribe({ next: (widgetTypeDetails) => { this.saveWidgetPending = false; @@ -612,7 +622,7 @@ export class WidgetEditorComponent extends PageComponent implements OnInit, OnDe config.title = this.widget.widgetName; this.widget.defaultConfig = JSON.stringify(config); this.isDirty = false; - this.widgetService.saveWidgetTypeDetails(this.widget, undefined, undefined).pipe( + this.widgetService.saveWidgetTypeDetails(this.widget, undefined, undefined, undefined).pipe( mergeMap((widget) => { if (saveWidgetAsData.widgetBundleId) { return this.widgetService.addWidgetFqnToWidgetBundle(saveWidgetAsData.widgetBundleId, widget.fqn).pipe( 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 index 502bc3381c..ba09311895 100644 --- 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 @@ -16,7 +16,9 @@ --> -

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

+

+ {{ data.message }} +

- +
-
+
{{ 'widgets-bundle.export-widgets-bundle-widgets-prompt' | translate }}
diff --git a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts index 025a2ddf4c..f012dc77fe 100644 --- a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts @@ -27,6 +27,7 @@ import { isDefinedAndNotNull } from '@core/utils'; export interface ExportWidgetsBundleDialogData { widgetsBundle: WidgetsBundle; includeBundleWidgetsInExport: boolean; + ignoreLoading?: boolean; } export interface ExportWidgetsBundleDialogResult { @@ -44,6 +45,8 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent, @@ -52,6 +55,7 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent) { super(store, router, dialogRef); this.widgetsBundle = data.widgetsBundle; + this.ignoreLoading = data.ignoreLoading; if (isDefinedAndNotNull(data.includeBundleWidgetsInExport)) { this.exportWidgetsFormControl.patchValue(data.includeBundleWidgetsInExport, {emitEvent: false}); } 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 528a8b0fd3..4746365c50 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 @@ -352,28 +352,7 @@ export class ImportExportService { forkJoin(tasks).subscribe({ next: ({includeBundleWidgetsInExport, widgetsBundle}) => { - this.dialog.open(ExportWidgetsBundleDialogComponent, { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { - widgetsBundle, - includeBundleWidgetsInExport - } - }).afterClosed().subscribe( - (result) => { - if (result) { - if (includeBundleWidgetsInExport !== result.exportWidgets) { - this.store.dispatch(new ActionPreferencesPutUserSettings({includeBundleWidgetsInExport: result.exportWidgets})); - } - if (result.exportWidgets) { - this.exportWidgetsBundleWithWidgetTypes(widgetsBundle); - } else { - this.exportWidgetsBundleWithWidgetTypeFqns(widgetsBundle); - } - } - } - ); + this.handleExportWidgetsBundle(widgetsBundle, includeBundleWidgetsInExport); }, error: (e) => { this.handleExportError(e, 'widgets-bundle.export-failed-error'); @@ -401,6 +380,9 @@ export class ImportExportService { })) .subscribe(ruleChainData => this.exportToPc(ruleChainData, entityData.name)); return; + case EntityType.WIDGETS_BUNDLE: + this.exportSelectedWidgetsBundle(entityData as WidgetsBundle); + return; case EntityType.DASHBOARD: preparedData = this.prepareDashboardExport(entityData as Dashboard); break; @@ -410,6 +392,43 @@ export class ImportExportService { this.exportToPc(preparedData, entityData.name); } + private exportSelectedWidgetsBundle(widgetsBundle: WidgetsBundle): void { + this.store.pipe(select(selectUserSettingsProperty( 'includeBundleWidgetsInExport'))).pipe(take(1)).subscribe({ + next: (includeBundleWidgetsInExport) => { + this.handleExportWidgetsBundle(widgetsBundle, includeBundleWidgetsInExport, true); + }, + error: (e) => { + this.handleExportError(e, 'widgets-bundle.export-failed-error'); + } + }); + } + + private handleExportWidgetsBundle(widgetsBundle: WidgetsBundle, includeBundleWidgetsInExport: boolean, ignoreLoading?: boolean): void { + this.dialog.open(ExportWidgetsBundleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + widgetsBundle, + includeBundleWidgetsInExport, + ignoreLoading + } + }).afterClosed().subscribe( + (result) => { + if (result) { + if (includeBundleWidgetsInExport !== result.exportWidgets) { + this.store.dispatch(new ActionPreferencesPutUserSettings({includeBundleWidgetsInExport: result.exportWidgets})); + } + if (result.exportWidgets) { + this.exportWidgetsBundleWithWidgetTypes(widgetsBundle); + } else { + this.exportWidgetsBundleWithWidgetTypeFqns(widgetsBundle); + } + } + } + ); + } + private exportWidgetsBundleWithWidgetTypes(widgetsBundle: WidgetsBundle) { this.widgetService.exportBundleWidgetTypesDetails(widgetsBundle.id.id).subscribe({ next: (widgetTypesDetails) => { diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 977de00ce7..3b60cd727e 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -41,7 +41,7 @@ import { isNotEmptyStr, mergeDeepIgnoreArray } from '@core/utils'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; import { ComponentStyle, Font, TimewindowStyle } from '@shared/models/widget-settings.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; -import { HasTenantId } from '@shared/models/entity.models'; +import { HasTenantId, HasVersion } from '@shared/models/entity.models'; import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; @@ -199,7 +199,7 @@ export interface WidgetControllerDescriptor { actionSources?: {[actionSourceId: string]: WidgetActionSource}; } -export interface BaseWidgetType extends BaseData, HasTenantId { +export interface BaseWidgetType extends BaseData, HasTenantId, HasVersion { tenantId: TenantId; fqn: string; name: string; 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 cb9587472f..b7e96b9faf 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2311,11 +2311,10 @@ "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?", + "message": "Do you want to overwrite existing version or discard changes and load the latest version?", "link": "You can download your version of the {{entityType}} using this", "overwrite": "Overwrite version", - "cancel": "Cancel changes" + "discard": "Discard changes" }, "type-tb-resource": "Resource", "type-tb-resources": "Resources",