Skip to content

Commit 03b393f

Browse files
authored
Add VisLayer error toasts when rendering vis (#3649)
* Add VisLayer error toasts when rendering vis * Finalize formatting of err stack details * Add one test case * Make message of VisLayerError required * Move test_helpers; clean up test; add comment * Add id to error toast * Add context to helper fns * Change back to toStrictEqual() for tests Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com> --------- Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com>
1 parent 3a719c5 commit 03b393f

File tree

12 files changed

+211
-23
lines changed

12 files changed

+211
-23
lines changed

src/core/public/notifications/toasts/toasts_api.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ export interface ErrorToastOptions extends ToastOptions {
8787
* message will still be shown in the detailed error modal.
8888
*/
8989
toastMessage?: string;
90+
/**
91+
* Unique ID for the toast. Can be used to prevent duplicate toasts on re-renders.
92+
*/
93+
id?: string;
9094
}
9195

9296
const normalizeToast = (toastOrTitle: ToastInput): ToastInputFields => {

src/plugins/vis_augmenter/public/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
PointInTimeEvent,
2222
PointInTimeEventsVisLayer,
2323
isPointInTimeEventsVisLayer,
24+
isVisLayerWithError,
2425
} from './types';
2526

2627
export * from './expressions';

src/plugins/vis_augmenter/public/saved_augment_vis/utils/test_helpers.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { cloneDeep } from 'lodash';
7-
import { VisLayerExpressionFn, ISavedAugmentVis } from '../../types';
7+
import { VisLayerExpressionFn, ISavedAugmentVis } from '../../';
88
import { VIS_REFERENCE_NAME } from '../saved_augment_vis_references';
99

1010
const pluginResourceId = 'test-plugin-resource-id';

src/plugins/vis_augmenter/public/types.test.ts

+19-14
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,13 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { VisLayerTypes, VisLayer, isPointInTimeEventsVisLayer, isValidVisLayer } from './types';
7-
8-
const generateVisLayer = (type: any): VisLayer => {
9-
return {
10-
type,
11-
originPlugin: 'test-plugin',
12-
pluginResource: {
13-
type: 'test-resource-type',
14-
id: 'test-resource-id',
15-
name: 'test-resource-name',
16-
urlPath: 'test-resource-url-path',
17-
},
18-
};
19-
};
6+
import {
7+
VisLayerTypes,
8+
isPointInTimeEventsVisLayer,
9+
isValidVisLayer,
10+
isVisLayerWithError,
11+
} from './types';
12+
import { generateVisLayer } from './utils';
2013

2114
describe('isPointInTimeEventsVisLayer()', function () {
2215
it('should return false if type does not match', function () {
@@ -41,3 +34,15 @@ describe('isValidVisLayer()', function () {
4134
expect(isValidVisLayer(visLayer)).toBe(true);
4235
});
4336
});
37+
38+
describe('isVisLayerWithError()', function () {
39+
it('should return false if no error', function () {
40+
const visLayer = generateVisLayer('unknown-vis-layer-type', false);
41+
expect(isVisLayerWithError(visLayer)).toBe(false);
42+
});
43+
44+
it('should return true if error', function () {
45+
const visLayer = generateVisLayer(VisLayerTypes.PointInTimeEvents, true);
46+
expect(isVisLayerWithError(visLayer)).toBe(true);
47+
});
48+
});

src/plugins/vis_augmenter/public/types.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export enum VisLayerErrorTypes {
1414

1515
export interface VisLayerError {
1616
type: keyof typeof VisLayerErrorTypes;
17-
message?: string;
17+
message: string;
1818
}
1919

2020
export type PluginResourceType = string;
@@ -53,6 +53,15 @@ export const isPointInTimeEventsVisLayer = (obj: any) => {
5353
return obj?.type === VisLayerTypes.PointInTimeEvents;
5454
};
5555

56+
/**
57+
* Used to determine if a given saved obj has a valid type and can
58+
* be converted into a VisLayer
59+
*/
5660
export const isValidVisLayer = (obj: any) => {
5761
return obj?.type in VisLayerTypes;
5862
};
63+
64+
/**
65+
* Used for checking if an existing VisLayer has a populated error field or not
66+
*/
67+
export const isVisLayerWithError = (visLayer: VisLayer): boolean => visLayer.error !== undefined;

src/plugins/vis_augmenter/public/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
*/
55

66
export * from './utils';
7+
export * from './test_helpers';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { get } from 'lodash';
7+
import { VisLayer, VisLayerErrorTypes } from '../types';
8+
9+
export const generateVisLayer = (
10+
type: any,
11+
error: boolean = false,
12+
errorMessage: string = 'some-error-message',
13+
resource?: {
14+
type?: string;
15+
id?: string;
16+
name?: string;
17+
urlPath?: string;
18+
}
19+
): VisLayer => {
20+
return {
21+
type,
22+
originPlugin: 'test-plugin',
23+
pluginResource: {
24+
type: get(resource, 'type', 'test-resource-type'),
25+
id: get(resource, 'id', 'test-resource-id'),
26+
name: get(resource, 'name', 'test-resource-name'),
27+
urlPath: get(resource, 'urlPath', 'test-resource-url-path'),
28+
},
29+
error: error
30+
? {
31+
type: VisLayerErrorTypes.FETCH_FAILURE,
32+
message: errorMessage,
33+
}
34+
: undefined,
35+
};
36+
};

src/plugins/vis_augmenter/public/utils/utils.test.ts

+69-2
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ import { Vis } from '../../../visualizations/public';
77
import {
88
buildPipelineFromAugmentVisSavedObjs,
99
getAugmentVisSavedObjs,
10+
getAnyErrors,
1011
isEligibleForVisLayers,
1112
} from './utils';
12-
import { VisLayerTypes, ISavedAugmentVis, VisLayerExpressionFn } from '../types';
1313
import {
1414
createSavedAugmentVisLoader,
1515
SavedObjectOpenSearchDashboardsServicesWithAugmentVis,
1616
getMockAugmentVisSavedObjectClient,
1717
generateAugmentVisSavedObject,
18-
} from '../saved_augment_vis';
18+
ISavedAugmentVis,
19+
VisLayerExpressionFn,
20+
VisLayerTypes,
21+
} from '../';
22+
import { generateVisLayer } from './';
1923

2024
describe('utils', () => {
2125
// TODO: redo / update this test suite when eligibility is finalized.
@@ -129,4 +133,67 @@ describe('utils', () => {
129133
expect(str).toEqual(`fn-1 arg1="value-1"\n| fn-2 arg2="value-2"`);
130134
});
131135
});
136+
137+
describe('getAnyErrors', () => {
138+
const noErrorLayer1 = generateVisLayer(VisLayerTypes.PointInTimeEvents, false);
139+
const noErrorLayer2 = generateVisLayer(VisLayerTypes.PointInTimeEvents, false);
140+
const errorLayer1 = generateVisLayer(VisLayerTypes.PointInTimeEvents, true, 'uh-oh!', {
141+
type: 'resource-type-1',
142+
id: '1234',
143+
name: 'resource-1',
144+
});
145+
const errorLayer2 = generateVisLayer(
146+
VisLayerTypes.PointInTimeEvents,
147+
true,
148+
'oh no something terrible has happened :(',
149+
{
150+
type: 'resource-type-2',
151+
id: '5678',
152+
name: 'resource-2',
153+
}
154+
);
155+
const errorLayer3 = generateVisLayer(VisLayerTypes.PointInTimeEvents, true, 'oops!', {
156+
type: 'resource-type-1',
157+
id: 'abcd',
158+
name: 'resource-3',
159+
});
160+
161+
it('empty array - returns undefined', async () => {
162+
const err = getAnyErrors([], 'title-vis-title');
163+
expect(err).toEqual(undefined);
164+
});
165+
it('single VisLayer no errors - returns undefined', async () => {
166+
const err = getAnyErrors([noErrorLayer1], 'test-vis-title');
167+
expect(err).toEqual(undefined);
168+
});
169+
it('multiple VisLayers no errors - returns undefined', async () => {
170+
const err = getAnyErrors([noErrorLayer1, noErrorLayer2], 'test-vis-title');
171+
expect(err).toEqual(undefined);
172+
});
173+
it('single VisLayer with error - returns formatted error', async () => {
174+
const err = getAnyErrors([errorLayer1], 'test-vis-title');
175+
expect(err).not.toEqual(undefined);
176+
expect(err?.stack).toStrictEqual(`-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"`);
177+
});
178+
it('multiple VisLayers with errors - returns formatted error', async () => {
179+
const err = getAnyErrors([errorLayer1, errorLayer2], 'test-vis-title');
180+
expect(err).not.toEqual(undefined);
181+
expect(err?.stack).toStrictEqual(
182+
`-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"\n\n\n` +
183+
`-----resource-type-2-----\nID: 5678\nMessage: "oh no something terrible has happened :("`
184+
);
185+
});
186+
it('multiple VisLayers with errors of same type - returns formatted error', async () => {
187+
const err = getAnyErrors([errorLayer1, errorLayer3], 'test-vis-title');
188+
expect(err).not.toEqual(undefined);
189+
expect(err?.stack).toStrictEqual(
190+
`-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"\n\n` + `ID: abcd\nMessage: "oops!"`
191+
);
192+
});
193+
it('VisLayers with and without error - returns formatted error', async () => {
194+
const err = getAnyErrors([noErrorLayer1, errorLayer1], 'test-vis-title');
195+
expect(err).not.toEqual(undefined);
196+
expect(err?.stack).toStrictEqual(`-----resource-type-1-----\nID: 1234\nMessage: "uh-oh!"`);
197+
});
198+
});
132199
});

src/plugins/vis_augmenter/public/utils/utils.ts

+43-2
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { get } from 'lodash';
6+
import { get, isEmpty } from 'lodash';
77
import { Vis } from '../../../../plugins/visualizations/public';
88
import {
99
formatExpression,
1010
buildExpressionFunction,
1111
buildExpression,
1212
ExpressionAstFunctionBuilder,
1313
} from '../../../../plugins/expressions/public';
14-
import { ISavedAugmentVis, SavedAugmentVisLoader, VisLayerFunctionDefinition } from '../';
14+
import {
15+
ISavedAugmentVis,
16+
SavedAugmentVisLoader,
17+
VisLayerFunctionDefinition,
18+
VisLayer,
19+
isVisLayerWithError,
20+
} from '../';
1521

1622
// TODO: provide a deeper eligibility check.
1723
// Tracked in https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3268
@@ -58,3 +64,38 @@ export const buildPipelineFromAugmentVisSavedObjs = (objs: ISavedAugmentVis[]):
5864
throw new Error('Expression function from augment-vis saved objects could not be generated');
5965
}
6066
};
67+
68+
/**
69+
* Returns an error with an aggregated message about all of the
70+
* errors found in the set of VisLayers. If no errors, returns undefined.
71+
*/
72+
export const getAnyErrors = (visLayers: VisLayer[], visTitle: string): Error | undefined => {
73+
const visLayersWithErrors = visLayers.filter((visLayer) => isVisLayerWithError(visLayer));
74+
if (!isEmpty(visLayersWithErrors)) {
75+
// Aggregate by unique plugin resource type
76+
const resourceTypes = [
77+
...new Set(visLayersWithErrors.map((visLayer) => visLayer.pluginResource.type)),
78+
];
79+
80+
let msgDetails = '';
81+
resourceTypes.forEach((type, index) => {
82+
const matchingVisLayers = visLayersWithErrors.filter(
83+
(visLayer) => visLayer.pluginResource.type === type
84+
);
85+
if (index !== 0) msgDetails += '\n\n\n';
86+
msgDetails += `-----${type}-----`;
87+
matchingVisLayers.forEach((visLayer, idx) => {
88+
if (idx !== 0) msgDetails += '\n';
89+
msgDetails += `\nID: ${visLayer.pluginResource.id}`;
90+
msgDetails += `\nMessage: "${visLayer.error?.message}"`;
91+
});
92+
});
93+
94+
const err = new Error(`Certain plugin resources failed to load on the ${visTitle} chart`);
95+
// We set as the stack here so it can be parsed and shown cleanly in the details modal coming from the error toast notification.
96+
err.stack = msgDetails;
97+
return err;
98+
} else {
99+
return undefined;
100+
}
101+
};

src/plugins/visualizations/public/embeddable/visualize_embeddable.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
} from '../../../expressions/public';
5858
import { buildPipeline } from '../legacy/build_pipeline';
5959
import { Vis, SerializedVis } from '../vis';
60-
import { getExpressions, getUiActions } from '../services';
60+
import { getExpressions, getNotifications, getUiActions } from '../services';
6161
import { VIS_EVENT_TO_TRIGGER } from './events';
6262
import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory';
6363
import { TriggerId } from '../../../ui_actions/public';
@@ -71,6 +71,7 @@ import {
7171
isEligibleForVisLayers,
7272
getAugmentVisSavedObjs,
7373
buildPipelineFromAugmentVisSavedObjs,
74+
getAnyErrors,
7475
} from '../../../vis_augmenter/public';
7576
import { VisSavedObject } from '../types';
7677

@@ -511,13 +512,27 @@ export class VisualizeEmbeddable
511512
layers: [] as VisLayers,
512513
};
513514
// We cannot use this.handler in this case, since it does not support the run() cmd
514-
// we need here. So, we consume the expressions service to run this instead.
515+
// we need here. So, we consume the expressions service to run this directly instead.
515516
const exprVisLayers = (await getExpressions().run(
516517
visLayersPipeline,
517518
visLayersPipelineInput,
518519
expressionParams as Record<string, unknown>
519520
)) as ExprVisLayers;
520-
return exprVisLayers.layers;
521+
const visLayers = exprVisLayers.layers;
522+
const err = getAnyErrors(visLayers, this.vis.title);
523+
// This is only true when one or more VisLayers has an error
524+
if (err !== undefined) {
525+
const { toasts } = getNotifications();
526+
toasts.addError(err, {
527+
title: i18n.translate('visualizations.renderVisTitle', {
528+
defaultMessage: `Error loading data on the ${this.vis.title} chart`,
529+
}),
530+
toastMessage: ' ',
531+
id: this.id,
532+
});
533+
}
534+
535+
return visLayers;
521536
}
522537
return [] as VisLayers;
523538
};

src/plugins/visualizations/public/plugin.ts

+4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
Plugin,
3838
ApplicationStart,
3939
SavedObjectsClientContract,
40+
NotificationsStart,
4041
} from '../../../core/public';
4142
import { TypesService, TypesSetup, TypesStart } from './vis_types';
4243
import {
@@ -61,6 +62,7 @@ import {
6162
setOverlays,
6263
setSavedSearchLoader,
6364
setEmbeddable,
65+
setNotifications,
6466
} from './services';
6567
import {
6668
VISUALIZE_EMBEDDABLE_TYPE,
@@ -130,6 +132,7 @@ export interface VisualizationsStartDeps {
130132
dashboard: DashboardStart;
131133
getAttributeService: DashboardStart['getAttributeService'];
132134
savedObjectsClient: SavedObjectsClientContract;
135+
notifications: NotificationsStart;
133136
}
134137

135138
/**
@@ -220,6 +223,7 @@ export class VisualizationsPlugin
220223
});
221224
setSavedAugmentVisLoader(savedAugmentVisLoader);
222225
setSavedSearchLoader(savedSearchLoader);
226+
setNotifications(core.notifications);
223227
return {
224228
...types,
225229
showNewVisModal,

src/plugins/visualizations/public/services.ts

+5
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
IUiSettingsClient,
3838
OverlayStart,
3939
SavedObjectsStart,
40+
NotificationsStart,
4041
} from '../../../core/public';
4142
import { TypesStart } from './vis_types';
4243
import { createGetterSetter } from '../../opensearch_dashboards_utils/common';
@@ -111,3 +112,7 @@ export const [getSavedSearchLoader, setSavedSearchLoader] = createGetterSetter<S
111112
export const [getSavedAugmentVisLoader, setSavedAugmentVisLoader] = createGetterSetter<
112113
SavedAugmentVisLoader
113114
>('SavedAugmentVisLoader');
115+
116+
export const [getNotifications, setNotifications] = createGetterSetter<NotificationsStart>(
117+
'Notifications'
118+
);

0 commit comments

Comments
 (0)