[Embeddable] Improve embeddable errors appearance (#134997)

* Provide a unified error handler for the embeddable
* Provide a way to customize embeddable error
* Update visualizations embeddable to render a custom error
This commit is contained in:
Michael Dokolin 2022-06-30 18:00:12 +02:00 committed by GitHub
parent 292a6a6afb
commit ed07efd794
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 205 additions and 96 deletions

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { EuiText, EuiIcon, EuiSpacer, EuiPopover, EuiLink } from '@elastic/eui';
import { EuiText, EuiIcon, EuiPopover, EuiLink, EuiEmptyPrompt } from '@elastic/eui';
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { KibanaThemeProvider, Markdown } from '@kbn/kibana-react-plugin/public';
@ -58,12 +58,13 @@ export class ErrorEmbeddable extends Embeddable<EmbeddableInput, EmbeddableOutpu
const node = this.compact ? (
<CompactEmbeddableError>{errorMarkdown}</CompactEmbeddableError>
) : (
<div className="embPanel__error embPanel__content" data-test-subj="embeddableStackError">
<EuiText color="subdued" size="xs">
<EuiIcon type="alert" color="danger" />
<EuiSpacer size="s" />
{errorMarkdown}
</EuiText>
<div className="embPanel__content" data-test-subj="embeddableStackError">
<EuiEmptyPrompt
className="embPanel__error"
iconType="alert"
iconColor="danger"
body={errorMarkdown}
/>
</div>
);
const content =

View file

@ -159,6 +159,13 @@ export interface IEmbeddable<
*/
render(domNode: HTMLElement | Element): void;
/**
* Renders a custom embeddable error at the given node.
* @param domNode
* @returns A callback that will be called on error destroy.
*/
renderError?(domNode: HTMLElement | Element, error: ErrorLike): () => void;
/**
* Reload the embeddable so output and rendering is up to date. Especially relevant
* if the embeddable takes relative time as input (e.g. now to now-15)

View file

@ -19,6 +19,10 @@
flex: 1 1 100%;
z-index: 1;
min-height: 0; // Absolute must for Firefox to scroll contents
&[data-error] {
display: none;
}
}
// SASSTODO: this MIGHT be fixing IE

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiBadge, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EmbeddableError } from '../embeddables/i_embeddable';
interface Props {
error?: EmbeddableError;
}
export function EmbeddableErrorLabel(props: Props) {
if (!props.error) return null;
const labelText =
props.error.name === 'AbortError'
? i18n.translate('embeddableApi.panel.labelAborted', {
defaultMessage: 'Aborted',
})
: i18n.translate('embeddableApi.panel.labelError', {
defaultMessage: 'Error',
});
return (
<div className="embPanel__labelWrapper">
<div className="embPanel__label">
<EuiToolTip data-test-subj="embeddableErrorMessage" content={props.error.message}>
<EuiBadge data-test-subj="embeddableErrorLabel" color="danger">
{labelText}
</EuiBadge>
</EuiToolTip>
</div>
</div>
);
}

View file

@ -7,7 +7,7 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { ReactWrapper, mount } from 'enzyme';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
@ -165,6 +165,105 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => {
expect(findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`).length).toBe(0);
});
describe('HelloWorldContainer in error state', () => {
let component: ReactWrapper<unknown>;
let embeddable: ContactCardEmbeddable;
beforeEach(async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
embeddable = (await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {})) as ContactCardEmbeddable;
component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
application={applicationMock}
overlays={{} as any}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
jest.spyOn(embeddable, 'renderError');
});
test('renders a custom error', () => {
embeddable.triggerError(new Error('something'));
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddable.renderError).toHaveBeenCalledWith(
expect.any(HTMLElement),
new Error('something')
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
});
test('renders a custom fatal error', () => {
embeddable.triggerError(new Error('something'), true);
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddable.renderError).toHaveBeenCalledWith(
expect.any(HTMLElement),
new Error('something')
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
});
test('destroys previous error', () => {
const { renderError } = embeddable as Required<typeof embeddable>;
let destroyError: jest.MockedFunction<ReturnType<typeof renderError>>;
(embeddable.renderError as jest.MockedFunction<typeof renderError>).mockImplementationOnce(
(...args) => {
destroyError = jest.fn(renderError(...args));
return destroyError;
}
);
embeddable.triggerError(new Error('something'));
component.update();
embeddable.triggerError(new Error('another error'));
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('another error');
expect(destroyError!).toHaveBeenCalledTimes(1);
});
test('renders a default error', async () => {
embeddable.renderError = undefined;
embeddable.triggerError(new Error('something'));
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.children.length).toBeGreaterThan(0);
});
});
const renderInEditModeAndOpenContextMenu = async (
embeddableInputs: any,
getActions: UiActionsStart['getTriggerCompatibleActions'] = () => Promise.resolve([])

View file

@ -40,7 +40,6 @@ import { InspectPanelAction } from './panel_header/panel_actions/inspect_panel_a
import { EditPanelAction } from '../actions';
import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
import { EmbeddableStart } from '../../plugin';
import { EmbeddableErrorLabel } from './embeddable_error_label';
import { EmbeddableStateTransfer, ErrorEmbeddable } from '..';
const sortByOrderField = (
@ -104,7 +103,7 @@ interface State {
notifications: Array<Action<EmbeddableContext>>;
loading?: boolean;
error?: EmbeddableError;
errorEmbeddable?: ErrorEmbeddable;
destroyError?(): void;
}
interface InspectorPanelAction {
@ -129,7 +128,8 @@ type PanelUniversalActions =
| EmptyObject;
export class EmbeddablePanel extends React.Component<Props, State> {
private embeddableRoot: React.RefObject<HTMLDivElement>;
private embeddableRoot = React.createRef<HTMLDivElement>();
private errorRoot = React.createRef<HTMLDivElement>();
private parentSubscription?: Subscription;
private subscription: Subscription = new Subscription();
private mounted: boolean = false;
@ -152,8 +152,13 @@ export class EmbeddablePanel extends React.Component<Props, State> {
badges: [],
notifications: [],
};
}
this.embeddableRoot = React.createRef();
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.error !== prevState.error) {
prevState.destroyError?.();
this.setState({ destroyError: this.renderError() });
}
}
private async refreshBadges() {
@ -242,9 +247,8 @@ export class EmbeddablePanel extends React.Component<Props, State> {
if (this.parentSubscription) {
this.parentSubscription.unsubscribe();
}
if (this.state.errorEmbeddable) {
this.state.errorEmbeddable.destroy();
}
this.state.destroyError?.();
this.props.embeddable.destroy();
}
@ -258,6 +262,24 @@ export class EmbeddablePanel extends React.Component<Props, State> {
}
};
private renderError() {
if (!this.state.error || !this.errorRoot.current) {
return;
}
if (this.props.embeddable.renderError) {
return this.props.embeddable.renderError(this.errorRoot.current, this.state.error);
}
const errorEmbeddable = new ErrorEmbeddable(this.state.error, {
id: this.props.embeddable.id,
});
errorEmbeddable.render(this.errorRoot.current);
return () => errorEmbeddable.destroy();
}
public render() {
const viewOnlyMode = [ViewMode.VIEW, ViewMode.PRINT].includes(this.state.viewMode);
const classes = classNames('embPanel', {
@ -300,7 +322,13 @@ export class EmbeddablePanel extends React.Component<Props, State> {
headerId={headerId}
/>
)}
<EmbeddableErrorLabel error={this.state.error} />
{this.state.error && (
<div
className="embPanel__content"
data-test-subj="embeddableError"
ref={this.errorRoot}
/>
)}
<div className="embPanel__content" ref={this.embeddableRoot} {...contentAttrs} />
</EuiPanel>
);
@ -317,11 +345,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
});
},
(error) => {
if (this.embeddableRoot.current) {
const errorEmbeddable = new ErrorEmbeddable(error, { id: this.props.embeddable.id });
errorEmbeddable.render(this.embeddableRoot.current);
this.setState({ errorEmbeddable });
}
this.setState({ error });
}
)
);

View file

@ -9,6 +9,7 @@
import React from 'react';
import ReactDom from 'react-dom';
import { Subscription } from 'rxjs';
import type { ErrorLike } from '@kbn/expressions-plugin/common';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { Container } from '../../../containers';
import { EmbeddableOutput, Embeddable, EmbeddableInput } from '../../../embeddables';
@ -76,6 +77,12 @@ export class ContactCardEmbeddable extends Embeddable<
);
}
public renderError?(node: HTMLElement, error: ErrorLike) {
ReactDom.render(<div data-test-subj="error">{error.message}</div>, node);
return () => ReactDom.unmountComponentAtNode(node);
}
public destroy() {
super.destroy();
this.subscription.unsubscribe();
@ -85,6 +92,14 @@ export class ContactCardEmbeddable extends Embeddable<
}
public reload() {}
public triggerError(error: ErrorLike, fatal = false) {
if (fatal) {
this.onFatalError(error);
} else {
this.updateOutput({ error });
}
}
}
export const CONTACT_USER_TRIGGER = 'CONTACT_USER_TRIGGER';

View file

@ -10,10 +10,11 @@ import _, { get } from 'lodash';
import { Subscription } from 'rxjs';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { render } from 'react-dom';
import { render, unmountComponentAtNode } from 'react-dom';
import { EuiLoadingChart } from '@elastic/eui';
import { Filter, onlyDisabledFiltersChanged, Query, TimeRange } from '@kbn/es-query';
import type { KibanaExecutionContext, SavedObjectAttributes } from '@kbn/core/public';
import type { ErrorLike } from '@kbn/expressions-plugin/common';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { TimefilterContract } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
@ -393,24 +394,7 @@ export class VisualizeEmbeddable
const { error } = this.getOutput();
if (error) {
if (isFallbackDataView(this.vis.data.indexPattern)) {
render(
<VisualizationMissedSavedObjectError
viewMode={this.input.viewMode ?? ViewMode.VIEW}
renderMode={this.input.renderMode ?? 'view'}
savedObjectMeta={{
savedObjectId: this.vis.data.indexPattern.id,
savedObjectType: this.vis.data.savedSearchId
? 'search'
: DATA_VIEW_SAVED_OBJECT_TYPE,
}}
application={getApplication()}
/>,
this.domNode
);
} else {
render(<VisualizationError error={error} />, this.domNode);
}
this.renderError(this.domNode, error);
}
})
);
@ -418,6 +402,27 @@ export class VisualizeEmbeddable
await this.updateHandler();
}
public renderError(domNode: HTMLElement, error: ErrorLike | string) {
if (isFallbackDataView(this.vis.data.indexPattern)) {
render(
<VisualizationMissedSavedObjectError
viewMode={this.input.viewMode ?? ViewMode.VIEW}
renderMode={this.input.renderMode ?? 'view'}
savedObjectMeta={{
savedObjectId: this.vis.data.indexPattern.id,
savedObjectType: this.vis.data.savedSearchId ? 'search' : DATA_VIEW_SAVED_OBJECT_TYPE,
}}
application={getApplication()}
/>,
domNode
);
} else {
render(<VisualizationError error={error} />, domNode);
}
return () => unmountComponentAtNode(domNode);
}
public destroy() {
super.destroy();
this.subscriptions.forEach((s) => s.unsubscribe());

View file

@ -3287,8 +3287,6 @@
"embeddableApi.panel.enhancedDashboardPanelAriaLabel": "Panneau du tableau de bord : {title}",
"embeddableApi.panel.inspectPanel.displayName": "Inspecter",
"embeddableApi.panel.inspectPanel.untitledEmbeddableFilename": "sans titre",
"embeddableApi.panel.labelAborted": "Annulé",
"embeddableApi.panel.labelError": "Erreur",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel": "Options de panneau",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex": "Options pour le panneau {index}",
"embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel": "Options de panneau pour {title}",

View file

@ -3285,8 +3285,6 @@
"embeddableApi.panel.enhancedDashboardPanelAriaLabel": "ダッシュボードパネル:{title}",
"embeddableApi.panel.inspectPanel.displayName": "検査",
"embeddableApi.panel.inspectPanel.untitledEmbeddableFilename": "無題",
"embeddableApi.panel.labelAborted": "中断しました",
"embeddableApi.panel.labelError": "エラー",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel": "パネルオプション",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex": "パネル{index}のオプション",
"embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel": "{title} のパネルオプション",

View file

@ -3289,8 +3289,6 @@
"embeddableApi.panel.enhancedDashboardPanelAriaLabel": "仪表板面板:{title}",
"embeddableApi.panel.inspectPanel.displayName": "检查",
"embeddableApi.panel.inspectPanel.untitledEmbeddableFilename": "未命名",
"embeddableApi.panel.labelAborted": "已中止",
"embeddableApi.panel.labelError": "错误",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel": "面板选项",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex": "面板 {index} 的选项",
"embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel": "{title} 的面板选项",

View file

@ -36,7 +36,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.loadSavedDashboard('Not Delayed');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.missingOrFail('embeddableErrorLabel');
await testSubjects.missingOrFail('embeddableError');
await enableNewChartLibraryDebug();
const data = await PageObjects.visChart.getBarChartData(xyChartSelector, 'Sum of bytes');
expect(data.length).to.be(5);
@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.loadSavedDashboard('Delayed 5s');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.missingOrFail('embeddableErrorLabel');
await testSubjects.missingOrFail('embeddableError');
await enableNewChartLibraryDebug();
const data = await PageObjects.visChart.getBarChartData(xyChartSelector, 'Sum of bytes');
expect(data.length).to.be(5);
@ -56,7 +56,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.loadSavedDashboard('Delayed 15s');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('embeddableErrorLabel');
await testSubjects.existOrFail('embeddableError');
await testSubjects.existOrFail('searchTimeoutError');
});
@ -64,9 +64,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.loadSavedDashboard('Multiple delayed');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('embeddableErrorLabel');
await testSubjects.existOrFail('embeddableError');
// there should be two failed panels
expect((await testSubjects.findAll('embeddableErrorLabel')).length).to.be(2);
expect((await testSubjects.findAll('embeddableError')).length).to.be(2);
// but only single error toast because searches are grouped
expect((await testSubjects.findAll('searchTimeoutError')).length).to.be(1);

View file

@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await browser.get(savedSessionURL);
await PageObjects.header.waitUntilLoadingHasFinished();
await searchSessions.expectState('restored');
await testSubjects.existOrFail('embeddableErrorLabel'); // expected that panel errors out because of non existing session
await testSubjects.existOrFail('embeddableError'); // expected that panel errors out because of non existing session
const session1 = await dashboardPanelActions.getSearchSessionIdByTitle(
'Sum of Bytes by Extension'
@ -56,7 +56,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await queryBar.clickQuerySubmitButton();
await PageObjects.header.waitUntilLoadingHasFinished();
await searchSessions.expectState('completed');
await testSubjects.missingOrFail('embeddableErrorLabel');
await testSubjects.missingOrFail('embeddableError');
const session2 = await dashboardPanelActions.getSearchSessionIdByTitle(
'Sum of Bytes by Extension'
);
@ -97,7 +97,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// Check that session is restored
await searchSessions.expectState('restored');
await testSubjects.missingOrFail('embeddableErrorLabel');
await testSubjects.missingOrFail('embeddableError');
// switching dashboard to edit mode (or any other non-fetch required) state change
// should leave session state untouched

View file

@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
async function checkSampleDashboardLoaded(visualizationContainer?: string) {
log.debug('Checking no error labels');
await testSubjects.missingOrFail('embeddableErrorLabel');
await testSubjects.missingOrFail('embeddableError');
log.debug('Checking charts rendered');
await elasticChart.waitForRenderComplete(visualizationContainer ?? 'lnsVisualizationContainer');
log.debug('Checking saved searches rendered');

View file

@ -115,7 +115,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// Check that session is restored
await searchSessions.expectState('restored');
await testSubjects.missingOrFail('embeddableErrorLabel');
await testSubjects.missingOrFail('embeddableError');
expect(await toasts.getToastCount()).to.be(0); // no session restoration related warnings
});
});