The Embeddables Plugin provides an opportunity to expose reusable interactive widgets that can be embedded outside the original plugin.
- Framework-agnostic API.
- Out-of-the-box React support.
- Integration with Redux.
- Integration with the UI Actions plugin.
- Hierarchical structure to enclose multiple widgets.
- Error handling.
Embeddable is a re-usable widget that can be rendered on a dashboard as well as in other applications. Developers are free to embed them directly in their plugins. End users can dynamically select an embeddable to add to a container. Dashboard container powers the grid of panels on the Dashboard app.
Container is a special type of embeddable that can hold other embeddable items. Embeddables can be added dynamically to the containers, but that should be implemented on the end plugin side. Currently, the dashboard plugin provides such functionality.
Every embeddable has an input which is a serializable set of data. This data can be used to update the state of the embeddable widget. The input can be updated later so that the embeddable should be capable of reacting to those changes.
Every embeddable may expose some data to the external interface. Usually, it is diverged from the input and not necessarily serializable. Output data can also be updated, but that should always be done inside the embeddable.
After listing the embeddable
plugin in your dependencies, the plugin will be intitalized on the setup stage.
The setup contract exposes a handle to register an embeddable factory. At this point, we can provide all the dependencies needed for the widget via the factory.
import { EmbeddableSetup } from '@kbn/embeddable-plugin/public';
import { HELLO_WORLD } from './hello_world';
import { HelloWorldFactory } from './hello_world_factory';
interface SetupDeps {
embeddable: EmbeddableSetup;
}
class ExamplePlugin implements Plugin<void, void, SetupDeps> {
setup({}: CoreSetup, { embeddable }: SetupDeps) {
embeddable.registerEmbeddableFactory(HELLO_WORLD, new HelloWorldFactory());
}
start() {}
}
export function plugin() {
return new ExamplePlugin();
}
The factory should implement the EmbeddableFactoryDefinition
interface.
At this stage, we can inject all the dependencies into the embeddable instance.
import {
IContainer,
EmbeddableInput,
EmbeddableFactoryDefinition,
} from '@kbn/embeddable-plugin/public';
import { HelloWorld, HELLO_WORLD } from './hello_world';
export class HelloWorldEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition {
readonly type = HELLO_WORLD;
async isEditable() {
return true;
}
async create(input: EmbeddableInput, parent?: IContainer) {
return new HelloWorld(input, {}, parent);
}
getDisplayName() {
return 'Hello World';
}
}
The embeddable should implement the IEmbeddable
interface, and usually, that just extends the base class Embeddable
.
import React from 'react';
import { Embeddable } from '@kbn/embeddable-plugin/public';
export const HELLO_WORLD = 'HELLO_WORLD';
export class HelloWorld extends Embeddable {
readonly type = HELLO_WORLD;
render() {
return <div>{this.getTitle()}</div>;
}
reload() {}
}
Every embeddable can implement a specific behavior for the following life-cycle stages.
This is a mandatory method to implement. It is used for the initial render of the embeddable.
import React from 'react';
import { render } from 'react-dom';
import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
// ...
render(node: HTMLElement) {
render(<div>{this.getTitle()}</div>, node);
}
}
There is also an option to return a React node directly. In that case, the returned node will be automatically mounted and unmounted.
import React from 'react';
import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
// ...
render() {
return <div>{this.getTitle()}</div>;
}
}
This hook is called after every input update to perform some UI changes.
import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
// ...
private node?: HTMLElement;
render(node: HTMLElement) {
this.node = node;
// ...
}
reload() {
if (this.node) {
this.render(this.node);
}
}
}
In some cases, the reload
hook can be called to force rerender of the embeddable widget.
When the imperative rendering approach is used, the example above is good enough to achieve the goal.
In the case of React rendering, it will no longer work as the returned node is mounted on the upper level. The recommended way is to use Redux store with a custom reducer.
import React from 'react';
import { createSlice } from '@reduxjs/toolkit';
import { connect, Provider } from 'react-redux';
import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
interface ComponentState {
reloadedAt?: number;
}
export interface HelloWorldState extends State<HelloWorld> {
component: ComponentState;
}
const component = createSlice({
name: 'hello-world-component',
initialState: {} as ComponentState,
reducers: {
reload(state) {
state.reloadedAt = new Date().getTime();
},
},
});
export class HelloWorld extends Embeddable {
readonly store = createStore<HelloWorld, HelloWorldState>(this, {
preloadedState: {
component: {},
},
reducer: { component: component.reducer },
});
render() {
return (
<Provider store={this.store}>
<Component />
</Provider>
);
}
reload() {
this.store.dispatch(component.actions.reload());
}
}
Alternatively, a state modifier can be exposed via a reference object and later called from the reload
hook.
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 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.
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
// ...
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 directly. In that case, the returned node will be automatically mounted and unmounted.
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>;
}
}
This hook is invoked when the embeddable is destroyed and should perform cleanup actions.
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
// ...
render(node: HTMLElement) {
this.node = node;
// ...
}
destroy() {
if (this.node) {
unmountComponentAtNode(this.node);
}
}
}
The input state can be updated throughout the lifecycle of an embeddable. That can be done via updateInput
method call.
import { Embeddable } from '@kbn/embeddable-plugin/public';
export class HelloWorld extends Embeddable {
setTitle(title: string) {
this.updateInput({ title });
}
}
The input should always be updated partially. Otherwise, it may break the inheritance of the container's input as all the values passed in the changes object have precedence over the values set on the parent container.
For example, the time range on a dashboard is inherited by all children unless they explicitly override their time range.
This is the way the per panel time range works. In that case, there is a call item.updateInput({ timeRange })
that detaches the time range from the container.
The plugin provides a way to organize a collection of embeddable widgets inside containers. In this case, the container holds the state of all the children and manages all the input state updates.
import { Container } from '@kbn/embeddable-plugin/public';
export class HelloWorldContainer extends Container {
protected getInhertedInput() {
return {
timeRange: this.input.timeRange,
viewMode: this.input.viewMode,
};
}
}
Note 1: The getInhertedInput
may also return values not from the input state, but it is an uncommon case.
Note 2: Keep in mind that this input state will be passed down to all the children, which can be redundant in most cases. It is better to return only necessary generic information that all children will likely consume.
In the example above, all the container children will share the timeRange
and viewMode
properties.
If the container has other properties in the input state, they will not be shared with the children.
From the embeddable point, that works transparently, and there is no difference whether the embeddable is placed inside a container or not.
Let's take, for example, a container with the following input:
{
gridData: { /* ... */ },
timeRange: 'now-15m to now',
// Every embeddable container has a panels mapping.
// It's how the base container class manages common changes like children being added, removed or edited.
panels: {
'1': {
// The `type` is used to grab the right embeddable factory.
// Every PanelState must specify one.
type: 'clock',
// The `explicitInput` is combined with the `inheritedInput`.
explicitInput: {
// The `explicitInput` requires to have an `id`.
// This is needed for the embeddable to know where it stays in the panels array if it's inside a container.
// This is not a saved object id even though it can be the same sometimes.
id: '1',
}
}
}
}
That could result in the following input being passed to a child embeddable:
{
timeRange: 'now-15m to now',
id: '1',
}
There is a way of overriding this inherited state.
For example, the inherited timeRange
input can be overridden by the explicit timeRange
input.
Let's take this example dashboard container input:
{
gridData: { /* ... */ },
timeRange: 'now-15m to now',
panels: {
['1']: {
type: 'clock',
explicitInput: {
timeRange: 'now-30m to now',
id: '1',
}
},
['2']: {
type: 'clock',
explicitInput: {
id: '2',
}
},
}
The first child embeddable will get the following state:
{
timeRange: 'now-30m to now',
id: '1',
}
This override wouldn't affect other children, so the second child would get:
{
timeRange: 'now-15m to now',
id: '2',
}
The id
parameter in the input is marked as required even though it is only used when the embeddable is inside a container.
That is done to guarantee consistency.
This has nothing to do with a saved object id, even though in the dashboard app, the saved object happens to be the same.
The parent container can be retrieved via either embeddabble.parent
or embeddable.getRoot()
.
The getRoot
variety will walk up to find the root parent.
We can use those to get an explicit input from the child embeddable:
return parent.getInput().panels[embeddable.id].explicitInput;
It is possible for a container to store an explicit input state on the embeddable side. It would be encapsulated from a container in this case.
This can ne achieved in two ways by implementing one of the following:
EmbeddableFactory.getExplicitInput
was intended as a way for an embeddable to retrieve input state it needs that is not provided by a container.EmbeddableFactory.getDefaultInput
will provide default values, only if the container did not supply them through inheritance. Explicit input will always provide these values, and will always be stored in a containerspanel[id].explicitInput
, even if the container did provide it.
The plugin provides a set of ready-to-use React components that abstract rendering of an embeddable behind a React component:
EmbeddablePanel
provides a way to render an embeddable inside a rectangular panel. This also provides error handling and a basic user interface over some of the embeddable properties.EmbeddableRoot
is the most straightforward wrapper performing rendering of an embeddable.EmbeddableRenderer
is a helper component to render an embeddable or an embeddable factory.
Apart from the React components, there is also a way to construct an embeddable object using useEmbeddableFactory
hook.
This React hook takes care of producing an embeddable and updating its input state if passed state changes.
The plugin provides an adapter for Redux over the embeddable state.
It uses the Redux Toolkit library underneath and works as a decorator on top of the configureStore
function.
In other words, it provides a way to use the full power of the library together with the embeddable plugin features.
The adapter implements a bi-directional sync mechanism between the embeddable instance and the Redux state. To perform state mutations, the plugin also exposes a pre-defined state of the actions that can be extended by an additional reducer.
Here is an example of initializing a Redux store:
import React from 'react';
import { connect, Provider } from 'react-redux';
import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
import { HelloWorldComponent } from './hello_world_component';
export const HELLO_WORLD = 'HELLO_WORLD';
export class HelloWorld extends Embeddable {
readonly type = HELLO_WORLD;
readonly store = createStore(this);
reload() {}
render() {
const Component = connect((state: State<HelloWorld>) => ({ title: state.input.title }))(
HelloWorldComponent
);
return (
<Provider store={this.store}>
<Component />
</Provider>
);
}
}
Then inside the embedded component, it is possible to use the useSelector
and useDispatch
hooks.
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { actions, State } from '@kbn/embeddable-plugin/public/store';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { HelloWorld } from './hello_world';
interface HelloWorldComponentProps {
title?: string;
}
export function HelloWorldComponent({ title }: HelloWorldComponentProps) {
const viewMode = useSelector<State<HelloWorld>>(({ input }) => input.viewMode);
const dispatch = useDispatch();
return (
<div>
<h1>{title}</h1>
{viewMode === ViewMode.EDIT && (
<input
type="text"
value={title}
onChange={({ target }) => dispatch(actions.input.setTitle(target.value))}
/>
)}
</div>
);
}
The createStore
function provides an option to pass a custom reducer in the second argument.
That reducer will be merged with the one the embeddable plugin provides.
That means there is no need to reimplement already existing actions.
import React from 'react';
import { createSlice } from '@reduxjs/toolkit';
import {
Embeddable,
EmbeddableInput,
EmbeddableOutput,
IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
interface HelloWorldInput extends EmbeddableInput {
greeting?: string;
}
interface HelloWorldOutput extends EmbeddableOutput {
message?: string;
}
const input = createSlice({
name: 'hello-world-input',
initialState: {} as HelloWorldInput,
reducers: {
setGreeting(state, action: PayloadAction<HelloWorldInput['greeting']>) {
state.greeting = action.payload;
},
},
});
const output = createSlice({
name: 'hello-world-input',
initialState: {} as HelloWorldOutput,
reducers: {
setMessage(state, action: PayloadAction<HelloWorldOutput['message']>) {
state.message = action.payload;
},
},
});
export const actions = {
...input.actions,
...output.actions,
};
export class HelloWorld extends Embeddable<HelloWorldInput, HelloWorldOutput> {
readonly store = createStore(this, {
reducer: {
input: input.reducer,
output: output.reducer,
},
});
// ...
}
There is a way to provide a custom reducer that will manipulate the root state:
// ...
import { createAction, createRducer } from '@reduxjs/toolkit';
// ...
const setGreeting = createAction<HelloWorldInput['greeting']>('greeting');
const setMessage = createAction<HelloWorldOutput['message']>('message');
const reducer = createReducer({} as State<HelloWorld>, (builder) =>
builder
.addCase(setGreeting, (state, action) => ({
...state,
input: { ...state.input, greeting: action.payload },
}))
.addCase(setMessage, (state, action) => ({
...state,
output: { ...state.output, message: action.payload },
}))
);
export const actions = {
setGreeting,
setMessage,
};
export class HelloWorld extends Embeddable<HelloWorldInput, HelloWorldOutput> {
readonly store = createStore(this, { reducer });
// ...
}
Sometimes, there is a need to store a custom state next to the embeddable state. This can be achieved by passing a custom reducer.
import React from 'react';
import { createSlice } from '@reduxjs/toolkit';
import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createStore, State } from '@kbn/embeddable-plugin/public/store';
interface ComponentState {
foo?: string;
bar?: string;
}
export interface HelloWorldState extends State<HelloWorld> {
component: ComponentState;
}
const component = createSlice({
name: 'hello-world-component',
initialState: {} as ComponentState,
reducers: {
setFoo(state, action: PayloadAction<ComponentState['foo']>) {
state.foo = action.payload;
},
setBar(state, action: PayloadAction<ComponentState['bar']>) {
state.bar = action.payload;
},
},
});
export const { actions } = component;
export class HelloWorld extends Embeddable {
readonly store = createStore<HelloWorld, HelloWorldState>(this, {
preloadedState: {
component: {
foo: 'bar',
bar: 'foo',
},
},
reducer: { component: component.reducer },
});
// ...
}
When using the useSelector
hook, it is convenient to have a State
type to guarantee type safety and determine types implicitly.
For the state containing input and output substates only, it is enough to use a utility type State
:
import { useSelector } from 'react-redux';
import type { State } from '@kbn/embeddable-plugin/public/store';
import type { Embeddable } from './some_embeddable';
// ...
const title = useSelector<State<Embeddable>>((state) => state.input.title);
For the more complex case, the best way would be to define a state type separately:
import { useSelector } from 'react-redux';
import type { State } from '@kbn/embeddable-plugin/public/store';
import type { Embeddable } from './some_embeddable';
interface EmbeddableState extends State<Embeddable> {
foo?: string;
bar?: Bar;
}
// ...
const foo = useSelector<EmbeddableState>((state) => state.foo);
In case when there is a need to enhance the produced store in some way (e.g., perform custom serialization or debugging), it is possible to use parameters supported by the configureStore
function.
In case when custom serialization is needed, that should be done using middleware. The embeddable plugin's createStore
function does not apply any middleware, so all the synchronization job is done outside the store.
Please use automatically generated API reference or generated TypeDoc comments to find the complete documentation.
- Multiple embeddable examples are implemented and registered here.
- They can be played around with and explored in the Embeddable Explorer example plugin.
- There is an example of rendering a dashboard container outside the dashboard app.
- There are storybook stories that demonstrate usage of the embeddable components.
To run the examples plugin use the following command:
yarn start --run-examples
To run the storybook:
yarn storybook embeddable