[embeddable] remove EmbeddableRenderer and embeddable stories (#203007)

PR starts cleaning up legacy embeddable components by removing
EmbeddableRenderer, EmbedddableRoot, and embeddable story books.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Anton Dosov <dosantappdev@gmail.com>
This commit is contained in:
Nathan Reese 2024-12-10 10:38:28 -07:00 committed by GitHub
parent 4d9a70f48e
commit 80f915f9e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 116 additions and 1065 deletions

View file

@ -26,7 +26,6 @@ const STORYBOOKS = [
'dashboard_enhanced',
'dashboard',
'data',
'embeddable',
'esql_editor',
'expression_error',
'expression_image',

View file

@ -29,7 +29,6 @@ export const storybookAliases = {
dashboard: 'src/plugins/dashboard/.storybook',
data: 'src/plugins/data/.storybook',
discover: 'src/plugins/discover/.storybook',
embeddable: 'src/plugins/embeddable/.storybook',
esql_ast_inspector: 'examples/esql_ast_inspector/.storybook',
es_ui_shared: 'src/plugins/es_ui_shared/.storybook',
expandable_flyout: 'packages/kbn-expandable-flyout/.storybook',

View file

@ -1,24 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { DecoratorFn } from '@storybook/react';
import { I18nProvider } from '@kbn/i18n-react';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
export const servicesContextDecorator: DecoratorFn = (story, { globals }) => {
const darkMode = ['v8.dark', 'v7.dark'].includes(globals.euiTheme);
return (
<I18nProvider>
<EuiThemeProvider darkMode={darkMode}>{story()}</EuiThemeProvider>
</I18nProvider>
);
};

View file

@ -1,11 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// eslint-disable-next-line import/no-default-export
export { defaultConfig as default } from '@kbn/storybook';

View file

@ -1,22 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { addons } from '@storybook/addons';
import { create } from '@storybook/theming';
import { PANEL_ID } from '@storybook/addon-actions';
addons.setConfig({
theme: create({
base: 'light',
brandTitle: 'Kibana Embeddable Storybook',
brandUrl: 'https://github.com/elastic/kibana/tree/main/src/plugins/embeddable',
}),
showPanel: true.valueOf,
selectedPanel: PANEL_ID,
});

View file

@ -1,30 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { addDecorator } from '@storybook/react';
import { Title, Subtitle, Description, Primary, Stories } from '@storybook/addon-docs/blocks';
import { servicesContextDecorator } from './decorator';
addDecorator(servicesContextDecorator);
export const parameters = {
docs: {
page: () => (
<>
<Title />
<Subtitle />
<Description />
<Primary />
<Stories />
</>
),
},
};

View file

@ -1,279 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, {
forwardRef,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import { ReplaySubject } from 'rxjs';
import { ThemeContext } from '@emotion/react';
import { DecoratorFn, Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { CoreTheme } from '@kbn/core-theme-browser';
import type { Action } from '@kbn/ui-actions-plugin/public';
import { CONTEXT_MENU_TRIGGER, EmbeddablePanel, PANEL_BADGE_TRIGGER, ViewMode } from '..';
import { actions } from '../store';
import { HelloWorldEmbeddable } from './hello_world_embeddable';
const layout: DecoratorFn = (story) => {
return (
<EuiFlexGroup direction="row" justifyContent="center">
<EuiFlexItem grow={false} style={{ height: 300, width: 500 }}>
{story()}
</EuiFlexItem>
</EuiFlexGroup>
);
};
export default {
title: 'components/EmbeddablePanel',
argTypes: {
hideHeader: {
name: 'Hide Header',
control: { type: 'boolean' },
},
loading: {
name: 'Loading',
control: { type: 'boolean' },
},
showShadow: {
name: 'Show Shadow',
control: { type: 'boolean' },
},
title: {
name: 'Title',
control: { type: 'text' },
},
viewMode: {
name: 'View Mode',
control: { type: 'boolean' },
},
},
decorators: [layout],
} as Meta;
interface HelloWorldEmbeddablePanelProps {
getActions?(type: string): Promise<Action[]>;
hideHeader: boolean;
loading: boolean;
showShadow: boolean;
showBorder: boolean;
title: string;
viewMode: boolean;
}
const HelloWorldEmbeddablePanel = forwardRef<
{ embeddable: HelloWorldEmbeddable },
HelloWorldEmbeddablePanelProps
>(
(
{
getActions,
hideHeader,
loading,
showShadow,
showBorder,
title,
viewMode,
}: HelloWorldEmbeddablePanelProps,
ref
) => {
const embeddable = useMemo(() => new HelloWorldEmbeddable({ id: `${Math.random()}` }, {}), []);
const theme$ = useMemo(() => new ReplaySubject<CoreTheme>(1), []);
const theme = useContext(ThemeContext) as CoreTheme;
useEffect(() => theme$.next(theme), [theme$, theme]);
useEffect(() => {
embeddable.store.dispatch(actions.input.setTitle(title));
}, [embeddable.store, title]);
useEffect(() => {
embeddable.store.dispatch(
actions.input.setViewMode(viewMode ? ViewMode.VIEW : ViewMode.EDIT)
);
}, [embeddable.store, viewMode]);
useEffect(
() => void embeddable.store.dispatch(actions.output.setLoading(loading)),
[embeddable, loading]
);
useImperativeHandle(ref, () => ({ embeddable }));
return (
<EmbeddablePanel
embeddable={embeddable}
getActions={getActions}
hideHeader={hideHeader}
showShadow={showShadow}
showBorder={showBorder}
/>
);
}
);
export const Default = HelloWorldEmbeddablePanel as Meta<HelloWorldEmbeddablePanelProps>;
Default.args = {
hideHeader: false,
loading: false,
showShadow: false,
showBorder: false,
title: 'Hello World',
viewMode: true,
};
interface DefaultWithBadgesProps extends HelloWorldEmbeddablePanelProps {
badges: string[];
}
export function DefaultWithBadges({ badges, ...props }: DefaultWithBadgesProps) {
const getActions = useCallback(
async (type: string) => {
switch (type) {
case PANEL_BADGE_TRIGGER:
return (
badges?.map<Action>((badge, id) => ({
execute: async (...args) => action(`onClick(${badge})`)(...args),
getDisplayName: () => badge,
getIconType: () => ['help', 'search', undefined][id % 3],
id: `${id}`,
isCompatible: async () => true,
type: '',
})) ?? []
);
default:
return [];
}
},
[badges]
);
const ref = useRef<React.ComponentRef<typeof HelloWorldEmbeddablePanel>>(null);
useEffect(
() =>
void ref.current?.embeddable.store.dispatch(
actions.input.setLastReloadRequestTime(new Date().getMilliseconds())
),
[getActions]
);
return <HelloWorldEmbeddablePanel ref={ref} {...props} getActions={getActions} />;
}
DefaultWithBadges.args = {
...Default.args,
badges: ['Help', 'Search', 'Something'],
};
DefaultWithBadges.argTypes = {
badges: { name: 'Badges' },
};
interface DefaultWithContextMenuProps extends HelloWorldEmbeddablePanelProps {
items: string[];
}
export function DefaultWithContextMenu({ items, ...props }: DefaultWithContextMenuProps) {
const getActions = useCallback(
async (type: string) => {
switch (type) {
case CONTEXT_MENU_TRIGGER:
return (
items?.map<Action>((item, id) => ({
execute: async (...args) => action(`onClick(${item})`)(...args),
getDisplayName: () => item,
getIconType: () => ['help', 'search', undefined][id % 3],
id: `${id}`,
isCompatible: async () => true,
type: '',
})) ?? []
);
default:
return [];
}
},
[items]
);
const ref = useRef<React.ComponentRef<typeof HelloWorldEmbeddablePanel>>(null);
useEffect(
() =>
void ref.current?.embeddable.store.dispatch(
actions.input.setLastReloadRequestTime(new Date().getMilliseconds())
),
[getActions]
);
return <HelloWorldEmbeddablePanel ref={ref} {...props} getActions={getActions} />;
}
DefaultWithContextMenu.args = {
...Default.args,
items: ['Help', 'Search', 'Something'],
};
DefaultWithContextMenu.argTypes = {
items: { name: 'Context Menu Items' },
};
interface DefaultWithErrorProps extends HelloWorldEmbeddablePanelProps {
message: string;
}
export function DefaultWithError({ message, ...props }: DefaultWithErrorProps) {
const ref = useRef<React.ComponentRef<typeof HelloWorldEmbeddablePanel>>(null);
useEffect(
() => void ref.current?.embeddable.store.dispatch(actions.output.setError(new Error(message))),
[message]
);
return <HelloWorldEmbeddablePanel ref={ref} {...props} />;
}
DefaultWithError.args = {
...Default.args,
message: 'Something went wrong',
};
DefaultWithError.argTypes = {
message: { name: 'Message', control: { type: 'text' } },
};
export function DefaultWithCustomError({ message, ...props }: DefaultWithErrorProps) {
const ref = useRef<React.ComponentRef<typeof HelloWorldEmbeddablePanel>>(null);
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]
);
return <HelloWorldEmbeddablePanel ref={ref} {...props} />;
}
DefaultWithCustomError.args = {
...Default.args,
message: 'Something went wrong',
};
DefaultWithCustomError.argTypes = {
message: { name: 'Message', control: { type: 'text' } },
};

View file

@ -1,78 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useMemo } from 'react';
import { DecoratorFn, Meta } from '@storybook/react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EmbeddableInput, EmbeddableRoot } from '..';
import { HelloWorldEmbeddable } from './hello_world_embeddable';
const layout: DecoratorFn = (story) => {
return (
<EuiFlexGroup direction="row" justifyContent="center">
<EuiFlexItem grow={false} style={{ height: 300, width: 500 }}>
{story()}
</EuiFlexItem>
</EuiFlexGroup>
);
};
export default {
title: 'components/EmbeddableRoot',
argTypes: {
loading: {
name: 'Loading',
control: { type: 'boolean' },
},
title: {
name: 'Title',
control: { type: 'text' },
},
},
decorators: [layout],
} as Meta;
interface DefaultProps {
error?: string;
loading?: boolean;
title: string;
}
export function Default({ title, ...props }: DefaultProps) {
const id = useMemo(() => `${Math.random()}`, []);
const input = useMemo<EmbeddableInput>(
() => ({
id,
title,
lastReloadRequestTime: new Date().getMilliseconds(),
}),
[id, title]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const embeddable = useMemo(() => new HelloWorldEmbeddable(input, {}), []);
return <EmbeddableRoot {...props} embeddable={embeddable} input={input} />;
}
Default.args = {
title: 'Hello World',
loading: false,
};
export const DefaultWithError = Default.bind({}) as Meta<DefaultProps>;
DefaultWithError.args = {
...Default.args,
error: 'Something went wrong',
};
DefaultWithError.argTypes = {
error: { name: 'Error', control: { type: 'text' } },
};

View file

@ -1,43 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useEffect, useMemo } from 'react';
import { Meta } from '@storybook/react';
import { ErrorEmbeddable } from '..';
export default {
title: 'components/ErrorEmbeddable',
argTypes: {
message: {
name: 'Message',
control: { type: 'text' },
},
},
} as Meta;
interface ErrorEmbeddableWrapperProps {
message: string;
}
function ErrorEmbeddableWrapper({ message }: ErrorEmbeddableWrapperProps) {
const embeddable = useMemo(
() => new ErrorEmbeddable(message, { id: `${Math.random()}` }, undefined),
[message]
);
useEffect(() => () => embeddable.destroy(), [embeddable]);
return embeddable.render();
}
export const Default = ErrorEmbeddableWrapper as Meta<ErrorEmbeddableWrapperProps>;
Default.args = {
message: 'Something went wrong',
};

View file

@ -1,33 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { connect, Provider } from 'react-redux';
import { EuiEmptyPrompt } from '@elastic/eui';
import { Embeddable } from '..';
import { createStore, State } from '../store';
export class HelloWorldEmbeddable extends Embeddable {
// eslint-disable-next-line @kbn/eslint/no_this_in_property_initializers
readonly store = createStore(this);
readonly type = 'hello-world';
reload() {}
render() {
const HelloWorld = connect((state: State) => ({ body: state.input.title }))(EuiEmptyPrompt);
return (
<Provider store={this.store}>
<HelloWorld />
</Provider>
);
}
}

View file

@ -22,8 +22,6 @@ export {
defaultEmbeddableFactoryProvider,
Embeddable,
EmbeddableFactoryNotFoundError,
EmbeddableRenderer,
EmbeddableRoot,
EmbeddableStateTransfer,
ErrorEmbeddable,
genericEmbeddableInputIsEqual,
@ -52,7 +50,6 @@ export {
SELECT_RANGE_TRIGGER,
shouldFetch$,
shouldRefreshFilterCompareOptions,
useEmbeddableFactory,
VALUE_CLICK_TRIGGER,
ViewMode,
withEmbeddableSubscription,
@ -72,7 +69,6 @@ export type {
EmbeddableInstanceConfiguration,
EmbeddableOutput,
EmbeddablePackageState,
EmbeddableRendererProps,
FilterableEmbeddable,
IContainer,
IEmbeddable,

View file

@ -1,67 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { waitFor } from '@testing-library/react';
import { render, renderHook } from '@testing-library/react';
import {
HelloWorldEmbeddable,
HelloWorldEmbeddableFactoryDefinition,
HELLO_WORLD_EMBEDDABLE,
} from '../../tests/fixtures';
import { EmbeddableRenderer, useEmbeddableFactory } from './embeddable_renderer';
import { embeddablePluginMock } from '../../mocks';
describe('useEmbeddableFactory', () => {
it('should update upstream value changes', async () => {
const { setup, doStart } = embeddablePluginMock.createInstance();
const getFactory = setup.registerEmbeddableFactory(
HELLO_WORLD_EMBEDDABLE,
new HelloWorldEmbeddableFactoryDefinition()
);
doStart();
const { result } = renderHook(() =>
useEmbeddableFactory({ factory: getFactory(), input: { id: 'hello' } })
);
const [, loading] = result.current;
expect(loading).toBe(true);
await waitFor(() => {
const [embeddable] = result.current;
expect(embeddable).toBeDefined();
});
});
});
describe('<EmbeddableRenderer/>', () => {
test('Render embeddable', () => {
const embeddable = new HelloWorldEmbeddable({ id: 'hello' });
const { getByTestId } = render(<EmbeddableRenderer embeddable={embeddable} />);
expect(getByTestId('helloWorldEmbeddable')).toBeInTheDocument();
});
test('Render factory', async () => {
const { setup, doStart } = embeddablePluginMock.createInstance();
const getFactory = setup.registerEmbeddableFactory(
HELLO_WORLD_EMBEDDABLE,
new HelloWorldEmbeddableFactoryDefinition()
);
doStart();
const { getByTestId, queryByTestId } = render(
<EmbeddableRenderer factory={getFactory()} input={{ id: 'hello' }} />
);
expect(getByTestId('embedSpinner')).toBeInTheDocument();
await waitFor(() => !queryByTestId('embedSpinner')); // wait until spinner disappears
expect(getByTestId('helloWorldEmbeddable')).toBeInTheDocument();
});
});

View file

@ -1,165 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useState } from 'react';
import { EmbeddableInput, IEmbeddable } from './i_embeddable';
import { EmbeddableRoot } from './embeddable_root';
import { EmbeddableFactory } from './embeddable_factory';
import { ErrorEmbeddable } from './error_embeddable';
import { isErrorEmbeddable } from './is_error_embeddable';
/**
* This type is a publicly exposed props of {@link EmbeddableRenderer}
* Union is used to validate that or factory or embeddable is passed in, but it can't be both simultaneously
* In case when embeddable is passed in, input is optional, because there is already an input inside of embeddable object
* In case when factory is used, then input is required, because it will be used as initial input to create an embeddable object
*/
export type EmbeddableRendererProps<I extends EmbeddableInput> =
| EmbeddableRendererPropsWithEmbeddable<I>
| EmbeddableRendererWithFactory<I>;
interface EmbeddableRendererPropsWithEmbeddable<I extends EmbeddableInput> {
input?: I;
onInputUpdated?: (newInput: I) => void;
embeddable: IEmbeddable<I>;
}
interface EmbeddableRendererWithFactory<I extends EmbeddableInput> {
input: I;
onInputUpdated?: (newInput: I) => void;
factory: EmbeddableFactory<I>;
}
function isWithFactory<I extends EmbeddableInput>(
props: EmbeddableRendererProps<I>
): props is EmbeddableRendererWithFactory<I> {
return 'factory' in props;
}
export function useEmbeddableFactory<I extends EmbeddableInput>({
input,
factory,
onInputUpdated,
}: EmbeddableRendererWithFactory<I>) {
const [embeddable, setEmbeddable] = useState<IEmbeddable<I> | ErrorEmbeddable | undefined>(
undefined
);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>();
const latestInput = React.useRef(input);
useEffect(() => {
latestInput.current = input;
}, [input]);
useEffect(() => {
let canceled = false;
// keeping track of embeddables created by this component to be able to destroy them
let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined;
setEmbeddable(undefined);
setLoading(true);
factory
.create(latestInput.current!)
.then((createdEmbeddable) => {
if (canceled) {
if (createdEmbeddable) {
createdEmbeddable.destroy();
}
} else {
createdEmbeddableRef = createdEmbeddable;
setEmbeddable(createdEmbeddable);
}
})
.catch((err) => {
if (canceled) return;
setError(err?.message);
})
.finally(() => {
if (canceled) return;
setLoading(false);
});
return () => {
canceled = true;
if (createdEmbeddableRef) {
createdEmbeddableRef.destroy();
}
};
}, [factory]);
useEffect(() => {
if (!embeddable) return;
if (isErrorEmbeddable(embeddable)) return;
if (!onInputUpdated) return;
const sub = embeddable.getInput$().subscribe((newInput) => {
onInputUpdated(newInput);
});
return () => {
sub.unsubscribe();
};
}, [embeddable, onInputUpdated]);
return [embeddable, loading, error] as const;
}
/**
* Helper react component to render an embeddable
* Can be used if you have an embeddable object or an embeddable factory
* Supports updating input by passing `input` prop
*
* @remarks
* This component shouldn't be used inside an embeddable container to render embeddable children
* because children may lose inherited input, here is why:
*
* When passing `input` inside a prop, internally there is a call:
*
* ```ts
* embeddable.updateInput(input);
* ```
* If you are simply rendering an embeddable, it's no problem.
*
* However when you are dealing with containers,
* you want to be sure to only pass into updateInput the actual state that changed.
* This is because calling child.updateInput({ foo }) will make foo explicit state.
* It cannot be inherited from it's parent.
*
* For example, on a dashboard, the time range is inherited by all children,
* unless they had their time range set explicitly.
* This is how "per panel time range" works.
* That action calls embeddable.updateInput({ timeRange }),
* and the time range will no longer be inherited from the container.
*
* see: https://github.com/elastic/kibana/pull/67783#discussion_r435447657 for more details.
* refer to: examples/embeddable_explorer for examples with correct usage of this component.
*
* @public
* @param props - {@link EmbeddableRendererProps}
*/
export const EmbeddableRenderer = <I extends EmbeddableInput>(
props: EmbeddableRendererProps<I>
) => {
if (isWithFactory(props)) {
return <EmbeddableByFactory {...props} />;
}
return <EmbeddableRoot embeddable={props.embeddable} input={props.input} />;
};
//
const EmbeddableByFactory = <I extends EmbeddableInput>({
factory,
input,
onInputUpdated,
}: EmbeddableRendererWithFactory<I>) => {
const [embeddable, loading, error] = useEmbeddableFactory({
factory,
input,
onInputUpdated,
});
return <EmbeddableRoot embeddable={embeddable} loading={loading} error={error} input={input} />;
};

View file

@ -1,61 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { HelloWorldEmbeddable, HelloWorldEmbeddableReact } from '../../tests/fixtures';
import { EmbeddableRoot } from './embeddable_root';
import { mount } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test';
test('EmbeddableRoot renders an embeddable', async () => {
const embeddable = new HelloWorldEmbeddable({ id: 'hello' });
const component = mount(<EmbeddableRoot embeddable={embeddable} />);
// Due to the way embeddables mount themselves on the dom node, they are not forced to be
// react components, and hence, we can't use the usual
// findTestSubject.
expect(
component.getDOMNode().querySelectorAll('[data-test-subj="helloWorldEmbeddable"]').length
).toBe(1);
expect(findTestSubject(component, 'embedSpinner').length).toBe(0);
expect(findTestSubject(component, 'embedError').length).toBe(0);
});
test('EmbeddableRoot renders a React-based embeddable', async () => {
const embeddable = new HelloWorldEmbeddableReact({ id: 'hello' });
const component = mount(<EmbeddableRoot embeddable={embeddable} />);
expect(component.find('[data-test-subj="helloWorldEmbeddable"]')).toHaveLength(1);
});
test('EmbeddableRoot updates input', async () => {
const embeddable = new HelloWorldEmbeddable({ id: 'hello' });
const component = mount(<EmbeddableRoot embeddable={embeddable} />);
const spy = jest.spyOn(embeddable, 'updateInput');
const newInput = { id: 'hello', something: 'new' };
component.setProps({ embeddable, input: newInput });
expect(spy).toHaveBeenCalledWith(newInput);
});
test('EmbeddableRoot renders a spinner if loading an no embeddable given', async () => {
const component = mount(<EmbeddableRoot loading={true} />);
// Due to the way embeddables mount themselves on the dom node, they are not forced to be
// react components, and hence, we can't use the usual
// findTestSubject.
expect(findTestSubject(component, 'embedSpinner').length).toBe(1);
expect(findTestSubject(component, 'embedError').length).toBe(0);
});
test('EmbeddableRoot renders an error if given with no embeddable', async () => {
const component = mount(<EmbeddableRoot error="bad" />);
// Due to the way embeddables mount themselves on the dom node, they are not forced to be
// react components, and hence, we can't use the usual
// findTestSubject.
expect(findTestSubject(component, 'embedError').length).toBe(1);
expect(findTestSubject(component, 'embedSpinner').length).toBe(0);
});

View file

@ -1,66 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
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>>;
loading?: boolean;
error?: string;
input?: EmbeddableInput;
}
export const EmbeddableRoot: React.FC<Props> = ({ embeddable, loading, error, input }) => {
const [node, setNode] = useState<ReactNode | undefined>();
const [embeddableHasMounted, setEmbeddableHasMounted] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const updateNode = useCallback((newNode: MaybePromise<ReactNode>) => {
if (isPromise(newNode)) {
newNode.then(updateNode);
return;
}
setNode(newNode);
}, []);
useEffect(() => {
if (!rootRef.current || !embeddable) {
return;
}
setEmbeddableHasMounted(true);
updateNode(embeddable.render(rootRef.current) ?? undefined);
embeddable.render(rootRef.current);
}, [updateNode, embeddable]);
useEffect(() => {
if (input && embeddable && embeddableHasMounted) {
embeddable.updateInput(input);
}
}, [input, embeddable, embeddableHasMounted]);
return (
<>
<div ref={rootRef}>{node}</div>
{loading && <EuiLoadingSpinner data-test-subj="embedSpinner" />}
{error && (
<EmbeddableErrorHandler embeddable={embeddable} error={error}>
{({ message }) => <EuiText data-test-subj="embedError">{message}</EuiText>}
</EmbeddableErrorHandler>
)}
</>
);
};

View file

@ -1,54 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { setStubKibanaServices as setPresentationPanelMocks } from '@kbn/presentation-panel-plugin/public/mocks';
import { waitFor, render } from '@testing-library/react';
import { ErrorEmbeddable } from './error_embeddable';
import { EmbeddableRoot } from './embeddable_root';
test('ErrorEmbeddable renders an embeddable', async () => {
setPresentationPanelMocks();
const embeddable = new ErrorEmbeddable('some error occurred', { id: '123', title: 'Error' });
const { getByTestId, getByText } = render(<EmbeddableRoot embeddable={embeddable} />);
expect(getByTestId('embeddableStackError')).toBeVisible();
await waitFor(() => getByTestId('errorMessageMarkdown')); // wait for lazy markdown component
expect(getByText(/some error occurred/i)).toBeVisible();
});
test('ErrorEmbeddable renders an embeddable with markdown message', async () => {
setPresentationPanelMocks();
const error = '[some link](http://localhost:5601/takeMeThere)';
const embeddable = new ErrorEmbeddable(error, { id: '123', title: 'Error' });
const { getByTestId, getByText } = render(<EmbeddableRoot embeddable={embeddable} />);
expect(getByTestId('embeddableStackError')).toBeVisible();
await waitFor(() => getByTestId('errorMessageMarkdown')); // wait for lazy markdown component
expect(getByText(/some link/i)).toMatchInlineSnapshot(`
<a
class="euiLink emotion-euiLink-primary"
href="http://localhost:5601/takeMeThere"
rel="noopener noreferrer"
target="_blank"
>
some link
<span
class="emotion-EuiExternalLinkIcon"
data-euiicon-type="popout"
role="presentation"
/>
<span
class="emotion-euiScreenReaderOnly"
>
(external, opens in a new tab or window)
</span>
</a>
`);
});

View file

@ -14,9 +14,6 @@ export { Embeddable } from './embeddable';
export { EmbeddableErrorHandler } from './embeddable_error_handler';
export * from './embeddable_factory';
export * from './embeddable_factory_definition';
export { EmbeddableRenderer, useEmbeddableFactory } from './embeddable_renderer';
export type { EmbeddableRendererProps } from './embeddable_renderer';
export { EmbeddableRoot } from './embeddable_root';
export { ErrorEmbeddable } from './error_embeddable';
export { isErrorEmbeddable } from './is_error_embeddable';
export { isEmbeddable } from './is_embeddable';

View file

@ -3,19 +3,16 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["*.ts", ".storybook/**/*", "common/**/*", "public/**/*", "server/**/*"],
"include": ["*.ts", "common/**/*", "public/**/*", "server/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/inspector-plugin",
"@kbn/saved-objects-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/kibana-react-plugin",
"@kbn/ui-actions-plugin",
"@kbn/i18n-react",
"@kbn/storybook",
"@kbn/utility-types",
"@kbn/es-query",
"@kbn/core-theme-browser",
"@kbn/i18n",
"@kbn/std",
"@kbn/expressions-plugin",

View file

@ -21,7 +21,8 @@
"dashboard",
"dashboardEnhanced",
"developerExamples",
"unifiedSearch"
"unifiedSearch",
"embeddable",
],
"requiredBundles": [
"dashboardEnhanced",

View file

@ -7,15 +7,19 @@
import React from 'react';
import { EuiPage } from '@elastic/eui';
import { CoreStart } from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { Page } from '../../components/page';
import { DrilldownsManager } from '../drilldowns_manager';
export const App: React.FC = () => {
export const App = ({ core }: { core: CoreStart }) => {
return (
<EuiPage>
<Page title={'UI Actions Enhanced'}>
<DrilldownsManager />
</Page>
</EuiPage>
<KibanaRenderContextProvider i18n={core.i18n} theme={core.theme}>
<EuiPage>
<Page title={'UI Actions Enhanced'}>
<DrilldownsManager />
</Page>
</EuiPage>
</KibanaRenderContextProvider>
);
};

View file

@ -18,29 +18,12 @@ import {
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { EmbeddableRoot, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public';
import { SampleMlJob, SampleApp1ClickContext } from '../../triggers';
import { ButtonEmbeddable } from '../../embeddables/button_embeddable';
import { ReactEmbeddableRenderer, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public';
import { useUiActions } from '../../context';
export const job: SampleMlJob = {
job_id: '123',
job_type: 'anomaly_detector',
description: 'This is some ML job.',
};
export const context: SampleApp1ClickContext = { job };
import { BUTTON_EMBEDDABLE } from '../../embeddables/register_button_embeddable';
export const DrilldownsWithEmbeddableExample: React.FC = () => {
const { plugins, managerWithEmbeddable } = useUiActions();
const embeddable = React.useMemo(
() =>
new ButtonEmbeddable(
{ id: 'DrilldownsWithEmbeddableExample' },
{ uiActions: plugins.uiActionsEnhanced }
),
[plugins.uiActionsEnhanced]
);
const [showManager, setShowManager] = React.useState(false);
const [openPopup, setOpenPopup] = React.useState(false);
const viewRef = React.useRef<'/create' | '/manage'>('/create');
@ -112,7 +95,13 @@ export const DrilldownsWithEmbeddableExample: React.FC = () => {
<EuiFlexItem grow={false}>{openManagerButton}</EuiFlexItem>
<EuiFlexItem grow={false}>
<div style={{ maxWidth: 200 }}>
<EmbeddableRoot embeddable={embeddable} />
<ReactEmbeddableRenderer<{}, {}>
type={BUTTON_EMBEDDABLE}
getParentApi={() => ({
getSerializedStateForChild: () => undefined,
})}
hidePanelChrome={true}
/>
</div>
</EuiFlexItem>
</EuiFlexGroup>
@ -124,7 +113,6 @@ export const DrilldownsWithEmbeddableExample: React.FC = () => {
initialRoute={viewRef.current}
dynamicActionManager={managerWithEmbeddable}
triggers={[VALUE_CLICK_TRIGGER]}
placeContext={{ embeddable }}
onClose={() => setShowManager(false)}
/>
</EuiFlyout>

View file

@ -0,0 +1,60 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import {
DefaultEmbeddableApi,
ReactEmbeddableFactory,
VALUE_CLICK_TRIGGER,
} from '@kbn/embeddable-plugin/public';
import { EuiCard, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { AdvancedUiActionsStart } from '@kbn/ui-actions-enhanced-plugin/public';
import { BUTTON_EMBEDDABLE } from './register_button_embeddable';
export const getButtonEmbeddableFactory = (uiActionsEnhanced: AdvancedUiActionsStart) => {
const factory: ReactEmbeddableFactory<{}, {}, DefaultEmbeddableApi<{}>> = {
type: BUTTON_EMBEDDABLE,
deserializeState: (state) => state.rawState,
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
const api = buildApi(
{
serializeState: () => {
return {
rawState: {},
references: [],
};
},
},
{}
);
return {
api,
Component: () => {
const onClick = useCallback(() => {
uiActionsEnhanced.getTrigger(VALUE_CLICK_TRIGGER).exec({
embeddable: api,
data: {
data: [],
},
});
}, []);
return (
<EuiFlexItem>
<EuiCard
icon={<EuiIcon size="xxl" type={`logoKibana`} />}
title={`Click me!`}
description={'This embeddable fires "VALUE_CLICK" trigger on click'}
onClick={onClick}
/>
</EuiFlexItem>
);
},
};
},
};
return factory;
};

View file

@ -1,53 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createElement } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { AdvancedUiActionsStart } from '@kbn/ui-actions-enhanced-plugin/public';
import { Embeddable, EmbeddableInput, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public';
import { ButtonEmbeddableComponent } from './button_embeddable_component';
export const BUTTON_EMBEDDABLE = 'BUTTON_EMBEDDABLE';
export interface ButtonEmbeddableParams {
uiActions: AdvancedUiActionsStart;
}
export class ButtonEmbeddable extends Embeddable {
type = BUTTON_EMBEDDABLE;
constructor(input: EmbeddableInput, private readonly params: ButtonEmbeddableParams) {
super(input, {});
}
reload() {}
private el?: HTMLElement;
public render(el: HTMLElement): void {
super.render(el);
this.el = el;
render(
createElement(ButtonEmbeddableComponent, {
onClick: () => {
this.params.uiActions.getTrigger(VALUE_CLICK_TRIGGER).exec({
embeddable: this,
data: {
data: [],
},
});
},
}),
el
);
}
public destroy() {
super.destroy();
if (this.el) unmountComponentAtNode(this.el);
}
}

View file

@ -1,28 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as React from 'react';
import { EuiCard, EuiFlexItem, EuiIcon } from '@elastic/eui';
export interface ButtonEmbeddableComponentProps {
onClick: () => void;
}
export const ButtonEmbeddableComponent: React.FC<ButtonEmbeddableComponentProps> = ({
onClick,
}) => {
return (
<EuiFlexItem>
<EuiCard
icon={<EuiIcon size="xxl" type={`logoKibana`} />}
title={`Click me!`}
description={'This embeddable fires "VALUE_CLICK" trigger on click'}
onClick={onClick}
/>
</EuiFlexItem>
);
};

View file

@ -1,8 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './button_embeddable';

View file

@ -0,0 +1,22 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EmbeddableSetup } from '@kbn/embeddable-plugin/public';
import { StartDependencies } from '../plugin';
export const BUTTON_EMBEDDABLE = 'BUTTON_EMBEDDABLE';
export function registerButtonEmbeddable(
embeddable: EmbeddableSetup,
services: Promise<StartDependencies>
) {
embeddable.registerReactEmbeddableFactory(BUTTON_EMBEDDABLE, async () => {
const { getButtonEmbeddableFactory } = await import('./button_embeddable');
const { uiActionsEnhanced } = await services;
return getButtonEmbeddableFactory(uiActionsEnhanced);
});
}

View file

@ -31,7 +31,7 @@ export const mount =
};
const reactElement = (
<context.Provider value={deps}>
<App />
<App core={core} />
</context.Provider>
);
render(reactElement, element);

View file

@ -21,6 +21,7 @@ import {
UiActionsEnhancedMemoryActionStorage,
UiActionsEnhancedDynamicActionManager,
} from '@kbn/ui-actions-enhanced-plugin/public';
import { EmbeddableSetup } from '@kbn/embeddable-plugin/public';
import { DashboardHelloWorldDrilldown } from './drilldowns/dashboard_hello_world_drilldown';
import { DashboardToDiscoverDrilldown } from './drilldowns/dashboard_to_discover_drilldown';
import { App1ToDashboardDrilldown } from './drilldowns/app1_to_dashboard_drilldown';
@ -35,12 +36,14 @@ import {
} from './triggers';
import { mount } from './mount';
import { App2ToDashboardDrilldown } from './drilldowns/app2_to_dashboard_drilldown';
import { registerButtonEmbeddable } from './embeddables/register_button_embeddable';
export interface SetupDependencies {
dashboard: DashboardSetup;
data: DataPublicPluginSetup;
developerExamples: DeveloperExamplesSetup;
discover: DiscoverSetup;
embeddable: EmbeddableSetup;
uiActionsEnhanced: AdvancedUiActionsSetup;
}
@ -62,7 +65,7 @@ export class UiActionsEnhancedExamplesPlugin
{
public setup(
core: CoreSetup<StartDependencies, UiActionsEnhancedExamplesStart>,
{ uiActionsEnhanced: uiActions, developerExamples }: SetupDependencies
{ embeddable, uiActionsEnhanced: uiActions, developerExamples }: SetupDependencies
) {
const start = createStartServicesGetter(core.getStartServices);
@ -150,6 +153,12 @@ export class UiActionsEnhancedExamplesPlugin
},
],
});
const startServicesPromise = core.getStartServices();
registerButtonEmbeddable(
embeddable,
startServicesPromise.then(([_, startDeps]) => startDeps)
);
}
public start(_core: CoreStart, plugins: StartDependencies): UiActionsEnhancedExamplesStart {

View file

@ -30,5 +30,6 @@
"@kbn/utility-types",
"@kbn/presentation-publishing",
"@kbn/react-kibana-mount",
"@kbn/react-kibana-context-render",
]
}