diff --git a/ui/cypress/support/utils/datalake/DataLakeUtils.ts b/ui/cypress/support/utils/datalake/DataLakeUtils.ts index 0d8e991d13..396b4c40b8 100644 --- a/ui/cypress/support/utils/datalake/DataLakeUtils.ts +++ b/ui/cypress/support/utils/datalake/DataLakeUtils.ts @@ -466,4 +466,8 @@ export class DataLakeUtils { timeout: 10000, }).should('have.length', amount); } + + public static checkIfConfirmationDialogIsShowing(): void { + cy.get('confirmation-dialog').should('be.visible'); + } } diff --git a/ui/cypress/tests/datalake/timeOrderDataView.spec.ts b/ui/cypress/tests/datalake/timeOrderDataView.spec.ts index 75cf2e8db0..ded730c5df 100644 --- a/ui/cypress/tests/datalake/timeOrderDataView.spec.ts +++ b/ui/cypress/tests/datalake/timeOrderDataView.spec.ts @@ -70,5 +70,9 @@ describe('Test Time Order in Data Explorer', () => { expect(timestamps[i]).to.be.at.most(timestamps[i + 1]); } }); + + // Check if dialog window is showing after applying changes to time settings + DataLakeUtils.goToDatalake(); + DataLakeUtils.checkIfConfirmationDialogIsShowing(); }); }); diff --git a/ui/src/app/data-explorer/components/dashboard/data-explorer-dashboard-panel.component.ts b/ui/src/app/data-explorer/components/dashboard/data-explorer-dashboard-panel.component.ts index a0ec0b773e..0a36c96b1b 100644 --- a/ui/src/app/data-explorer/components/dashboard/data-explorer-dashboard-panel.component.ts +++ b/ui/src/app/data-explorer/components/dashboard/data-explorer-dashboard-panel.component.ts @@ -46,14 +46,19 @@ import { MatDialog } from '@angular/material/dialog'; import { map, switchMap } from 'rxjs/operators'; import { SpDataExplorerRoutes } from '../../data-explorer.routes'; import { DataExplorerRoutingService } from '../../services/data-explorer-routing.service'; +import { DataExplorerDetectChangesService } from '../../services/data-explorer-detect-changes.service'; +import { SupportsUnsavedChangeDialog } from '../../models/dataview-dashboard.model'; @Component({ selector: 'sp-data-explorer-dashboard-panel', templateUrl: './data-explorer-dashboard-panel.component.html', styleUrls: ['./data-explorer-dashboard-panel.component.scss'], }) -export class DataExplorerDashboardPanelComponent implements OnInit, OnDestroy { +export class DataExplorerDashboardPanelComponent + implements OnInit, OnDestroy, SupportsUnsavedChangeDialog +{ dashboardLoaded = false; + originalDashboard: Dashboard; dashboard: Dashboard; /** @@ -82,6 +87,7 @@ export class DataExplorerDashboardPanelComponent implements OnInit, OnDestroy { constructor( private dataViewDataExplorerService: DataViewDataExplorerService, + private detectChangesService: DataExplorerDetectChangesService, private dialog: MatDialog, private timeSelectionService: TimeSelectionService, private authService: AuthService, @@ -136,6 +142,22 @@ export class DataExplorerDashboardPanelComponent implements OnInit, OnDestroy { } } + setShouldShowConfirm(): boolean { + const originalTimeSettings = this.originalDashboard + .dashboardTimeSettings as TimeSettings; + const currentTimeSettings = this.dashboard + .dashboardTimeSettings as TimeSettings; + return this.detectChangesService.shouldShowConfirm( + this.originalDashboard, + this.dashboard, + originalTimeSettings, + currentTimeSettings, + model => { + model.dashboardTimeSettings = undefined; + }, + ); + } + persistDashboardChanges() { this.dashboard.dashboardGeneralSettings.defaultViewMode = this.viewMode; this.dataViewDataExplorerService @@ -168,7 +190,7 @@ export class DataExplorerDashboardPanelComponent implements OnInit, OnDestroy { } discardChanges() { - this.routingService.navigateToOverview(); + this.routingService.navigateToOverview(true); } triggerEditMode() { @@ -184,6 +206,7 @@ export class DataExplorerDashboardPanelComponent implements OnInit, OnDestroy { getDashboard(dashboardId: string, startTime: number, endTime: number) { this.dataViewService.getDashboard(dashboardId).subscribe(dashboard => { this.dashboard = dashboard; + this.originalDashboard = JSON.parse(JSON.stringify(dashboard)); this.breadcrumbService.updateBreadcrumb( this.breadcrumbService.makeRoute( [SpDataExplorerRoutes.BASE], @@ -231,11 +254,11 @@ export class DataExplorerDashboardPanelComponent implements OnInit, OnDestroy { this.routingService.navigateToOverview(); } - confirmLeaveDashboard( + confirmLeaveDialog( _route: ActivatedRouteSnapshot, _state: RouterStateSnapshot, ): Observable { - if (this.editMode) { + if (this.editMode && this.setShouldShowConfirm()) { const dialogRef = this.dialog.open(ConfirmDialogComponent, { width: '500px', data: { @@ -250,7 +273,13 @@ export class DataExplorerDashboardPanelComponent implements OnInit, OnDestroy { return dialogRef.afterClosed().pipe( map(shouldUpdate => { if (shouldUpdate) { - this.persistDashboardChanges(); + this.dashboard.dashboardGeneralSettings.defaultViewMode = + this.viewMode; + this.dataViewDataExplorerService + .updateDashboard(this.dashboard) + .subscribe(result => { + return true; + }); } return true; }), diff --git a/ui/src/app/data-explorer/components/data-view/data-explorer-data-view.component.ts b/ui/src/app/data-explorer/components/data-view/data-explorer-data-view.component.ts index a2880731e1..5a7962aa29 100644 --- a/ui/src/app/data-explorer/components/data-view/data-explorer-data-view.component.ts +++ b/ui/src/app/data-explorer/components/data-view/data-explorer-data-view.component.ts @@ -21,24 +21,38 @@ import { DataExplorerWidgetModel, DataLakeMeasure, DataViewDataExplorerService, + TimeSelectionId, TimeSettings, } from '@streampipes/platform-services'; -import { ActivatedRoute } from '@angular/router'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import { ConfirmDialogComponent } from '@streampipes/shared-ui'; import { TimeSelectionService } from '../../services/time-selection.service'; import { DataExplorerRoutingService } from '../../services/data-explorer-routing.service'; import { DataExplorerDashboardService } from '../../services/data-explorer-dashboard.service'; +import { DataExplorerDetectChangesService } from '../../services/data-explorer-detect-changes.service'; +import { SupportsUnsavedChangeDialog } from '../../models/dataview-dashboard.model'; +import { Observable, of } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { map } from 'rxjs/operators'; @Component({ selector: 'sp-data-explorer-data-view', templateUrl: './data-explorer-data-view.component.html', styleUrls: ['./data-explorer-data-view.component.scss'], }) -export class DataExplorerDataViewComponent implements OnInit { +export class DataExplorerDataViewComponent + implements OnInit, SupportsUnsavedChangeDialog +{ dataViewLoaded = false; timeSettings: TimeSettings; editMode = true; dataView: DataExplorerWidgetModel; + originalDataView: DataExplorerWidgetModel; dataLakeMeasure: DataLakeMeasure; gridsterItemComponent: any; @@ -46,7 +60,9 @@ export class DataExplorerDataViewComponent implements OnInit { constructor( private dashboardService: DataExplorerDashboardService, + private detectChangesService: DataExplorerDetectChangesService, private route: ActivatedRoute, + private dialog: MatDialog, private routingService: DataExplorerRoutingService, private dataViewService: DataViewDataExplorerService, private timeSelectionService: TimeSelectionService, @@ -69,6 +85,7 @@ export class DataExplorerDataViewComponent implements OnInit { this.dataViewLoaded = false; this.dataViewService.getWidget(dataViewId).subscribe(res => { this.dataView = res; + this.originalDataView = JSON.parse(JSON.stringify(this.dataView)); if (!this.dataView.timeSettings?.startTime) { this.timeSettings = this.makeDefaultTimeSettings(); } else { @@ -100,6 +117,21 @@ export class DataExplorerDataViewComponent implements OnInit { return this.timeSelectionService.getDefaultTimeSettings(); } + setShouldShowConfirm(): boolean { + const originalTimeSettings = this.originalDataView + .timeSettings as TimeSettings; + const currentTimeSettings = this.dataView.timeSettings as TimeSettings; + return this.detectChangesService.shouldShowConfirm( + this.originalDataView, + this.dataView, + originalTimeSettings, + currentTimeSettings, + model => { + model.timeSettings = undefined; + }, + ); + } + createWidget() { this.dataLakeMeasure = new DataLakeMeasure(); this.dataView = new DataExplorerWidgetModel(); @@ -121,12 +153,52 @@ export class DataExplorerDataViewComponent implements OnInit { ? this.dataViewService.updateWidget(this.dataView) : this.dataViewService.saveWidget(this.dataView); observable.subscribe(() => { - this.routingService.navigateToOverview(); + this.routingService.navigateToOverview(true); }); } + confirmLeaveDialog( + _route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot, + ): Observable { + if (this.editMode && this.setShouldShowConfirm()) { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '500px', + data: { + title: 'Save changes?', + subtitle: + 'Update all changes to data view or discard current changes.', + cancelTitle: 'Discard changes', + okTitle: 'Update', + confirmAndCancel: true, + }, + }); + return dialogRef.afterClosed().pipe( + map(shouldUpdate => { + if (shouldUpdate) { + this.dataView.timeSettings = this.timeSettings; + const observable = + this.dataView.elementId !== undefined + ? this.dataViewService.updateWidget( + this.dataView, + ) + : this.dataViewService.saveWidget( + this.dataView, + ); + observable.subscribe(() => { + return true; + }); + } + return true; + }), + ); + } else { + return of(true); + } + } + discardChanges() { - this.routingService.navigateToOverview(); + this.routingService.navigateToOverview(true); } updateDateRange(timeSettings: TimeSettings) { diff --git a/ui/src/app/data-explorer/data-explorer-panel.can-deactivate.guard.ts b/ui/src/app/data-explorer/data-explorer-panel.can-deactivate.guard.ts index 60e339dbea..9afbf69d0e 100644 --- a/ui/src/app/data-explorer/data-explorer-panel.can-deactivate.guard.ts +++ b/ui/src/app/data-explorer/data-explorer-panel.can-deactivate.guard.ts @@ -17,19 +17,24 @@ */ import { Injectable } from '@angular/core'; -import { DataExplorerDashboardPanelComponent } from './components/dashboard/data-explorer-dashboard-panel.component'; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; +import { SupportsUnsavedChangeDialog } from './models/dataview-dashboard.model'; @Injectable({ providedIn: 'root' }) export class DataExplorerPanelCanDeactivateGuard { + constructor(private router: Router) {} canDeactivate( - component: DataExplorerDashboardPanelComponent, + component: SupportsUnsavedChangeDialog, route: ActivatedRouteSnapshot, state: RouterStateSnapshot, ): Observable | boolean { - if (!state.root.queryParams.omitConfirm === undefined) { - return component.confirmLeaveDashboard(route, state); + if (!this.router.getCurrentNavigation().extras?.state?.omitConfirm) { + return component.confirmLeaveDialog(route, state); } else { return true; } diff --git a/ui/src/app/data-explorer/data-explorer.module.ts b/ui/src/app/data-explorer/data-explorer.module.ts index d3f42afa9a..81be2dc5fb 100644 --- a/ui/src/app/data-explorer/data-explorer.module.ts +++ b/ui/src/app/data-explorer/data-explorer.module.ts @@ -193,6 +193,7 @@ import { GaugeWidgetConfigComponent } from './components/widgets/gauge/config/ga { path: 'data-view/:id', component: DataExplorerDataViewComponent, + canDeactivate: [DataExplorerPanelCanDeactivateGuard], }, { path: 'dashboard/:id', diff --git a/ui/src/app/data-explorer/models/dataview-dashboard.model.ts b/ui/src/app/data-explorer/models/dataview-dashboard.model.ts index 99c50efcea..670c73a29d 100644 --- a/ui/src/app/data-explorer/models/dataview-dashboard.model.ts +++ b/ui/src/app/data-explorer/models/dataview-dashboard.model.ts @@ -33,6 +33,8 @@ import { EChartsOption } from 'echarts'; import { WidgetSize } from './dataset.model'; import { EventEmitter } from '@angular/core'; import { FieldUpdateInfo } from './field-update.model'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface IDataViewDashboardConfig extends GridsterConfig {} @@ -134,3 +136,10 @@ export interface DataExplorerVisConfig { forType?: number | string; configurationValid: boolean; } + +export interface SupportsUnsavedChangeDialog { + confirmLeaveDialog( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + ): Observable; +} diff --git a/ui/src/app/data-explorer/services/data-explorer-detect-changes.service.ts b/ui/src/app/data-explorer/services/data-explorer-detect-changes.service.ts new file mode 100644 index 0000000000..241d3315c9 --- /dev/null +++ b/ui/src/app/data-explorer/services/data-explorer-detect-changes.service.ts @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { + Dashboard, + DataExplorerWidgetModel, + TimeSettings, +} from '@streampipes/platform-services'; + +@Injectable({ providedIn: 'root' }) +export class DataExplorerDetectChangesService { + constructor() {} + + shouldShowConfirm( + originalItem: T, + currentItem: DataExplorerWidgetModel | Dashboard, + originalTimeSettings: TimeSettings, + currentTimeSettings: TimeSettings, + clearTimestampFn: (model: T) => void, + ): boolean { + return ( + this.hasWidgetChanged( + originalItem, + currentItem, + clearTimestampFn, + ) || + this.hasTimeSettingsChanged( + originalTimeSettings, + currentTimeSettings, + ) + ); + } + + hasTimeSettingsChanged( + originalTimeSettings: TimeSettings, + currentTimeSettings: TimeSettings, + ): boolean { + if (originalTimeSettings.timeSelectionId == 0) { + return this.hasCustomTimeSettingsChanged( + originalTimeSettings, + currentTimeSettings, + ); + } else { + return this.hasTimeSelectionIdChanged( + originalTimeSettings, + currentTimeSettings, + ); + } + } + + hasCustomTimeSettingsChanged( + original: TimeSettings, + current: TimeSettings, + ): boolean { + return ( + original.startTime !== current.startTime || + original.endTime !== current.endTime + ); + } + + hasTimeSelectionIdChanged( + original: TimeSettings, + current: TimeSettings, + ): boolean { + return original.timeSelectionId !== current.timeSelectionId; + } + + hasWidgetChanged( + originalItem: DataExplorerWidgetModel | Dashboard, + currentItem: DataExplorerWidgetModel | Dashboard, + clearTimestampFn: (model: DataExplorerWidgetModel | Dashboard) => void, + ): boolean { + const clonedOriginal = JSON.parse(JSON.stringify(originalItem)); + const clonedCurrent = JSON.parse(JSON.stringify(currentItem)); + clearTimestampFn(clonedOriginal); + clearTimestampFn(clonedCurrent); + return JSON.stringify(clonedOriginal) !== JSON.stringify(clonedCurrent); + } +}