Skip to content

Commit 7bf7e51

Browse files
authored
router: warn user if there are unsaved edits when navigating away (#5223)
* warn user if there are unsaved edits when navigating away from the current route * fire discardDirtyUpdates action when user wants to discard unsaved changes
1 parent f9f298d commit 7bf7e51

File tree

6 files changed

+171
-9
lines changed

6 files changed

+171
-9
lines changed

tensorboard/webapp/app_routing/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ tf_ng_module(
1111
],
1212
deps = [
1313
":app_root",
14+
":dirty_updates_registry",
1415
":location",
1516
":programmatical_navigation_module",
1617
":route_registry",

tensorboard/webapp/app_routing/actions/app_routing_actions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ import {Navigation, Route, RouteKind} from '../types';
1919
/** @typehack */ import * as _typeHackModels from '@ngrx/store/src/models';
2020
/** @typehack */ import * as _typeHackStore from '@ngrx/store';
2121

22+
/**
23+
* Created when user wants to discard unsaved edits and navigate away.
24+
*/
25+
export const discardDirtyUpdates = createAction(
26+
'[App Routing] Discarding Unsaved Updates'
27+
);
28+
2229
/**
2330
* Created when router rehydrates state from the URL after a browser initiated
2431
* event. Please do note that the action is fired before `navigated` so make

tensorboard/webapp/app_routing/app_routing_module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {StoreModule} from '@ngrx/store';
1818

1919
import {AppRootModule} from './app_root_module';
2020
import {AppRoutingEffects} from './effects';
21+
import {DirtyUpdatesRegistryModule} from './dirty_updates_registry_module';
2122
import {LocationModule} from './location_module';
2223
import {ProgrammaticalNavigationModule} from './programmatical_navigation_module';
2324
import {RouteRegistryModule} from './route_registry_module';
@@ -32,6 +33,6 @@ import {APP_ROUTING_FEATURE_KEY} from './store/app_routing_types';
3233
AppRootModule,
3334
LocationModule,
3435
],
35-
providers: [ProgrammaticalNavigationModule],
36+
providers: [DirtyUpdatesRegistryModule, ProgrammaticalNavigationModule],
3637
})
3738
export class AppRoutingModule {}

tensorboard/webapp/app_routing/effects/BUILD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ tf_ng_module(
1313
deps = [
1414
"//tensorboard/webapp:app_state",
1515
"//tensorboard/webapp/app_routing:app_root",
16+
"//tensorboard/webapp/app_routing:dirty_updates_registry",
1617
"//tensorboard/webapp/app_routing:internal_utils",
1718
"//tensorboard/webapp/app_routing:location",
1819
"//tensorboard/webapp/app_routing:programmatical_navigation_module",
@@ -38,6 +39,7 @@ tf_ng_module(
3839
"//tensorboard/webapp/angular:expect_angular_core_testing",
3940
"//tensorboard/webapp/angular:expect_ngrx_store_testing",
4041
"//tensorboard/webapp/app_routing:app_root",
42+
"//tensorboard/webapp/app_routing:dirty_updates_registry",
4143
"//tensorboard/webapp/app_routing:location",
4244
"//tensorboard/webapp/app_routing:programmatical_navigation_module",
4345
"//tensorboard/webapp/app_routing:route_registry",

tensorboard/webapp/app_routing/effects/app_routing_effects.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
import {Injectable} from '@angular/core';
1616
import {Actions, createEffect, ofType} from '@ngrx/effects';
1717
import {Action, createAction, Store} from '@ngrx/store';
18-
import {merge, Observable, of} from 'rxjs';
18+
import {combineLatest, merge, Observable, of} from 'rxjs';
1919
import {
2020
debounceTime,
2121
delay,
@@ -28,6 +28,7 @@ import {
2828

2929
import {State} from '../../app_state';
3030
import {
31+
discardDirtyUpdates,
3132
navigated,
3233
navigating,
3334
navigationRequested,
@@ -40,6 +41,7 @@ import {
4041
serializeCompareExperimentParams,
4142
} from '../internal_utils';
4243
import {Location} from '../location';
44+
import {DirtyUpdatesRegistryModule} from '../dirty_updates_registry_module';
4345
import {ProgrammaticalNavigationModule} from '../programmatical_navigation_module';
4446
import {RouteConfigs} from '../route_config';
4547
import {RouteRegistryModule} from '../route_registry_module';
@@ -64,6 +66,7 @@ export class AppRoutingEffects {
6466
private readonly actions$: Actions,
6567
private readonly store: Store<State>,
6668
private readonly location: Location,
69+
private readonly dirtyUpdatesRegistry: DirtyUpdatesRegistryModule<State>,
6770
registry: RouteRegistryModule,
6871
private readonly programmaticalNavModule: ProgrammaticalNavigationModule,
6972
private readonly appRootProvider: AppRootProvider
@@ -198,8 +201,32 @@ export class AppRoutingEffects {
198201
* @export
199202
*/
200203
navigate$ = createEffect(() => {
204+
const hasDirtyUpdates$ = combineLatest(
205+
this.dirtyUpdatesRegistry
206+
.getDirtyUpdatesSelectors()
207+
.map((selector) => this.store.select(selector))
208+
).pipe(
209+
map(
210+
(updates) =>
211+
updates[0].experimentIds !== undefined &&
212+
updates[0].experimentIds.length > 0
213+
)
214+
);
201215
const dispatchNavigating$ = this.validatedRoute$.pipe(
202-
tap(({routeMatch, options}) => {
216+
withLatestFrom(hasDirtyUpdates$),
217+
filter(([, hasDirtyUpdates]) => {
218+
if (hasDirtyUpdates) {
219+
const discardChanges = window.confirm(
220+
`You have unsaved edits, are you sure you want to discard them?`
221+
);
222+
if (discardChanges) {
223+
this.store.dispatch(discardDirtyUpdates());
224+
}
225+
return discardChanges;
226+
}
227+
return true;
228+
}),
229+
tap(([{routeMatch, options}]) => {
203230
if (options.browserInitiated && routeMatch.deepLinkProvider) {
204231
// Query paramter formed by the redirector is passed to the
205232
// deserializer instead of one from Location.getSearch(). This
@@ -223,7 +250,7 @@ export class AppRoutingEffects {
223250
);
224251
}
225252
}),
226-
switchMap(({routeMatch, options}) => {
253+
switchMap(([{routeMatch, options}]) => {
227254
const navigationOptions = {
228255
replaceState: options.replaceState ?? false,
229256
};

tensorboard/webapp/app_routing/effects/app_routing_effects_test.ts

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ limitations under the License.
1616
import {Component} from '@angular/core';
1717
import {fakeAsync, flush, TestBed, tick} from '@angular/core/testing';
1818
import {provideMockActions} from '@ngrx/effects/testing';
19-
import {Action, createAction, props, Store} from '@ngrx/store';
19+
import {Action, createAction, createSelector, props, Store} from '@ngrx/store';
2020
import {MockStore, provideMockStore} from '@ngrx/store/testing';
2121
import {of, ReplaySubject} from 'rxjs';
2222

2323
import {State} from '../../app_state';
2424
import * as actions from '../actions';
25-
import {navigationRequested} from '../actions';
2625
import {AppRootProvider, TestableAppRootProvider} from '../app_root';
2726
import {Location} from '../location';
27+
import {DirtyUpdatesRegistryModule} from '../dirty_updates_registry_module';
2828
import {
2929
NavigateToCompare,
3030
NavigateToExperiments,
@@ -33,7 +33,13 @@ import {
3333
import {RouteRegistryModule} from '../route_registry_module';
3434
import {getActiveRoute} from '../store/app_routing_selectors';
3535
import {buildRoute, provideLocationTesting, TestableLocation} from '../testing';
36-
import {Navigation, Route, RouteKind, SerializableQueryParams} from '../types';
36+
import {
37+
DirtyUpdates,
38+
Navigation,
39+
Route,
40+
RouteKind,
41+
SerializableQueryParams,
42+
} from '../types';
3743

3844
import {AppRoutingEffects, TEST_ONLY} from './app_routing_effects';
3945

@@ -45,6 +51,14 @@ const testNavToCompareAction = createAction(
4551
'[TEST] test nav to compare',
4652
props<NavigateToCompare['routeParams']>()
4753
);
54+
const testDirtyExperimentsSelector = createSelector(
55+
(s) => s,
56+
(state: any) => {
57+
return {
58+
experimentIds: [],
59+
} as DirtyUpdates;
60+
}
61+
);
4862

4963
describe('app_routing_effects', () => {
5064
let effects: AppRoutingEffects;
@@ -138,6 +152,10 @@ describe('app_routing_effects', () => {
138152
};
139153
}
140154

155+
function dirtyUpdatesFactory() {
156+
return testDirtyExperimentsSelector;
157+
}
158+
141159
await TestBed.configureTestingModule({
142160
imports: [
143161
RouteRegistryModule.registerRoutes(routeFactory),
@@ -147,6 +165,9 @@ describe('app_routing_effects', () => {
147165
ProgrammaticalNavigationModule.registerProgrammaticalNavigation(
148166
programmaticalCompareNavigationFactory
149167
),
168+
DirtyUpdatesRegistryModule.registerDirtyUpdates<State>(
169+
dirtyUpdatesFactory
170+
),
150171
],
151172
providers: [
152173
provideMockActions(action),
@@ -157,6 +178,7 @@ describe('app_routing_effects', () => {
157178
provide: AppRootProvider,
158179
useClass: TestableAppRootProvider,
159180
},
181+
DirtyUpdatesRegistryModule,
160182
],
161183
}).compileComponents();
162184

@@ -255,6 +277,108 @@ describe('app_routing_effects', () => {
255277
]);
256278
});
257279

280+
describe('dispatchNavigating$ with dirty updates', () => {
281+
beforeEach(() => {
282+
const activeRoute = buildRoute({
283+
routeKind: RouteKind.EXPERIMENTS,
284+
pathname: '/experiments',
285+
queryParams: [],
286+
navigationOptions: {
287+
replaceState: false,
288+
},
289+
});
290+
const dirtyExperimentsFactory = () => {
291+
return {experimentIds: ['otter']};
292+
};
293+
294+
store.overrideSelector(getActiveRoute, activeRoute);
295+
store.overrideSelector(
296+
testDirtyExperimentsSelector,
297+
dirtyExperimentsFactory()
298+
);
299+
store.refreshState();
300+
});
301+
302+
it('warns user when navigating away', () => {
303+
spyOn(window, 'confirm');
304+
action.next(
305+
actions.navigationRequested({
306+
pathname: '/experiment/meow',
307+
})
308+
);
309+
310+
expect(window.confirm).toHaveBeenCalledWith(
311+
'You have unsaved edits, are you sure you want to discard them?'
312+
);
313+
});
314+
315+
it('noops if user cancels navigation', () => {
316+
spyOn(window, 'confirm').and.returnValue(false);
317+
action.next(
318+
actions.navigationRequested({
319+
pathname: '/experiment/meow',
320+
})
321+
);
322+
323+
expect(window.confirm).toHaveBeenCalledTimes(1);
324+
expect(actualActions).toEqual([]);
325+
});
326+
327+
it(
328+
'fires discardDirtyUpdates, navigating and navigated if user ' +
329+
'discards dirty updates',
330+
fakeAsync(() => {
331+
spyOn(window, 'confirm').and.returnValue(true);
332+
action.next(
333+
actions.navigationRequested({
334+
pathname: '/experiment/meow',
335+
})
336+
);
337+
338+
expect(window.confirm).toHaveBeenCalledTimes(1);
339+
340+
expect(actualActions).toEqual([
341+
actions.discardDirtyUpdates(),
342+
actions.navigating({
343+
after: buildRoute({
344+
routeKind: RouteKind.EXPERIMENT,
345+
params: {experimentId: 'meow'},
346+
pathname: '/experiment/meow',
347+
queryParams: [],
348+
}),
349+
}),
350+
]);
351+
352+
tick();
353+
expect(actualActions).toEqual([
354+
actions.discardDirtyUpdates(),
355+
actions.navigating({
356+
after: buildRoute({
357+
routeKind: RouteKind.EXPERIMENT,
358+
params: {experimentId: 'meow'},
359+
pathname: '/experiment/meow',
360+
queryParams: [],
361+
}),
362+
}),
363+
actions.navigated({
364+
before: buildRoute({
365+
routeKind: RouteKind.EXPERIMENTS,
366+
params: {},
367+
pathname: '/experiments',
368+
queryParams: [],
369+
}),
370+
after: buildRoute({
371+
routeKind: RouteKind.EXPERIMENT,
372+
params: {experimentId: 'meow'},
373+
pathname: '/experiment/meow',
374+
queryParams: [],
375+
}),
376+
}),
377+
]);
378+
})
379+
);
380+
});
381+
258382
describe('order of events', () => {
259383
it(
260384
'dispatches navigating, waits (for UI to clear prev route page), ' +
@@ -1019,7 +1143,7 @@ describe('app_routing_effects', () => {
10191143
setAppRootAndSubscribe('/foo/bar/');
10201144

10211145
// Do note that this path name does not contain the appRoot.
1022-
action.next(navigationRequested({pathname: '/experiment/123'}));
1146+
action.next(actions.navigationRequested({pathname: '/experiment/123'}));
10231147

10241148
expect(actualActions).toEqual([
10251149
actions.navigating({
@@ -1046,7 +1170,7 @@ describe('app_routing_effects', () => {
10461170
.and.returnValue('/foo/bar/experiment/123');
10471171

10481172
// Do note that this path name does not contain the appRoot.
1049-
action.next(navigationRequested({pathname: '../experiment/123'}));
1173+
action.next(actions.navigationRequested({pathname: '../experiment/123'}));
10501174

10511175
expect(actualActions).toEqual([
10521176
actions.navigating({

0 commit comments

Comments
 (0)