Skip to content

Commit cf53ff6

Browse files
authored
Introduce alert snackbar UI (#4244)
Diffbase: #4221 Followup: #4245 Introduces an alert snackbar UI, which surfaces application errors to the user in the corner of the screen. App, feature modules may now register actions that trigger an alert on screen: ``` AlertActionModule.registerActionAlerts([ {localizedMessage: "Fetch failed"}, ... ]) ``` This change hides away the composite action using Angular's DI framework. The actions that should trigger alerts are registered once, and are dispatched by the AlertEffects, rather than specific features.
1 parent 60ade15 commit cf53ff6

27 files changed

+1005
-2
lines changed

tensorboard/webapp/BUILD

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ ng_module(
3838
"selectors.ts",
3939
],
4040
deps = [
41+
"//tensorboard/webapp/alert/store",
4142
"//tensorboard/webapp/app_routing/store",
4243
"//tensorboard/webapp/experiments/store:selectors",
4344
"//tensorboard/webapp/metrics/store",
@@ -74,6 +75,8 @@ ng_module(
7475
":mat_icon",
7576
":oss_plugins_module",
7677
":reducer_config",
78+
"//tensorboard/webapp/alert",
79+
"//tensorboard/webapp/alert/views:alert_snackbar",
7780
"//tensorboard/webapp/angular:expect_angular_platform_browser_animations",
7881
"//tensorboard/webapp/app_routing",
7982
"//tensorboard/webapp/app_routing:route_registry",
@@ -106,6 +109,7 @@ ng_module(
106109
"app_state.ts",
107110
],
108111
deps = [
112+
"//tensorboard/webapp/alert/store:types",
109113
"//tensorboard/webapp/app_routing/store:types",
110114
"//tensorboard/webapp/core/store",
111115
"//tensorboard/webapp/experiments/store:types",
@@ -204,6 +208,9 @@ tensorboard_html_binary(
204208
tf_ng_web_test_suite(
205209
name = "karma_test",
206210
deps = [
211+
"//tensorboard/webapp/alert:test_lib",
212+
"//tensorboard/webapp/alert/store:test_lib",
213+
"//tensorboard/webapp/alert/views:views_test",
207214
"//tensorboard/webapp/app_routing:app_routing_test",
208215
"//tensorboard/webapp/app_routing:route_config_test",
209216
"//tensorboard/webapp/app_routing:testing",

tensorboard/webapp/alert/BUILD

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
load("//tensorboard/defs:defs.bzl", "tf_ts_library")
2+
load("@npm_angular_bazel//:index.bzl", "ng_module")
3+
4+
package(default_visibility = ["//tensorboard:internal"])
5+
6+
ng_module(
7+
name = "alert",
8+
srcs = [
9+
"alert_module.ts",
10+
],
11+
deps = [
12+
":alert_action",
13+
"//tensorboard/webapp/alert/effects",
14+
"//tensorboard/webapp/alert/store",
15+
"//tensorboard/webapp/alert/store:types",
16+
"//tensorboard/webapp/alert/views:alert_snackbar",
17+
"@npm//@angular/core",
18+
"@npm//@ngrx/effects",
19+
"@npm//@ngrx/store",
20+
],
21+
)
22+
23+
ng_module(
24+
name = "alert_action",
25+
srcs = [
26+
"alert_action_module.ts",
27+
],
28+
deps = [
29+
":types",
30+
"@npm//@angular/core",
31+
"@npm//@ngrx/store",
32+
],
33+
)
34+
35+
tf_ts_library(
36+
name = "types",
37+
srcs = [
38+
"types.ts",
39+
],
40+
)
41+
42+
tf_ts_library(
43+
name = "test_lib",
44+
testonly = True,
45+
srcs = [
46+
"alert_action_test.ts",
47+
],
48+
deps = [
49+
":alert_action",
50+
"//tensorboard/webapp:app_state",
51+
"//tensorboard/webapp/alert/actions",
52+
"//tensorboard/webapp/alert/effects",
53+
"//tensorboard/webapp/alert/store",
54+
"//tensorboard/webapp/angular:expect_angular_core_testing",
55+
"//tensorboard/webapp/angular:expect_ngrx_store_testing",
56+
"@npm//@angular/common",
57+
"@npm//@angular/compiler",
58+
"@npm//@angular/core",
59+
"@npm//@ngrx/effects",
60+
"@npm//@ngrx/store",
61+
"@npm//@types/jasmine",
62+
"@npm//rxjs",
63+
],
64+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
load("//tensorboard/defs:defs.bzl", "tf_ts_library")
2+
3+
package(default_visibility = ["//tensorboard:internal"])
4+
5+
tf_ts_library(
6+
name = "actions",
7+
srcs = [
8+
"index.ts",
9+
],
10+
visibility = [
11+
"//tensorboard/webapp/alert:__subpackages__",
12+
],
13+
deps = [
14+
"//tensorboard/webapp/alert:types",
15+
"@npm//@ngrx/store",
16+
],
17+
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/* Copyright 2020 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {createAction, props} from '@ngrx/store';
16+
17+
import {AlertReport} from '../types';
18+
19+
/** @typehack */ import * as _typeHackModels from '@ngrx/store/src/models';
20+
/** @typehack */ import * as _typeHackStore from '@ngrx/store';
21+
22+
/**
23+
* Fires when an alert is to be reported.
24+
*/
25+
export const alertReported = createAction(
26+
'[Alert] Alert Reported',
27+
props<AlertReport>()
28+
);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/* Copyright 2020 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {
16+
Inject,
17+
ModuleWithProviders,
18+
NgModule,
19+
Optional,
20+
InjectionToken,
21+
} from '@angular/core';
22+
import {Action, ActionCreator, Creator} from '@ngrx/store';
23+
import {AlertReport} from './types';
24+
25+
// While this token is not used outside, it must be exported so that stricter
26+
// build tools may discover it during compilation.
27+
export const ACTION_TO_ALERT_PROVIDER = new InjectionToken<
28+
ActionToAlertConfig[]
29+
>('[Alert] Action-To-Alert Provider');
30+
31+
export type ActionToAlertTransformer = (action: Action) => AlertReport | null;
32+
33+
export interface ActionToAlertConfig {
34+
/**
35+
* The action to listen for.
36+
*/
37+
actionCreator: ActionCreator<string, Creator>;
38+
39+
/**
40+
* A function that returns an alert report, or null, when the action is
41+
* received.
42+
*/
43+
alertFromAction: ActionToAlertTransformer;
44+
}
45+
46+
/**
47+
* An NgModule that provides alert-producing actions. These action configs are
48+
* collected by AlertModule, which tracks application alerts.
49+
*
50+
* When the configured action fires, the AlertModule may respond.
51+
*
52+
* @NgModule({
53+
* imports: [
54+
* AlertActionModule.registerAlertActions([
55+
* {
56+
* action: fetchKeysFailed,
57+
* alertFromAction: () => {localizedMessage: "Keys failed to fetch."},
58+
* },
59+
* {
60+
* action: greenButtonClicked,
61+
* alertFromAction: (actionPayload) => {
62+
* if (!actionPayload.wasButtonEnabled) {
63+
* return {localizedMessage: "Green button failed."};
64+
* }
65+
* return null;
66+
* }
67+
* }
68+
* ]),
69+
* ],
70+
* })
71+
*/
72+
@NgModule({})
73+
export class AlertActionModule {
74+
/**
75+
* Map from action creator type to transformer function.
76+
*/
77+
private readonly providers = new Map<string, ActionToAlertTransformer>();
78+
79+
constructor(
80+
@Optional()
81+
@Inject(ACTION_TO_ALERT_PROVIDER)
82+
providers: ActionToAlertConfig[][]
83+
) {
84+
for (const configs of providers || []) {
85+
for (const config of configs) {
86+
if (this.providers.has(config.actionCreator.type)) {
87+
throw new RangeError(
88+
`"${config.actionCreator.type}" is already registered for alerts.` +
89+
' Multiple alerts for the same action is not allowed.'
90+
);
91+
}
92+
this.providers.set(config.actionCreator.type, config.alertFromAction);
93+
}
94+
}
95+
}
96+
97+
getAlertFromAction(action: Action): AlertReport | null {
98+
const lambda = this.providers.get(action.type);
99+
if (!lambda) {
100+
return null;
101+
}
102+
return lambda(action);
103+
}
104+
105+
static registerAlertActions(
106+
providerFactory: () => ActionToAlertConfig[]
107+
): ModuleWithProviders<AlertActionModule> {
108+
return {
109+
ngModule: AlertActionModule,
110+
providers: [
111+
{
112+
provide: ACTION_TO_ALERT_PROVIDER,
113+
multi: true,
114+
useFactory: providerFactory,
115+
},
116+
],
117+
};
118+
}
119+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/* Copyright 2020 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {TestBed} from '@angular/core/testing';
16+
import {EffectsModule} from '@ngrx/effects';
17+
import {provideMockActions} from '@ngrx/effects/testing';
18+
import {Action, createAction, Store} from '@ngrx/store';
19+
import {MockStore, provideMockStore} from '@ngrx/store/testing';
20+
import {ReplaySubject} from 'rxjs';
21+
import {State} from '../app_state';
22+
import * as alertActions from './actions';
23+
import {AlertActionModule} from './alert_action_module';
24+
import {AlertEffects} from './effects';
25+
26+
const alertActionOccurred = createAction('[Test] Action Occurred (need alert)');
27+
const noAlertActionOccurred = createAction('[Test] Action Occurred (no alert)');
28+
29+
describe('alert_effects', () => {
30+
let actions$: ReplaySubject<Action>;
31+
let store: MockStore<Partial<State>>;
32+
let recordedActions: Action[] = [];
33+
let shouldReportAlert: boolean;
34+
35+
beforeEach(async () => {
36+
shouldReportAlert = false;
37+
actions$ = new ReplaySubject<Action>(1);
38+
39+
await TestBed.configureTestingModule({
40+
imports: [
41+
AlertActionModule.registerAlertActions(() => [
42+
{
43+
actionCreator: alertActionOccurred,
44+
alertFromAction: (action: Action) => {
45+
if (shouldReportAlert) {
46+
return {localizedMessage: 'alert details'};
47+
}
48+
return null;
49+
},
50+
},
51+
]),
52+
EffectsModule.forFeature([AlertEffects]),
53+
EffectsModule.forRoot([]),
54+
],
55+
providers: [provideMockActions(actions$), provideMockStore({})],
56+
}).compileComponents();
57+
58+
store = TestBed.inject<Store<State>>(Store) as MockStore<State>;
59+
recordedActions = [];
60+
spyOn(store, 'dispatch').and.callFake((action: Action) => {
61+
recordedActions.push(action);
62+
});
63+
});
64+
65+
it(`reports an alert when 'alertFromAction' returns a report`, () => {
66+
shouldReportAlert = true;
67+
actions$.next(alertActionOccurred);
68+
69+
expect(recordedActions).toEqual([
70+
alertActions.alertReported({localizedMessage: 'alert details'}),
71+
]);
72+
});
73+
74+
it(`does not alert when 'alertFromAction' returns null`, () => {
75+
shouldReportAlert = false;
76+
actions$.next(alertActionOccurred);
77+
78+
expect(recordedActions).toEqual([]);
79+
});
80+
81+
it(`does not alert when a non-matching action is fired`, () => {
82+
shouldReportAlert = true;
83+
actions$.next(noAlertActionOccurred);
84+
85+
expect(recordedActions).toEqual([]);
86+
});
87+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* Copyright 2020 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {NgModule} from '@angular/core';
16+
import {EffectsModule} from '@ngrx/effects';
17+
import {StoreModule} from '@ngrx/store';
18+
import {AlertActionModule} from './alert_action_module';
19+
import {AlertEffects} from './effects';
20+
import {reducers} from './store';
21+
import {ALERT_FEATURE_KEY} from './store/alert_types';
22+
import {AlertSnackbarModule} from './views/alert_snackbar_module';
23+
24+
@NgModule({
25+
imports: [
26+
AlertActionModule,
27+
AlertSnackbarModule,
28+
StoreModule.forFeature(ALERT_FEATURE_KEY, reducers),
29+
EffectsModule.forFeature([AlertEffects]),
30+
],
31+
})
32+
export class AlertModule {}

0 commit comments

Comments
 (0)