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);
+ }
+}