Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Embeddable] Add unified error UI #143367

Merged
merged 7 commits into from
Nov 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>>;
Expand Down Expand Up @@ -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>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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" />
);

return (
<div className="embPanel__content" data-test-subj="embeddableStackError">
<EuiEmptyPrompt
className="embPanel__error"
iconType="alert"
iconColor="danger"
body={body}
/>
</div>
);
const error = typeof this.error === 'string' ? { message: this.error, name: '' } : this.error;

return <EmbeddablePanelError embeddable={this} error={error} />;
}
}
1 change: 1 addition & 0 deletions src/plugins/embeddable/public/lib/embeddables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 6 additions & 12 deletions src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,6 @@
.embPanel__content--fullWidth {
width: 100%;
}

.embPanel__content--error {
&:hover {
box-shadow: none;
transform: none;
}
}
}

// HEADER
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 30 additions & 11 deletions src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -105,7 +111,7 @@ interface State {
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;
loading?: boolean;
error?: EmbeddableError;
error?: Error;
destroyError?(): void;
node?: ReactNode;
}
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(() => {
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to also include a title prop (along with an appropriate titleSize) for this EuiEmptyPrompt component (either dynamic or static)? If a static title, would something like Unable to render visualization work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to add that similarly to the button, but it requires significant changes in the code to get the embeddable type. I would rather leave that without the title prop. Especially since the error appearance is similar to what we had before.

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}
dokmic marked this conversation as resolved.
Show resolved Hide resolved
</EuiButtonEmpty>
)
}
/>
);
}
Loading