Skip to content

Commit

Permalink
[Embeddable] Add seamless React integration (#143131)
Browse files Browse the repository at this point in the history
* Add support of React nodes rendering to the embeddable panel
* Add support of React nodes rendering by the error handler
* Update visualizations embeddable to use React for error handling
  • Loading branch information
dokmic authored Oct 24, 2022
1 parent dc02c1e commit b921a49
Show file tree
Hide file tree
Showing 26 changed files with 385 additions and 284 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,62 @@ import {
EuiFormControlLayout,
EuiFormLabel,
EuiFormRow,
EuiIcon,
EuiLink,
EuiLoadingChart,
EuiPopover,
EuiText,
EuiToolTip,
} from '@elastic/eui';

import { FormattedMessage } from '@kbn/i18n-react';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public';
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { ControlGroupReduxState } from '../types';
import { pluginServices } from '../../services';
import { EditControlButton } from '../editor/edit_control';
import { ControlGroupStrings } from '../control_group_strings';
import { useChildEmbeddable } from '../../hooks/use_child_embeddable';
import { TIME_SLIDER_CONTROL } from '../../../common';

interface ControlFrameErrorProps {
error: Error;
}

const ControlFrameError = ({ error }: ControlFrameErrorProps) => {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const popoverButton = (
<EuiText className="errorEmbeddableCompact__button" size="xs">
<EuiLink
className="eui-textTruncate"
color="subdued"
onClick={() => setPopoverOpen((open) => !open)}
>
<EuiIcon type="alert" color="danger" />
<FormattedMessage
id="controls.frame.error.message"
defaultMessage="An error has occurred. Read more"
/>
</EuiLink>
</EuiText>
);

return (
<EuiPopover
button={popoverButton}
isOpen={isPopoverOpen}
anchorClassName="errorEmbeddableCompact__popoverAnchor"
closePopover={() => setPopoverOpen(false)}
>
<Markdown
markdown={error.message}
openLinksInNewTab={true}
data-test-subj="errorMessageMarkdown"
/>
</EuiPopover>
);
};

export interface ControlFrameProps {
customPrepend?: JSX.Element;
enableActions?: boolean;
Expand All @@ -40,7 +83,7 @@ export const ControlFrame = ({
embeddableType,
}: ControlFrameProps) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const [hasFatalError, setHasFatalError] = useState(false);
const [fatalError, setFatalError] = useState<Error>();

const {
useEmbeddableSelector: select,
Expand All @@ -61,19 +104,14 @@ export const ControlFrame = ({
const usingTwoLineLayout = controlStyle === 'twoLine';

useEffect(() => {
if (embeddableRoot.current && embeddable) {
embeddable.render(embeddableRoot.current);
if (embeddableRoot.current) {
embeddable?.render(embeddableRoot.current);
}
const inputSubscription = embeddable
?.getInput$()
.subscribe((newInput) => setTitle(newInput.title));
const errorSubscription = embeddable?.getOutput$().subscribe({
error: (error: Error) => {
if (!embeddableRoot.current) return;
const errorEmbeddable = new ErrorEmbeddable(error, { id: embeddable.id }, undefined, true);
errorEmbeddable.render(embeddableRoot.current);
setHasFatalError(true);
},
error: setFatalError,
});
return () => {
inputSubscription?.unsubscribe();
Expand All @@ -88,7 +126,7 @@ export const ControlFrame = ({
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
})}
>
{!hasFatalError && embeddableType !== TIME_SLIDER_CONTROL && (
{!fatalError && embeddableType !== TIME_SLIDER_CONTROL && (
<EuiToolTip content={ControlGroupStrings.floatingActions.getEditButtonTitle()}>
<EditControlButton embeddableId={embeddableId} />
</EuiToolTip>
Expand Down Expand Up @@ -119,7 +157,7 @@ export const ControlFrame = ({
const embeddableParentClassNames = classNames('controlFrame__control', {
'controlFrame--twoLine': controlStyle === 'twoLine',
'controlFrame--oneLine': controlStyle === 'oneLine',
'controlFrame--fatalError': hasFatalError,
'controlFrame--fatalError': !!fatalError,
});

function renderEmbeddablePrepend() {
Expand Down Expand Up @@ -149,12 +187,19 @@ export const ControlFrame = ({
</>
}
>
{embeddable && (
{embeddable && !fatalError && (
<div
className={embeddableParentClassNames}
id={`controlFrame--${embeddableId}`}
ref={embeddableRoot}
/>
>
{fatalError && <ControlFrameError error={fatalError} />}
</div>
)}
{fatalError && (
<div className={embeddableParentClassNames} id={`controlFrame--${embeddableId}`}>
{<ControlFrameError error={fatalError} />}
</div>
)}
{!embeddable && (
<div className={embeddableParentClassNames} id={`controlFrame--${embeddableId}`}>
Expand Down
51 changes: 38 additions & 13 deletions src/plugins/embeddable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,15 @@ export class HelloWorldEmbeddableFactoryDefinition implements EmbeddableFactoryD
The embeddable should implement the `IEmbeddable` interface, and usually, that just extends the base class `Embeddable`.
```tsx
import React from 'react';
import { render } from 'react-dom';
import { Embeddable } from '@kbn/embeddable-plugin/public';

export const HELLO_WORLD = 'HELLO_WORLD';

export class HelloWorld extends Embeddable {
readonly type = HELLO_WORLD;

render(node: HTMLElement) {
render(<div>{this.getTitle()}</div>, node);
render() {
return <div>{this.getTitle()}</div>;
}

reload() {}
Expand All @@ -126,6 +125,21 @@ export class HelloWorld extends Embeddable {
}
```

There is also an option to return a [React node](https://reactjs.org/docs/react-component.html#render) directly.
In that case, the returned node will be automatically mounted and unmounted.
```tsx
import React from 'react';
import { Embeddable } from '@kbn/embeddable-plugin/public';

export class HelloWorld extends Embeddable {
// ...

render() {
return <div>{this.getTitle()}</div>;
}
}
```

#### `reload`
This hook is called after every input update to perform some UI changes.
```typescript
Expand All @@ -150,13 +164,13 @@ export class HelloWorld extends Embeddable {
}
```

#### `renderError`
#### `catchError`
This is an optional error handler to provide a custom UI for the error state.

The embeddable may change its state in the future so that the error should be able to disappear.
In that case, the method should return a callback performing cleanup actions for the error UI.

If there is no implementation provided for the `renderError` hook, the embeddable will render a fallback error UI.
If there is no implementation provided for the `catchError` hook, the embeddable will render a fallback error UI.

In case of an error, the embeddable UI will not be destroyed or unmounted.
The default behavior is to hide that visually and show the error message on top of that.
Expand All @@ -169,14 +183,29 @@ import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
// ...

renderError(node: HTMLElement, error: Error) {
catchError(error: Error, node: HTMLElement) {
render(<div>Something went wrong: {error.message}</div>, node);

return () => unmountComponentAtNode(node);
}
}
```

There is also an option to return a [React node](https://reactjs.org/docs/react-component.html#render) directly.
In that case, the returned node will be automatically mounted and unmounted.
```typescript
import React from 'react';
import { Embeddable } from '@kbn/embeddable-plugin/public';

export class HelloWorld extends Embeddable {
// ...

catchError(error: Error) {
return <div>Something went wrong: {error.message}</div>;
}
}
```

#### `destroy`
This hook is invoked when the embeddable is destroyed and should perform cleanup actions.
```typescript
Expand Down Expand Up @@ -366,7 +395,6 @@ To perform state mutations, the plugin also exposes a pre-defined state of the a
Here is an example of initializing a Redux store:
```tsx
import React from 'react';
import { render } from 'react-dom';
import { connect, Provider } from 'react-redux';
import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
Expand All @@ -381,16 +409,15 @@ export class HelloWorld extends Embeddable {

reload() {}

render(node: HTMLElement) {
render() {
const Component = connect((state: State<HelloWorld>) => ({ title: state.input.title }))(
HelloWorldComponent
);

render(
return (
<Provider store={this.store}>
<Component />
</Provider>,
node
</Provider>
);
}
}
Expand Down Expand Up @@ -434,7 +461,6 @@ That means there is no need to reimplement already existing actions.
```tsx
import React from 'react';
import { render } from 'react-dom';
import { createSlice } from '@reduxjs/toolkit';
import {
Embeddable,
Expand Down Expand Up @@ -523,7 +549,6 @@ This can be achieved by passing a custom reducer.
```tsx
import React from 'react';
import { render } from 'react-dom';
import { createSlice } from '@reduxjs/toolkit';
import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import React, {
useMemo,
useRef,
} from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { ReplaySubject } from 'rxjs';
import { ThemeContext } from '@emotion/react';
import { DecoratorFn, Meta } from '@storybook/react';
Expand Down Expand Up @@ -251,15 +250,13 @@ DefaultWithError.argTypes = {
export function DefaultWithCustomError({ message, ...props }: DefaultWithErrorProps) {
const ref = useRef<React.ComponentRef<typeof HelloWorldEmbeddablePanel>>(null);

useEffect(
() =>
ref.current?.embeddable.setErrorRenderer((node, error) => {
render(<EuiEmptyPrompt iconColor="warning" iconType="bug" body={error.message} />, node);

return () => unmountComponentAtNode(node);
}),
[]
);
useEffect(() => {
if (ref.current) {
ref.current.embeddable.catchError = (error) => {
return <EuiEmptyPrompt iconColor="warning" iconType="bug" body={error.message} />;
};
}
}, []);
useEffect(
() => void ref.current?.embeddable.store.dispatch(actions.output.setError(new Error(message))),
[message]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Default.args = {
loading: false,
};

export const DefaultWithError = Default as Meta<DefaultProps>;
export const DefaultWithError = Default.bind({}) as Meta<DefaultProps>;

DefaultWithError.args = {
...Default.args,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@
* Side Public License, v 1.
*/

import React, { useContext, useEffect, useMemo, useRef } from 'react';
import { filter, ReplaySubject } from 'rxjs';
import { ThemeContext } from '@emotion/react';
import { useEffect, useMemo } from 'react';
import { Meta } from '@storybook/react';
import { CoreTheme } from '@kbn/core-theme-browser';

import { ErrorEmbeddable } from '..';
import { setTheme } from '../services';

export default {
title: 'components/ErrorEmbeddable',
Expand All @@ -26,42 +22,21 @@ export default {
} as Meta;

interface ErrorEmbeddableWrapperProps {
compact?: boolean;
message: string;
}

function ErrorEmbeddableWrapper({ compact, message }: ErrorEmbeddableWrapperProps) {
function ErrorEmbeddableWrapper({ message }: ErrorEmbeddableWrapperProps) {
const embeddable = useMemo(
() => new ErrorEmbeddable(message, { id: `${Math.random()}` }, undefined, compact),
[compact, message]
() => new ErrorEmbeddable(message, { id: `${Math.random()}` }, undefined),
[message]
);
const root = useRef<HTMLDivElement>(null);
const theme$ = useMemo(() => new ReplaySubject<CoreTheme>(1), []);
const theme = useContext(ThemeContext) as CoreTheme;
useEffect(() => () => embeddable.destroy(), [embeddable]);

useEffect(() => setTheme({ theme$: theme$.pipe(filter(Boolean)) }), [theme$]);
useEffect(() => theme$.next(theme), [theme$, theme]);
useEffect(() => {
if (!root.current) {
return;
}

embeddable.render(root.current);

return () => embeddable.destroy();
}, [embeddable]);

return <div ref={root} />;
return embeddable.render();
}

export const Default = ErrorEmbeddableWrapper as Meta<ErrorEmbeddableWrapperProps>;

Default.args = {
message: 'Something went wrong',
};

export const DefaultCompact = ((props: ErrorEmbeddableWrapperProps) => (
<ErrorEmbeddableWrapper {...props} compact />
)) as Meta<ErrorEmbeddableWrapperProps>;

DefaultCompact.args = { ...Default.args };
Loading

0 comments on commit b921a49

Please sign in to comment.