[Embeddable] Add unified error UI (#143367)

* Refactor embeddable error handler
* Remove embeddable error handler from the visualization embeddable
* Update Lens embeddable to handle errors correctly
This commit is contained in:
Michael Dokolin 2022-11-04 16:46:32 +01:00 committed by GitHub
parent 4b617042f3
commit 0a72c67838
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 195 additions and 140 deletions

View file

@ -0,0 +1,57 @@
/*
* 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 { isFunction } from 'lodash';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { isPromise } from '@kbn/std';
import type { MaybePromise } from '@kbn/utility-types';
import type { ErrorLike } from '@kbn/expressions-plugin/common';
import type { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
type IReactEmbeddable = IEmbeddable<EmbeddableInput, EmbeddableOutput, MaybePromise<ReactNode>>;
interface EmbeddableErrorHandlerProps {
children: IReactEmbeddable['catchError'];
embeddable?: IReactEmbeddable;
error: ErrorLike | string;
}
export function EmbeddableErrorHandler({
children,
embeddable,
error,
}: EmbeddableErrorHandlerProps) {
const [node, setNode] = useState<ReactNode>();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) {
return;
}
const handler = embeddable?.catchError?.bind(embeddable) ?? children;
if (!handler) {
return;
}
const renderedNode = handler(
typeof error === 'string' ? { message: error, name: '' } : error,
ref.current
);
if (isFunction(renderedNode)) {
return renderedNode;
}
if (isPromise(renderedNode)) {
renderedNode.then(setNode);
} else {
setNode(renderedNode);
}
}, [children, embeddable, error]);
return <div ref={ref}>{node}</div>;
}

View file

@ -12,6 +12,7 @@ import { EuiText } from '@elastic/eui';
import { isPromise } from '@kbn/std';
import { MaybePromise } from '@kbn/utility-types';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { EmbeddableErrorHandler } from './embeddable_error_handler';
interface Props {
embeddable?: IEmbeddable<EmbeddableInput, EmbeddableOutput, MaybePromise<ReactNode>>;
@ -91,7 +92,11 @@ export class EmbeddableRoot extends React.Component<Props, State> {
<React.Fragment>
<div ref={this.root}>{this.state.node}</div>
{this.props.loading && <EuiLoadingSpinner data-test-subj="embedSpinner" />}
{this.props.error && <EuiText data-test-subj="embedError">{this.props.error}</EuiText>}
{this.props.error && (
<EmbeddableErrorHandler embeddable={this.props.embeddable} error={this.props.error}>
{({ message }) => <EuiText data-test-subj="embedError">{message}</EuiText>}
</EmbeddableErrorHandler>
)}
</React.Fragment>
);
}

View file

@ -6,9 +6,8 @@
* Side Public License, v 1.
*/
import { EuiEmptyPrompt } from '@elastic/eui';
import React, { ReactNode } from 'react';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import { EmbeddablePanelError } from '../panel/embeddable_panel_error';
import { Embeddable } from './embeddable';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { IContainer } from '../containers';
@ -33,20 +32,8 @@ export class ErrorEmbeddable extends Embeddable<EmbeddableInput, EmbeddableOutpu
public reload() {}
public render() {
const title = typeof this.error === 'string' ? this.error : this.error.message;
const body = (
<Markdown markdown={title} openLinksInNewTab={true} data-test-subj="errorMessageMarkdown" />
);
const error = typeof this.error === 'string' ? { message: this.error, name: '' } : this.error;
return (
<div className="embPanel__content" data-test-subj="embeddableStackError">
<EuiEmptyPrompt
className="embPanel__error"
iconType="alert"
iconColor="danger"
body={body}
/>
</div>
);
return <EmbeddablePanelError embeddable={this} error={error} />;
}
}

View file

@ -9,6 +9,7 @@
export type { EmbeddableOutput, EmbeddableInput, IEmbeddable } from './i_embeddable';
export { isEmbeddable } from './is_embeddable';
export { Embeddable } from './embeddable';
export { EmbeddableErrorHandler } from './embeddable_error_handler';
export * from './embeddable_factory';
export * from './embeddable_factory_definition';
export * from './default_embeddable_factory_provider';

View file

@ -29,13 +29,6 @@
.embPanel__content--fullWidth {
width: 100%;
}
.embPanel__content--error {
&:hover {
box-shadow: none;
transform: none;
}
}
}
// HEADER
@ -165,11 +158,12 @@
}
.embPanel__error {
text-align: center;
justify-content: center;
flex-direction: column;
overflow: auto;
padding: $euiSizeS;
padding: $euiSizeL;
& > * {
max-height: 100%;
overflow: auto;
}
}
.embPanel__label {

View file

@ -6,7 +6,13 @@
* Side Public License, v 1.
*/
import { EuiContextMenuPanelDescriptor, EuiPanel, htmlIdGenerator } from '@elastic/eui';
import {
EuiContextMenuPanelDescriptor,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
htmlIdGenerator,
} from '@elastic/eui';
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import { Subscription } from 'rxjs';
@ -27,11 +33,11 @@ import {
contextMenuTrigger,
} from '../triggers';
import {
IEmbeddable,
EmbeddableOutput,
EmbeddableError,
EmbeddableErrorHandler,
EmbeddableInput,
} from '../embeddables/i_embeddable';
EmbeddableOutput,
IEmbeddable,
} from '../embeddables';
import { ViewMode } from '../types';
import { EmbeddablePanelError } from './embeddable_panel_error';
@ -105,7 +111,7 @@ interface State {
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;
loading?: boolean;
error?: EmbeddableError;
error?: Error;
destroyError?(): void;
node?: ReactNode;
}
@ -301,11 +307,24 @@ export class EmbeddablePanel extends React.Component<Props, State> {
/>
)}
{this.state.error && (
<EmbeddablePanelError
editPanelAction={this.state.universalActions.editPanel}
embeddable={this.props.embeddable}
error={this.state.error}
/>
<EuiFlexGroup
alignItems="center"
className="eui-fullHeight embPanel__error"
data-test-subj="embeddableError"
justifyContent="center"
>
<EuiFlexItem>
<EmbeddableErrorHandler embeddable={this.props.embeddable} error={this.state.error}>
{(error) => (
<EmbeddablePanelError
editPanelAction={this.state.universalActions.editPanel}
embeddable={this.props.embeddable}
error={error}
/>
)}
</EmbeddableErrorHandler>
</EuiFlexItem>
</EuiFlexGroup>
)}
<div className="embPanel__content" ref={this.embeddableRoot} {...contentAttrs}>
{this.state.node}

View file

@ -6,16 +6,15 @@
* Side Public License, v 1.
*/
import { isFunction } from 'lodash';
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { EuiPanel } from '@elastic/eui';
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { EuiButtonEmpty, EuiEmptyPrompt, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isPromise } from '@kbn/std';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import type { MaybePromise } from '@kbn/utility-types';
import { ErrorLike } from '@kbn/expressions-plugin/common';
import { distinctUntilChanged, merge, of, switchMap } from 'rxjs';
import { EditPanelAction } from '../actions';
import { EmbeddableInput, EmbeddableOutput, ErrorEmbeddable, IEmbeddable } from '../embeddables';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../embeddables';
interface EmbeddablePanelErrorProps {
editPanelAction?: EditPanelAction;
@ -29,27 +28,25 @@ export function EmbeddablePanelError({
error,
}: EmbeddablePanelErrorProps) {
const [isEditable, setEditable] = useState(false);
const [node, setNode] = useState<ReactNode>();
const ref = useRef<HTMLDivElement>(null);
const handleErrorClick = useMemo(
() => (isEditable ? () => editPanelAction?.execute({ embeddable }) : undefined),
[editPanelAction, embeddable, isEditable]
);
const title = embeddable.getTitle();
const actionDisplayName = useMemo(
const label = useMemo(
() => editPanelAction?.getDisplayName({ embeddable }),
[editPanelAction, embeddable]
);
const title = useMemo(() => embeddable.getTitle(), [embeddable]);
const ariaLabel = useMemo(
() =>
!title
? actionDisplayName
? label
: i18n.translate('embeddableApi.panel.editPanel.displayName', {
defaultMessage: 'Edit {value}',
values: { value: title },
}),
[title, actionDisplayName]
[label, title]
);
useEffect(() => {
@ -62,42 +59,29 @@ export function EmbeddablePanelError({
return () => subscription.unsubscribe();
}, [editPanelAction, embeddable]);
useEffect(() => {
if (!ref.current) {
return;
}
if (!embeddable.catchError) {
const errorEmbeddable = new ErrorEmbeddable(error, { id: embeddable.id });
setNode(errorEmbeddable.render());
return () => errorEmbeddable.destroy();
}
const renderedNode = embeddable.catchError(error, ref.current);
if (isFunction(renderedNode)) {
return renderedNode;
}
if (isPromise(renderedNode)) {
renderedNode.then(setNode);
} else {
setNode(renderedNode);
}
}, [embeddable, error]);
return (
<EuiPanel
element="div"
className="embPanel__content embPanel__content--error"
color="transparent"
paddingSize="none"
data-test-subj="embeddableError"
panelRef={ref}
role={isEditable ? 'button' : undefined}
aria-label={isEditable ? ariaLabel : undefined}
onClick={handleErrorClick}
>
{node}
</EuiPanel>
<EuiEmptyPrompt
body={
<EuiText size="s">
<Markdown
markdown={error.message}
openLinksInNewTab={true}
data-test-subj="errorMessageMarkdown"
/>
</EuiText>
}
data-test-subj="embeddableStackError"
iconType="alert"
iconColor="danger"
layout="vertical"
actions={
isEditable && (
<EuiButtonEmpty aria-label={ariaLabel} onClick={handleErrorClick} size="s">
{label}
</EuiButtonEmpty>
)
}
/>
);
}

View file

@ -12,28 +12,25 @@ import React from 'react';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { ApplicationStart } from '@kbn/core/public';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-plugin/common';
import type { ViewMode } from '@kbn/embeddable-plugin/common';
import type { RenderMode } from '@kbn/expressions-plugin/common';
interface VisualizationMissedSavedObjectErrorProps {
savedObjectMeta: {
savedObjectType: typeof DATA_VIEW_SAVED_OBJECT_TYPE | 'search';
savedObjectId?: string;
};
application: ApplicationStart;
viewMode: ViewMode;
message: string;
renderMode: RenderMode;
}
export const VisualizationMissedSavedObjectError = ({
savedObjectMeta,
application,
viewMode,
message,
renderMode,
}: VisualizationMissedSavedObjectErrorProps) => {
const { management: isManagementEnabled } = application.capabilities.navLinks;
const isIndexPatternManagementEnabled = application.capabilities.management.kibana.indexPatterns;
const isEditVisEnabled = application.capabilities.visualize?.save;
return (
<EuiEmptyPrompt
@ -59,33 +56,7 @@ export const VisualizationMissedSavedObjectError = ({
</RedirectAppLinks>
) : null
}
body={
<>
<p>
{i18n.translate('visualizations.missedDataView.errorMessage', {
defaultMessage: `Could not find the {type}: {id}`,
values: {
id: savedObjectMeta.savedObjectId ?? '-',
type:
savedObjectMeta.savedObjectType === 'search'
? i18n.translate('visualizations.noSearch.label', {
defaultMessage: 'search',
})
: i18n.translate('visualizations.noDataView.label', {
defaultMessage: 'data view',
}),
},
})}
</p>
{viewMode === 'edit' && renderMode !== 'edit' && isEditVisEnabled ? (
<p>
{i18n.translate('visualizations.missedDataView.editInVisualizeEditor', {
defaultMessage: `Edit in Visualize editor to fix the error`,
})}
</p>
) : null}
</>
}
body={<p>{message}</p>}
/>
);
};

View file

@ -29,7 +29,6 @@ import {
IContainer,
ReferenceOrValueEmbeddable,
SavedObjectEmbeddableInput,
ViewMode,
} from '@kbn/embeddable-plugin/public';
import {
ExpressionAstExpression,
@ -401,6 +400,25 @@ export class VisualizeEmbeddable
this.abortController.abort();
}
this.renderComplete.dispatchError();
if (isFallbackDataView(this.vis.data.indexPattern)) {
error = new Error(
i18n.translate('visualizations.missedDataView.errorMessage', {
defaultMessage: `Could not find the {type}: {id}`,
values: {
id: this.vis.data.indexPattern.id ?? '-',
type: this.vis.data.savedSearchId
? i18n.translate('visualizations.noSearch.label', {
defaultMessage: 'search',
})
: i18n.translate('visualizations.noDataView.label', {
defaultMessage: 'data view',
}),
},
})
);
}
this.updateOutput({
...this.getOutput(),
rendered: true,
@ -503,7 +521,7 @@ export class VisualizeEmbeddable
const { error } = this.getOutput();
if (error) {
render(this.catchError(error), this.domNode);
render(this.renderError(error), this.domNode);
}
})
);
@ -511,17 +529,16 @@ export class VisualizeEmbeddable
await this.updateHandler();
}
public catchError(error: ErrorLike | string) {
private renderError(error: ErrorLike | string) {
if (isFallbackDataView(this.vis.data.indexPattern)) {
return (
<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()}
message={typeof error === 'string' ? error : error.message}
/>
);
}

View file

@ -134,7 +134,7 @@ export class VisualizeEmbeddableFactory
public getDisplayName() {
return i18n.translate('visualizations.displayName', {
defaultMessage: 'Visualization',
defaultMessage: 'visualization',
});
}

View file

@ -51,14 +51,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// wrapping into own describe to make sure new tab is cleaned up even if test failed
// see: https://github.com/elastic/kibana/pull/67280#discussion_r430528122
describe('recreate index pattern link works', () => {
it('recreate index pattern link works', async () => {
describe('when the saved object is missing', () => {
it('shows the missing data view error message', async () => {
await PageObjects.dashboard.gotoDashboardLandingPage();
await PageObjects.dashboard.loadSavedDashboard('dashboard with missing index pattern');
await PageObjects.header.waitUntilLoadingHasFinished();
const errorEmbeddable = await testSubjects.find('visualization-missed-data-view-error');
const embeddableError = await testSubjects.find('embeddableError');
const errorMessage = await embeddableError.getVisibleText();
expect(await errorEmbeddable.isDisplayed()).to.be(true);
expect(errorMessage).to.contain('Could not find the data view');
});
});
});

View file

@ -645,6 +645,25 @@ export class Embeddable
}
}
private getError(): Error | undefined {
const message =
typeof this.errors?.[0]?.longMessage === 'string'
? this.errors[0].longMessage
: this.errors?.[0]?.shortMessage;
if (message != null) {
return new Error(message);
}
if (!this.expression) {
return new Error(
i18n.translate('xpack.lens.embeddable.failure', {
defaultMessage: "Visualization couldn't be displayed",
})
);
}
}
/**
*
* @param {HTMLElement} domNode
@ -665,7 +684,7 @@ export class Embeddable
this.updateOutput({
...this.getOutput(),
loading: true,
error: undefined,
error: this.getError(),
});
this.renderComplete.dispatchInProgress();
@ -697,7 +716,8 @@ export class Embeddable
style={input.style}
executionContext={this.getExecutionContext()}
canEdit={this.getIsEditable() && input.viewMode === 'edit'}
onRuntimeError={() => {
onRuntimeError={(message) => {
this.updateOutput({ error: new Error(message) });
this.logError('runtime');
}}
noPadding={this.visDisplayOptions?.noPadding}

View file

@ -79,7 +79,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
getDisplayName() {
return i18n.translate('xpack.lens.embeddableDisplayName', {
defaultMessage: 'lens',
defaultMessage: 'Lens',
});
}

View file

@ -44,7 +44,7 @@ export interface ExpressionWrapperProps {
style?: React.CSSProperties;
className?: string;
canEdit: boolean;
onRuntimeError: () => void;
onRuntimeError: (message?: string) => void;
executionContext?: KibanaExecutionContext;
lensInspector: LensInspector;
noPadding?: boolean;
@ -148,7 +148,9 @@ export function ExpressionWrapper({
syncCursor={syncCursor}
executionContext={executionContext}
renderError={(errorMessage, error) => {
onRuntimeError();
const messages = getOriginalRequestErrorMessages(error);
onRuntimeError(messages[0] ?? errorMessage);
return (
<div data-test-subj="expression-renderer-error">
<EuiFlexGroup direction="column" alignItems="center" justifyContent="center">
@ -156,7 +158,7 @@ export function ExpressionWrapper({
<EuiIcon type="alert" color="danger" />
</EuiFlexItem>
<EuiFlexItem>
{(getOriginalRequestErrorMessages(error) || [errorMessage]).map((message) => (
{messages.map((message) => (
<EuiText size="s">{message}</EuiText>
))}
</EuiFlexItem>

View file

@ -6140,7 +6140,7 @@
"visualizations.deprecatedTag": "Déclassé",
"visualizations.disabledLabVisualizationLink": "Lire la documentation",
"visualizations.disabledLabVisualizationMessage": "Veuillez activer le mode lab dans les paramètres avancés pour consulter les visualisations lab.",
"visualizations.displayName": "Visualisation",
"visualizations.displayName": "visualisation",
"visualizations.editor.createBreadcrumb": "Créer",
"visualizations.editor.defaultEditBreadcrumbText": "Modifier la visualisation",
"visualizations.embeddable.inspectorTitle": "Inspecteur",
@ -6175,7 +6175,6 @@
"visualizations.listing.table.typeColumnName": "Type",
"visualizations.listingPageTitle": "Bibliothèque Visualize",
"visualizations.missedDataView.dataViewReconfigure": "Recréez-la dans la page de gestion des vues de données.",
"visualizations.missedDataView.editInVisualizeEditor": "Effectuer des modifications dans l'éditeur Visualize pour corriger l'erreur",
"visualizations.newChart.conditionalMessage.advancedSettingsLink": "Paramètres avancés.",
"visualizations.newChart.libraryMode.new": "nouveau",
"visualizations.newChart.libraryMode.old": "âge",
@ -17450,7 +17449,7 @@
"xpack.lens.embeddable.missingTimeRangeParam.longMessage": "La propriété timeRange est requise pour cette configuration.",
"xpack.lens.embeddable.missingTimeRangeParam.shortMessage": "Propriété timeRange manquante",
"xpack.lens.embeddable.moreErrors": "Effectuez des modifications dans l'éditeur Lens pour afficher plus d'erreurs",
"xpack.lens.embeddableDisplayName": "lens",
"xpack.lens.embeddableDisplayName": "Lens",
"xpack.lens.endValue.nearest": "La plus proche",
"xpack.lens.endValue.none": "Masquer",
"xpack.lens.endValue.zero": "Zéro",

View file

@ -6169,7 +6169,6 @@
"visualizations.listing.table.typeColumnName": "型",
"visualizations.listingPageTitle": "Visualizeライブラリ",
"visualizations.missedDataView.dataViewReconfigure": "データビュー管理ページで再作成",
"visualizations.missedDataView.editInVisualizeEditor": "Visualizeエディターで編集し、エラーを修正",
"visualizations.newChart.conditionalMessage.advancedSettingsLink": "高度な設定",
"visualizations.newChart.libraryMode.new": "新規",
"visualizations.newChart.libraryMode.old": "古",

View file

@ -6176,7 +6176,6 @@
"visualizations.listing.table.typeColumnName": "类型",
"visualizations.listingPageTitle": "Visualize 库",
"visualizations.missedDataView.dataViewReconfigure": "在数据视图管理页面中重新创建",
"visualizations.missedDataView.editInVisualizeEditor": "在 Visualize 编辑器中编辑以修复该错误",
"visualizations.newChart.conditionalMessage.advancedSettingsLink": "免费的 API 密钥。",
"visualizations.newChart.libraryMode.new": "新",
"visualizations.newChart.libraryMode.old": "以前",
@ -17458,7 +17457,7 @@
"xpack.lens.embeddable.missingTimeRangeParam.longMessage": "给定配置需要包含 timeRange 属性",
"xpack.lens.embeddable.missingTimeRangeParam.shortMessage": "缺少 timeRange 属性",
"xpack.lens.embeddable.moreErrors": "在 Lens 编辑器中编辑以查看更多错误",
"xpack.lens.embeddableDisplayName": "lens",
"xpack.lens.embeddableDisplayName": "Lens",
"xpack.lens.endValue.nearest": "最近",
"xpack.lens.endValue.none": "隐藏",
"xpack.lens.endValue.zero": "零",