feat: Add dialog window to confirm that unsaved changes will be discarded (#3171)

* Add dialog window to confirm that unsaved changes will be discarded

* Fix CanActivateGuard so that confirm dialog window only pops up for unsaved changes
diff --git a/ui/cypress/support/utils/datalake/DataLakeUtils.ts b/ui/cypress/support/utils/datalake/DataLakeUtils.ts
index 0d8e991..396b4c4 100644
--- a/ui/cypress/support/utils/datalake/DataLakeUtils.ts
+++ b/ui/cypress/support/utils/datalake/DataLakeUtils.ts
@@ -466,4 +466,8 @@
             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 75cf2e8..ded730c 100644
--- a/ui/cypress/tests/datalake/timeOrderDataView.spec.ts
+++ b/ui/cypress/tests/datalake/timeOrderDataView.spec.ts
@@ -70,5 +70,9 @@
                 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 a0ec0b7..0a36c96 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 { 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 @@
 
     constructor(
         private dataViewDataExplorerService: DataViewDataExplorerService,
+        private detectChangesService: DataExplorerDetectChangesService,
         private dialog: MatDialog,
         private timeSelectionService: TimeSelectionService,
         private authService: AuthService,
@@ -136,6 +142,22 @@
         }
     }
 
+    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 @@
     }
 
     discardChanges() {
-        this.routingService.navigateToOverview();
+        this.routingService.navigateToOverview(true);
     }
 
     triggerEditMode() {
@@ -184,6 +206,7 @@
     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 @@
         this.routingService.navigateToOverview();
     }
 
-    confirmLeaveDashboard(
+    confirmLeaveDialog(
         _route: ActivatedRouteSnapshot,
         _state: RouterStateSnapshot,
     ): Observable<boolean> {
-        if (this.editMode) {
+        if (this.editMode && this.setShouldShowConfirm()) {
             const dialogRef = this.dialog.open(ConfirmDialogComponent, {
                 width: '500px',
                 data: {
@@ -250,7 +273,13 @@
             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 a288073..5a7962a 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 @@
     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 @@
 
     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 @@
         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 @@
         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 @@
                 ? 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<boolean> {
+        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 60e339d..9afbf69 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> | 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 d3f42af..81be2dc 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 @@
                     {
                         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 99c50ef..670c73a 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 { 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 @@
     forType?: number | string;
     configurationValid: boolean;
 }
+
+export interface SupportsUnsavedChangeDialog {
+    confirmLeaveDialog(
+        route: ActivatedRouteSnapshot,
+        state: RouterStateSnapshot,
+    ): Observable<boolean>;
+}
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 0000000..241d331
--- /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<T extends Dashboard | DataExplorerWidgetModel>(
+        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);
+    }
+}