[Embeddable Rebuild] Make React embeddables work in Canvas (#179667)

Closes https://github.com/elastic/kibana/issues/179548

## Summary

This PR makes it so that React embeddables will now work in Canvas. It
does so by doing two main things:

1. It ensures that the `ReactEmbeddableRenderer` is used in Canvas when
an embeddable exists in the React embeddable registry - if it does not
exist, it continues to use the legacy embeddable factory's `render`
method.

Since Canvas auto-applies all changes and doesn't have save
functionality like Dashboard does, we must keep track of changes **as
they happen** and update the expression to match the new Embeddable
input - therefore, I had to add a `onAnyStateChange` callback to the
`ReactEmbeddableRenderer` as a backdoor for Canvas. As a bonus to this,
embeddables that **previously** didn't respect inline editing (such as
the Image embeddable) will start to work once they are converted!


3. It adds a new trigger (`ADD_CANVAS_ELEMENT_TRIGGER`) specifically for
registering an embeddable to the **Canvas** add panel menu. This trigger
can be attached to the **same action** that the Dashboard
`ADD_PANEL_TRIGGER` is attached to - this makes it super simple to add
React embeddables to Canvas:
    
    ```typescript
    uiActions.registerAction<EmbeddableApiContext>({
      id: ADD_EMBEDDABLE_ACTION_ID,
      isCompatible: async ({ embeddable }) => { ... },
      execute: async ({ embeddable }) => { ... },
      getDisplayName: () => { ... },
    });

    // register this action to the Dashboard add panel menu:
uiActions.attachAction('ADD_PANEL_TRIGGER', ADD_EMBEDDABLE_ACTION_ID);

    // register this action to the Canvas add panel menu:
uiActions.attachAction('ADD_CANVAS_ELEMENT_TRIGGER',
ADD_EMBEDDABLE_ACTION_ID);
    ```

As a small cleanup, I also replaced some inline embeddable expressions
with `embeddableInputToExpression` - this is because I was originally
missing `| render` at the end of my expression, and I didn't catch it
because the expressions I was comparing it to were all declared inline.
This should help keep things more consistent.

### How to Test
I attached the `ADD_EUI_MARKDOWN_ACTION_ID` action to the new
`ADD_CANVAS_ELEMENT_TRIGGER`, so the new EUI Markdown React embeddable
should show up in the Canvas add panel menu. Ensure that this React
embeddable can be added to a workpad, and make sure it responds to edits
as expected:



d9062949-9c61-4c9e-8a8e-08042753eab6



### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2024-04-03 16:29:07 -06:00 committed by GitHub
parent a85ba2b7ee
commit 4cc61b98e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 473 additions and 104 deletions

View file

@ -7,8 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { EmbeddableApiContext, apiCanAddNewPanel } from '@kbn/presentation-publishing';
import { IncompatibleActionError, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { ADD_EUI_MARKDOWN_ACTION_ID, EUI_MARKDOWN_ID } from './constants';
@ -21,10 +20,10 @@ export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => {
id: ADD_EUI_MARKDOWN_ACTION_ID,
getIconType: () => 'editorCodeBlock',
isCompatible: async ({ embeddable }) => {
return apiIsPresentationContainer(embeddable);
return apiCanAddNewPanel(embeddable);
},
execute: async ({ embeddable }) => {
if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError();
embeddable.addNewPanel(
{
panelType: EUI_MARKDOWN_ID,
@ -39,4 +38,9 @@ export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => {
}),
});
uiActions.attachAction('ADD_PANEL_TRIGGER', ADD_EUI_MARKDOWN_ACTION_ID);
if (uiActions.hasTrigger('ADD_CANVAS_ELEMENT_TRIGGER')) {
// Because Canvas is not enabled in Serverless, this trigger might not be registered - only attach
// the create action if the Canvas-specific trigger does indeed exist.
uiActions.attachAction('ADD_CANVAS_ELEMENT_TRIGGER', ADD_EUI_MARKDOWN_ACTION_ID);
}
};

View file

@ -7,8 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { apiCanAddNewPanel, EmbeddableApiContext } from '@kbn/presentation-publishing';
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
import { ADD_FIELD_LIST_ACTION_ID, FIELD_LIST_ID } from './constants';
@ -18,10 +17,10 @@ export const registerCreateFieldListAction = (uiActions: UiActionsPublicStart) =
id: ADD_FIELD_LIST_ACTION_ID,
getIconType: () => 'indexOpen',
isCompatible: async ({ embeddable }) => {
return apiIsPresentationContainer(embeddable);
return apiCanAddNewPanel(embeddable);
},
execute: async ({ embeddable }) => {
if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError();
embeddable.addNewPanel({
panelType: FIELD_LIST_ID,
});

View file

@ -6,8 +6,7 @@
* Side Public License, v 1.
*/
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { apiCanAddNewPanel, EmbeddableApiContext } from '@kbn/presentation-publishing';
import { IncompatibleActionError, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { ADD_SEARCH_ACTION_ID, SEARCH_EMBEDDABLE_ID } from './constants';
@ -19,10 +18,10 @@ export const registerAddSearchPanelAction = (uiActions: UiActionsStart) => {
'Demonstrates how to use global filters, global time range, panel time range, and global query state in an embeddable',
getIconType: () => 'search',
isCompatible: async ({ embeddable }) => {
return apiIsPresentationContainer(embeddable);
return apiCanAddNewPanel(embeddable);
},
execute: async ({ embeddable }) => {
if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError();
embeddable.addNewPanel(
{
panelType: SEARCH_EMBEDDABLE_ID,

View file

@ -21,7 +21,6 @@
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/es-query",
"@kbn/presentation-containers",
"@kbn/data-views-plugin",
"@kbn/data-plugin",
"@kbn/charts-plugin",

View file

@ -15,7 +15,6 @@ export {
export {
apiIsPresentationContainer,
getContainerParentFromAPI,
type PanelPackage,
type PresentationContainer,
} from './interfaces/presentation_container';
export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays';

View file

@ -6,20 +6,18 @@
* Side Public License, v 1.
*/
import { apiHasParentApi, PublishesViewMode } from '@kbn/presentation-publishing';
import {
apiCanAddNewPanel,
apiHasParentApi,
CanAddNewPanel,
PanelPackage,
PublishesViewMode,
} from '@kbn/presentation-publishing';
import { PublishesLastSavedState } from './last_saved_state';
export interface PanelPackage {
panelType: string;
initialState?: object;
}
export type PresentationContainer = Partial<PublishesViewMode> &
PublishesLastSavedState & {
addNewPanel: <ApiType extends unknown = unknown>(
panel: PanelPackage,
displaySuccessMessage?: boolean
) => Promise<ApiType | undefined>;
PublishesLastSavedState &
CanAddNewPanel & {
registerPanelApi: <ApiType extends unknown = unknown>(
panelId: string,
panelApi: ApiType
@ -32,13 +30,15 @@ export type PresentationContainer = Partial<PublishesViewMode> &
};
export const apiIsPresentationContainer = (api: unknown | null): api is PresentationContainer => {
return Boolean(
typeof (api as PresentationContainer)?.removePanel === 'function' &&
typeof (api as PresentationContainer)?.registerPanelApi === 'function' &&
typeof (api as PresentationContainer)?.replacePanel === 'function' &&
typeof (api as PresentationContainer)?.addNewPanel === 'function' &&
typeof (api as PresentationContainer)?.getChildIds === 'function' &&
typeof (api as PresentationContainer)?.getChild === 'function'
return (
apiCanAddNewPanel(api) &&
Boolean(
typeof (api as PresentationContainer)?.removePanel === 'function' &&
typeof (api as PresentationContainer)?.registerPanelApi === 'function' &&
typeof (api as PresentationContainer)?.replacePanel === 'function' &&
typeof (api as PresentationContainer)?.getChildIds === 'function' &&
typeof (api as PresentationContainer)?.getChild === 'function'
)
);
};

View file

@ -28,6 +28,11 @@ export {
useInheritedViewMode,
type CanAccessViewMode,
} from './interfaces/can_access_view_mode';
export {
apiCanAddNewPanel,
type CanAddNewPanel,
type PanelPackage,
} from './interfaces/can_add_new_panel';
export { apiHasDisableTriggers, type HasDisableTriggers } from './interfaces/has_disable_triggers';
export { hasEditCapabilities, type HasEditCapabilities } from './interfaces/has_edit_capabilities';
export { apiHasParentApi, type HasParentApi } from './interfaces/has_parent_api';

View file

@ -0,0 +1,29 @@
/*
* 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.
*/
export interface PanelPackage {
panelType: string;
initialState?: object;
}
/**
* This API can add a new panel as a child.
*/
export interface CanAddNewPanel {
addNewPanel: <ApiType extends unknown = unknown>(
panel: PanelPackage,
displaySuccessMessage?: boolean
) => Promise<ApiType | undefined>;
}
/**
* A type guard which can be used to determine if a given API can add a new panel.
*/
export const apiCanAddNewPanel = (api: unknown): api is CanAddNewPanel => {
return typeof (api as CanAddNewPanel)?.addNewPanel === 'function';
};

View file

@ -30,7 +30,7 @@ import {
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { PanelPackage } from '@kbn/presentation-containers';
import { PanelPackage } from '@kbn/presentation-publishing';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { LocatorPublic } from '@kbn/share-plugin/common';
import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen';

View file

@ -21,11 +21,8 @@ import {
} from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import {
PresentationContainer,
PanelPackage,
SerializedPanelState,
} from '@kbn/presentation-containers';
import { PanelPackage } from '@kbn/presentation-publishing';
import { PresentationContainer, SerializedPanelState } from '@kbn/presentation-containers';
import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable';
import { EmbeddableStart } from '../../plugin';

View file

@ -12,13 +12,17 @@ import {
SerializedPanelState,
} from '@kbn/presentation-containers';
import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public';
import { StateComparators } from '@kbn/presentation-publishing';
import { ComparatorDefinition, StateComparators } from '@kbn/presentation-publishing';
import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import { combineLatest } from 'rxjs';
import { debounceTime, skip } from 'rxjs/operators';
import { v4 as generateId } from 'uuid';
import { getReactEmbeddableFactory } from './react_embeddable_registry';
import { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
import { DefaultEmbeddableApi, ReactEmbeddableApiRegistration } from './types';
const ON_STATE_CHANGE_DEBOUNCE = 100;
/**
* Renders a component from the React Embeddable registry into a Presentation Panel.
*
@ -34,6 +38,7 @@ export const ReactEmbeddableRenderer = <
parentApi,
onApiAvailable,
panelProps,
onAnyStateChange,
}: {
maybeId?: string;
type: string;
@ -49,6 +54,11 @@ export const ReactEmbeddableRenderer = <
| 'hideHeader'
| 'hideInspector'
>;
/**
* This `onAnyStateChange` callback allows the parent to keep track of the state of the embeddable
* as it changes. This is **not** expected to change over the lifetime of the component.
*/
onAnyStateChange?: (state: SerializedPanelState<StateType>) => void;
}) => {
const cleanupFunction = useRef<(() => void) | null>(null);
@ -68,6 +78,21 @@ export const ReactEmbeddableRenderer = <
comparators,
factory.deserializeState
);
if (onAnyStateChange) {
/**
* To avoid unnecessary re-renders, only subscribe to the comparator publishing subjects if
* an `onAnyStateChange` callback is provided
*/
const comparatorDefinitions: Array<ComparatorDefinition<StateType, keyof StateType>> =
Object.values(comparators);
combineLatest(comparatorDefinitions.map((comparator) => comparator[0]))
.pipe(skip(1), debounceTime(ON_STATE_CHANGE_DEBOUNCE))
.subscribe(() => {
onAnyStateChange(apiRegistration.serializeState());
});
}
const fullApi = {
...apiRegistration,
uuid,

View file

@ -39,6 +39,7 @@ const createStartContract = (): Start => {
getAction: jest.fn(),
hasAction: jest.fn(),
getTrigger: jest.fn(),
hasTrigger: jest.fn(),
getTriggerActions: jest.fn((id: string) => []),
getTriggerCompatibleActions: jest.fn((triggerId: string, context: object) =>
Promise.resolve([] as Array<Action<object>>)

View file

@ -55,6 +55,10 @@ export class UiActionsService {
this.triggerToActions.set(trigger.id, []);
};
public readonly hasTrigger = (triggerId: string): boolean => {
return Boolean(this.triggers.get(triggerId));
};
public readonly getTrigger = (triggerId: string): TriggerContract => {
const trigger = this.triggers.get(triggerId);

View file

@ -5,25 +5,33 @@
* 2.0.
*/
import React, { FC } from 'react';
import useObservable from 'react-use/lib/useObservable';
import ReactDOM from 'react-dom';
import { CoreStart } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import type { EmbeddableAppContext } from '@kbn/embeddable-plugin/public';
import {
IEmbeddable,
EmbeddableFactory,
EmbeddableFactoryNotFoundError,
isErrorEmbeddable,
EmbeddablePanel,
IEmbeddable,
isErrorEmbeddable,
reactEmbeddableRegistryHasKey,
ReactEmbeddableRenderer,
} from '@kbn/embeddable-plugin/public';
import type { EmbeddableAppContext } from '@kbn/embeddable-plugin/public';
import { StartDeps } from '../../plugin';
import { EmbeddableExpression } from '../../expression_types/embeddable';
import { RendererStrings } from '../../../i18n';
import { embeddableInputToExpression } from './embeddable_input_to_expression';
import { RendererFactory, EmbeddableInput } from '../../../types';
import { PresentationContainer } from '@kbn/presentation-containers';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import React, { FC } from 'react';
import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import { CANVAS_APP, CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib';
import { RendererStrings } from '../../../i18n';
import {
CanvasContainerApi,
EmbeddableInput,
RendererFactory,
RendererHandlers,
} from '../../../types';
import { EmbeddableExpression } from '../../expression_types/embeddable';
import { StartDeps } from '../../plugin';
import { embeddableInputToExpression } from './embeddable_input_to_expression';
const { embeddable: strings } = RendererStrings;
@ -32,6 +40,39 @@ const embeddablesRegistry: {
[key: string]: IEmbeddable | Promise<IEmbeddable>;
} = {};
const renderReactEmbeddable = ({
type,
uuid,
input,
container,
handlers,
}: {
type: string;
uuid: string;
input: EmbeddableInput;
container: CanvasContainerApi;
handlers: RendererHandlers;
}) => {
return (
<ReactEmbeddableRenderer
type={type}
maybeId={uuid}
parentApi={container as unknown as PresentationContainer}
key={`${type}_${uuid}`}
state={{ rawState: input }}
onAnyStateChange={(newState) => {
const newExpression = embeddableInputToExpression(
newState.rawState as unknown as EmbeddableInput,
type,
undefined,
true
);
if (newExpression) handlers.onEmbeddableInputChange(newExpression);
}}
/>
);
};
const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => {
const I18nContext = core.i18n.Context;
const EmbeddableRenderer: FC<{ embeddable: IEmbeddable }> = ({ embeddable }) => {
@ -75,20 +116,43 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => {
export const embeddableRendererFactory = (
core: CoreStart,
plugins: StartDeps
): RendererFactory<EmbeddableExpression<EmbeddableInput>> => {
): RendererFactory<EmbeddableExpression<EmbeddableInput> & { canvasApi: CanvasContainerApi }> => {
const renderEmbeddable = renderEmbeddableFactory(core, plugins);
return () => ({
name: 'embeddable',
displayName: strings.getDisplayName(),
help: strings.getHelpDescription(),
reuseDomNode: true,
render: async (domNode, { input, embeddableType }, handlers) => {
render: async (domNode, { input, embeddableType, canvasApi }, handlers) => {
const uniqueId = handlers.getElementId();
const isByValueEnabled = plugins.presentationUtil.labsService.isProjectEnabled(
'labs:canvas:byValueEmbeddable'
);
if (!embeddablesRegistry[uniqueId]) {
if (reactEmbeddableRegistryHasKey(embeddableType)) {
/**
* Prioritize React embeddables
*/
ReactDOM.render(
renderReactEmbeddable({
input,
handlers,
uuid: uniqueId,
type: embeddableType,
container: canvasApi,
}),
domNode,
() => handlers.done()
);
handlers.onDestroy(() => {
handlers.onEmbeddableDestroyed();
return ReactDOM.unmountComponentAtNode(domNode);
});
} else if (!embeddablesRegistry[uniqueId]) {
/**
* Handle legacy embeddables - embeddable does not exist in registry
*/
const factory = Array.from(plugins.embeddable.getEmbeddableFactories()).find(
(embeddableFactory) => embeddableFactory.type === embeddableType
) as EmbeddableFactory<EmbeddableInput>;
@ -155,6 +219,9 @@ export const embeddableRendererFactory = (
return ReactDOM.unmountComponentAtNode(domNode);
});
} else {
/**
* Handle legacy embeddables - embeddable already exists in registry
*/
const embeddable = embeddablesRegistry[uniqueId];
// updating embeddable input with changes made to expression or filters

View file

@ -21,17 +21,27 @@ export const inputToExpressionTypeMap = {
/*
Take the input from an embeddable and the type of embeddable and convert it into an expression
*/
export function embeddableInputToExpression(
input: EmbeddableInput,
export function embeddableInputToExpression<
UseGenericEmbeddable extends boolean,
ConditionalReturnType = UseGenericEmbeddable extends true ? string : string | undefined
>(
input: Omit<EmbeddableInput, 'id'>,
embeddableType: string,
palettes: PaletteRegistry,
useGenericEmbeddable?: boolean
): string | undefined {
palettes?: PaletteRegistry,
useGenericEmbeddable?: UseGenericEmbeddable
): ConditionalReturnType {
// if `useGenericEmbeddable` is `true`, it **always** returns a string
if (useGenericEmbeddable) {
return genericToExpression(input, embeddableType);
return genericToExpression(input, embeddableType) as ConditionalReturnType;
}
// otherwise, depending on if the embeddable type is defined, it might return undefined
if (inputToExpressionTypeMap[embeddableType]) {
return inputToExpressionTypeMap[embeddableType](input as any, palettes);
return inputToExpressionTypeMap[embeddableType](
input as any,
palettes
) as ConditionalReturnType;
}
return undefined as ConditionalReturnType;
}

View file

@ -8,6 +8,6 @@
import { encode } from '../../../../common/lib/embeddable_dataurl';
import { EmbeddableInput } from '../../../expression_types';
export function toExpression(input: EmbeddableInput, embeddableType: string): string {
return `embeddable config="${encode(input)}" type="${embeddableType}"`;
export function toExpression(input: Omit<EmbeddableInput, 'id'>, embeddableType: string): string {
return `embeddable config="${encode(input)}" type="${embeddableType}" | render`;
}

View file

@ -9,7 +9,7 @@ import { toExpression as toExpressionString } from '@kbn/interpreter';
import type { PaletteRegistry } from '@kbn/coloring';
import type { SavedLensInput } from '../../../functions/external/saved_lens';
export function toExpression(input: SavedLensInput, palettes: PaletteRegistry): string {
export function toExpression(input: SavedLensInput, palettes?: PaletteRegistry): string {
const expressionParts = [] as string[];
expressionParts.push('savedLens');
@ -26,7 +26,7 @@ export function toExpression(input: SavedLensInput, palettes: PaletteRegistry):
);
}
if (input.palette) {
if (input.palette && palettes) {
expressionParts.push(
`palette={${toExpressionString(
palettes.get(input.palette.name).toExpression(input.palette.params)
@ -34,5 +34,5 @@ export function toExpression(input: SavedLensInput, palettes: PaletteRegistry):
);
}
return expressionParts.join(' ');
return `${expressionParts.join(' ')} | render`;
}

View file

@ -36,5 +36,5 @@ export function toExpression(input: MapEmbeddableInput & { savedObjectId: string
}
}
return expressionParts.join(' ');
return `${expressionParts.join(' ')} | render`;
}

View file

@ -36,5 +36,5 @@ export function toExpression(input: VisualizeInput & { savedObjectId: string }):
expressionParts.push(`hideLegend=true`);
}
return expressionParts.join(' ');
return `${expressionParts.join(' ')} | render`;
}

View file

@ -8,12 +8,12 @@
import React, { useMemo, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector, useDispatch } from 'react-redux';
import { encode } from '../../../common/lib/embeddable_dataurl';
import { AddEmbeddableFlyout as Component, Props as ComponentProps } from './flyout.component';
// @ts-expect-error untyped local
import { addElement } from '../../state/actions/elements';
import { getSelectedPage } from '../../state/selectors/workpad';
import { EmbeddableTypes } from '../../../canvas_plugin_src/expression_types/embeddable';
import { embeddableInputToExpression } from '../../../canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression';
import { State } from '../../../types';
import { useLabsService } from '../../services';
@ -88,10 +88,12 @@ export const AddEmbeddablePanel: React.FunctionComponent<FlyoutProps> = ({
// with the new generic `embeddable` function.
// Otherwise we fallback to the embeddable type specific expressions.
if (isByValueEnabled) {
const config = encode({ savedObjectId: id });
partialElement.expression = `embeddable config="${config}"
type="${type}"
| render`;
partialElement.expression = embeddableInputToExpression(
{ savedObjectId: id },
type,
undefined,
true
);
} else if (allowedEmbeddables[type]) {
partialElement.expression = allowedEmbeddables[type](id);
}

View file

@ -0,0 +1,54 @@
/*
* 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 { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { BehaviorSubject } from 'rxjs';
import { EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/common';
import { embeddableInputToExpression } from '../../../canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression';
import { CanvasContainerApi } from '../../../types';
import { METRIC_TYPE, trackCanvasUiMetric } from '../../lib/ui_metric';
// @ts-expect-error unconverted file
import { addElement } from '../../state/actions/elements';
import { getSelectedPage } from '../../state/selectors/workpad';
export const useCanvasApi: () => CanvasContainerApi = () => {
const selectedPageId = useSelector(getSelectedPage);
const dispatch = useDispatch();
const createNewEmbeddable = useCallback(
(type: string, embeddableInput: EmbeddableInput) => {
if (trackCanvasUiMetric) {
trackCanvasUiMetric(METRIC_TYPE.CLICK, type);
}
if (embeddableInput) {
const expression = embeddableInputToExpression(embeddableInput, type, undefined, true);
dispatch(addElement(selectedPageId, { expression }));
}
},
[selectedPageId, dispatch]
);
const getCanvasApi = useCallback(() => {
return {
viewMode: new BehaviorSubject<ViewMode>(ViewMode.EDIT), // always in edit mode
addNewPanel: async ({
panelType,
initialState,
}: {
panelType: string;
initialState: EmbeddableInput;
}) => {
createNewEmbeddable(panelType, initialState);
},
} as CanvasContainerApi;
}, [createNewEmbeddable]);
return useMemo(() => getCanvasApi(), [getCanvasApi]);
};

View file

@ -10,7 +10,7 @@ import { useDispatch } from 'react-redux';
import { fromExpression } from '@kbn/interpreter';
import { ErrorStrings } from '../../../../i18n';
import { CANVAS_APP } from '../../../../common/lib';
import { decode, encode } from '../../../../common/lib/embeddable_dataurl';
import { decode } from '../../../../common/lib/embeddable_dataurl';
import { CanvasElement, CanvasPage } from '../../../../types';
import { useEmbeddablesService, useLabsService, useNotifyService } from '../../../services';
// @ts-expect-error unconverted file
@ -23,6 +23,7 @@ import {
fetchEmbeddableRenderable,
} from '../../../state/actions/embeddable';
import { clearValue } from '../../../state/actions/resolved_args';
import { embeddableInputToExpression } from '../../../../canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression';
const { actionsElements: strings } = ErrorStrings;
@ -83,9 +84,7 @@ export const useIncomingEmbeddable = (selectedPage: CanvasPage) => {
updatedInput = { ...originalInput, ...incomingInput };
}
const expression = `embeddable config="${encode(updatedInput)}"
type="${type}"
| render`;
const expression = embeddableInputToExpression(updatedInput, type, undefined, true);
dispatch(
updateEmbeddableExpression({
@ -100,9 +99,7 @@ export const useIncomingEmbeddable = (selectedPage: CanvasPage) => {
// select new embeddable element
dispatch(selectToplevelNodes([embeddableId]));
} else {
const expression = `embeddable config="${encode(incomingInput)}"
type="${type}"
| render`;
const expression = embeddableInputToExpression(incomingInput, type, undefined, true);
dispatch(addElement(selectedPage.id, { expression }));
}
}

View file

@ -13,6 +13,7 @@ import { useNotifyService } from '../../services';
import { RenderToDom } from '../render_to_dom';
import { ErrorStrings } from '../../../i18n';
import { RendererHandlers } from '../../../types';
import { useCanvasApi } from '../hooks/use_canvas_api';
const { RenderWithFn: strings } = ErrorStrings;
@ -41,6 +42,7 @@ export const RenderWithFn: FC<Props> = ({
width,
height,
}) => {
const canvasApi = useCanvasApi();
const { error: onError } = useNotifyService();
const [domNode, setDomNode] = useState<HTMLElement | null>(null);
@ -88,9 +90,12 @@ export const RenderWithFn: FC<Props> = ({
if (!isEqual(handlers.current, incomingHandlers)) {
handlers.current = incomingHandlers;
}
await renderFn(renderTarget.current!, config, handlers.current);
}, [renderTarget, config, renderFn, incomingHandlers]);
/**
* we are creating a new react tree when we render the element, so we need to pass the current api as a prop
* to the element rather than calling `useCanvasApi` directly
*/
await renderFn(renderTarget.current!, { ...config, canvasApi }, handlers.current);
}, [renderTarget, config, renderFn, incomingHandlers, canvasApi]);
useEffect(() => {
if (!domNode) {

View file

@ -94,10 +94,12 @@ const testVisTypeAliases: VisTypeAlias[] = [
storiesOf('components/WorkpadHeader/EditorMenu', module).add('default', () => (
<EditorMenu
addPanelActions={[]}
factories={testFactories}
promotedVisTypes={testVisTypes}
visTypeAliases={testVisTypeAliases}
createNewVisType={() => action('createNewVisType')}
createNewEmbeddable={() => action('createNewEmbeddable')}
createNewEmbeddableFromFactory={() => action('createNewEmbeddableFromFactory')}
createNewEmbeddableFromAction={() => action('createNewEmbeddableFromAction')}
/>
));

View file

@ -5,16 +5,21 @@
* 2.0.
*/
import React, { FC } from 'react';
import React, { FC, useCallback } from 'react';
import {
EuiContextMenu,
EuiContextMenuPanelItemDescriptor,
EuiContextMenuItemIcon,
EuiContextMenuPanelItemDescriptor,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public';
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { i18n } from '@kbn/i18n';
import { ToolbarPopover } from '@kbn/shared-ux-button-toolbar';
import { Action, ActionExecutionContext } from '@kbn/ui-actions-plugin/public/actions';
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { addCanvasElementTrigger } from '../../../state/triggers/add_canvas_element_trigger';
import { useCanvasApi } from '../../hooks/use_canvas_api';
const strings = {
getEditorMenuButtonLabel: () =>
@ -33,21 +38,30 @@ interface FactoryGroup {
interface Props {
factories: EmbeddableFactoryDefinition[];
addPanelActions: Action[];
promotedVisTypes: BaseVisType[];
visTypeAliases: VisTypeAlias[];
createNewVisType: (visType?: BaseVisType | VisTypeAlias) => () => void;
createNewEmbeddable: (factory: EmbeddableFactoryDefinition) => () => void;
createNewEmbeddableFromFactory: (factory: EmbeddableFactoryDefinition) => () => void;
createNewEmbeddableFromAction: (
action: Action,
context: ActionExecutionContext<object>,
closePopover: () => void
) => (event: React.MouseEvent) => void;
}
export const EditorMenu: FC<Props> = ({
factories,
addPanelActions,
promotedVisTypes,
visTypeAliases,
createNewVisType,
createNewEmbeddable,
createNewEmbeddableFromAction,
createNewEmbeddableFromFactory,
}: Props) => {
const factoryGroupMap: Record<string, FactoryGroup> = {};
const ungroupedFactories: EmbeddableFactoryDefinition[] = [];
const canvasApi = useCanvasApi();
let panelCount = 1;
@ -113,16 +127,37 @@ export const EditorMenu: FC<Props> = ({
name: factory.getDisplayName(),
icon,
toolTipContent,
onClick: createNewEmbeddable(factory),
onClick: createNewEmbeddableFromFactory(factory),
'data-test-subj': `createNew-${factory.type}`,
};
};
const editorMenuPanels = [
const getAddPanelActionMenuItems = useCallback(
(closePopover: () => void) => {
return addPanelActions.map((item) => {
const context = {
embeddable: canvasApi,
trigger: addCanvasElementTrigger,
};
const actionName = item.getDisplayName(context);
return {
name: actionName,
icon: item.getIconType(context),
onClick: createNewEmbeddableFromAction(item, context, closePopover),
'data-test-subj': `create-action-${actionName}`,
toolTipContent: item?.getDisplayNameTooltip?.(context),
};
});
},
[addPanelActions, createNewEmbeddableFromAction, canvasApi]
);
const getEditorMenuPanels = (closePopover: () => void) => [
{
id: 0,
items: [
...visTypeAliases.map(getVisTypeAliasMenuItem),
...getAddPanelActionMenuItems(closePopover),
...Object.values(factoryGroupMap).map(({ id, appName, icon, panelId }) => ({
name: appName,
icon,
@ -149,10 +184,10 @@ export const EditorMenu: FC<Props> = ({
panelPaddingSize="none"
data-test-subj="canvasEditorMenuButton"
>
{() => (
{({ closePopover }: { closePopover: () => void }) => (
<EuiContextMenu
initialPanelId={0}
panels={editorMenuPanels}
panels={getEditorMenuPanels(closePopover)}
data-test-subj="canvasEditorContextMenu"
/>
)}

View file

@ -13,12 +13,21 @@ import {
EmbeddableFactoryDefinition,
EmbeddableInput,
} from '@kbn/embeddable-plugin/public';
import { Action, ActionExecutionContext } from '@kbn/ui-actions-plugin/public/actions';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../../lib/ui_metric';
import { useEmbeddablesService, useVisualizationsService } from '../../../services';
import {
useEmbeddablesService,
useUiActionsService,
useVisualizationsService,
} from '../../../services';
import { CANVAS_APP } from '../../../../common/lib';
import { encode } from '../../../../common/lib/embeddable_dataurl';
import { ElementSpec } from '../../../../types';
import { EditorMenu as Component } from './editor_menu.component';
import { embeddableInputToExpression } from '../../../../canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression';
import { EmbeddableInput as CanvasEmbeddableInput } from '../../../../canvas_plugin_src/expression_types';
import { useCanvasApi } from '../../hooks/use_canvas_api';
import { ADD_CANVAS_ELEMENT_TRIGGER } from '../../../state/triggers/add_canvas_element_trigger';
interface Props {
/**
@ -37,6 +46,10 @@ export const EditorMenu: FC<Props> = ({ addElement }) => {
const { pathname, search, hash } = useLocation();
const stateTransferService = embeddablesService.getStateTransfer();
const visualizationsService = useVisualizationsService();
const uiActions = useUiActionsService();
const canvasApi = useCanvasApi();
const [addPanelActions, setAddPanelActions] = useState<Array<Action<object>>>([]);
const embeddableFactories = useMemo(
() => (embeddablesService ? Array.from(embeddablesService.getEmbeddableFactories()) : []),
@ -58,6 +71,21 @@ export const EditorMenu: FC<Props> = ({ addElement }) => {
});
}, [embeddableFactories]);
useEffect(() => {
let mounted = true;
async function loadPanelActions() {
const registeredActions = await uiActions?.getTriggerCompatibleActions?.(
ADD_CANVAS_ELEMENT_TRIGGER,
{ embeddable: canvasApi }
);
if (mounted) setAddPanelActions(registeredActions);
}
loadPanelActions();
return () => {
mounted = false;
};
}, [uiActions, canvasApi]);
const createNewVisType = useCallback(
(visType?: BaseVisType | VisTypeAlias) => () => {
let path = '';
@ -93,11 +121,12 @@ export const EditorMenu: FC<Props> = ({ addElement }) => {
[stateTransferService, pathname, search, hash]
);
const createNewEmbeddable = useCallback(
const createNewEmbeddableFromFactory = useCallback(
(factory: EmbeddableFactoryDefinition) => async () => {
if (trackCanvasUiMetric) {
trackCanvasUiMetric(METRIC_TYPE.CLICK, factory.type);
}
let embeddableInput;
if (factory.getExplicitInput) {
embeddableInput = await factory.getExplicitInput();
@ -107,17 +136,37 @@ export const EditorMenu: FC<Props> = ({ addElement }) => {
}
if (embeddableInput) {
const config = encode(embeddableInput);
const expression = `embeddable config="${config}"
type="${factory.type}"
| render`;
const expression = embeddableInputToExpression(
embeddableInput as CanvasEmbeddableInput,
factory.type,
undefined,
true
);
addElement({ expression });
}
},
[addElement]
);
const createNewEmbeddableFromAction = useCallback(
(action: Action, context: ActionExecutionContext<object>, closePopover: () => void) =>
(event: React.MouseEvent) => {
closePopover();
if (event.currentTarget instanceof HTMLAnchorElement) {
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 &&
(!event.currentTarget.target || event.currentTarget.target === '_self') &&
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
) {
event.preventDefault();
action.execute(context);
}
} else action.execute(context);
},
[]
);
const getVisTypesByGroup = (group: VisGroups): BaseVisType[] =>
visualizationsService
.getByGroup(group)
@ -156,9 +205,11 @@ export const EditorMenu: FC<Props> = ({ addElement }) => {
return (
<Component
createNewVisType={createNewVisType}
createNewEmbeddable={createNewEmbeddable}
createNewEmbeddableFromFactory={createNewEmbeddableFromFactory}
createNewEmbeddableFromAction={createNewEmbeddableFromAction}
promotedVisTypes={promotedVisTypes}
factories={factories}
addPanelActions={addPanelActions}
visTypeAliases={visTypeAliases}
/>
);

View file

@ -23,7 +23,7 @@ import { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { ExpressionsSetup, ExpressionsStart } from '@kbn/expressions-plugin/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { Start as InspectorStart } from '@kbn/inspector-plugin/public';
@ -38,6 +38,7 @@ import { getSessionStorage } from './lib/storage';
import { initLoadingIndicator } from './lib/loading_indicator';
import { getPluginApi, CanvasApi } from './plugin_api';
import { setupExpressions } from './setup_expressions';
import { addCanvasElementTrigger } from './state/triggers/add_canvas_element_trigger';
export type { CoreStart, CoreSetup };
@ -54,6 +55,7 @@ export interface CanvasSetupDeps {
usageCollection?: UsageCollectionSetup;
bfetch: BfetchPublicSetup;
charts: ChartsPluginSetup;
uiActions: UiActionsSetup;
}
export interface CanvasStartDeps {
@ -182,6 +184,8 @@ export class CanvasPlugin
return transitions;
});
setupPlugins.uiActions.registerTrigger(addCanvasElementTrigger);
return {
...canvasApi,
};

View file

@ -21,6 +21,7 @@ import { CanvasPlatformService } from './platform';
import { CanvasReportingService } from './reporting';
import { CanvasVisualizationsService } from './visualizations';
import { CanvasWorkpadService } from './workpad';
import { CanvasUiActionsService } from './ui_actions';
export interface CanvasPluginServices {
customElement: CanvasCustomElementService;
@ -35,6 +36,7 @@ export interface CanvasPluginServices {
reporting: CanvasReportingService;
visualizations: CanvasVisualizationsService;
workpad: CanvasWorkpadService;
uiActions: CanvasUiActionsService;
}
export const pluginServices = new PluginServices<CanvasPluginServices>();
@ -55,3 +57,4 @@ export const useReportingService = () => (() => pluginServices.getHooks().report
export const useVisualizationsService = () =>
(() => pluginServices.getHooks().visualizations.useService())();
export const useWorkpadService = () => (() => pluginServices.getHooks().workpad.useService())();
export const useUiActionsService = () => (() => pluginServices.getHooks().uiActions.useService())();

View file

@ -26,6 +26,7 @@ import { reportingServiceFactory } from './reporting';
import { visualizationsServiceFactory } from './visualizations';
import { workpadServiceFactory } from './workpad';
import { filtersServiceFactory } from './filters';
import { uiActionsServiceFactory } from './ui_actions';
export { customElementServiceFactory } from './custom_element';
export { dataViewsServiceFactory } from './data_views';
@ -38,6 +39,7 @@ export { platformServiceFactory } from './platform';
export { reportingServiceFactory } from './reporting';
export { visualizationsServiceFactory } from './visualizations';
export { workpadServiceFactory } from './workpad';
export { uiActionsServiceFactory } from './ui_actions';
export const pluginServiceProviders: PluginServiceProviders<
CanvasPluginServices,
@ -55,6 +57,7 @@ export const pluginServiceProviders: PluginServiceProviders<
reporting: new PluginServiceProvider(reportingServiceFactory),
visualizations: new PluginServiceProvider(visualizationsServiceFactory),
workpad: new PluginServiceProvider(workpadServiceFactory),
uiActions: new PluginServiceProvider(uiActionsServiceFactory),
};
export const pluginServiceRegistry = new PluginServiceRegistry<

View file

@ -0,0 +1,19 @@
/*
* 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 { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { CanvasStartDeps } from '../../plugin';
import { CanvasUiActionsService } from '../ui_actions';
export type UiActionsServiceFactory = KibanaPluginServiceFactory<
CanvasUiActionsService,
CanvasStartDeps
>;
export const uiActionsServiceFactory: UiActionsServiceFactory = ({ startPlugins }) => ({
getTriggerCompatibleActions: startPlugins.uiActions.getTriggerCompatibleActions,
});

View file

@ -26,6 +26,7 @@ import { reportingServiceFactory } from './reporting';
import { visualizationsServiceFactory } from './visualizations';
import { workpadServiceFactory } from './workpad';
import { filtersServiceFactory } from './filters';
import { uiActionsServiceFactory } from './ui_actions';
export { customElementServiceFactory } from './custom_element';
export { dataViewsServiceFactory } from './data_views';
@ -52,6 +53,7 @@ export const pluginServiceProviders: PluginServiceProviders<CanvasPluginServices
reporting: new PluginServiceProvider(reportingServiceFactory),
visualizations: new PluginServiceProvider(visualizationsServiceFactory),
workpad: new PluginServiceProvider(workpadServiceFactory),
uiActions: new PluginServiceProvider(uiActionsServiceFactory),
};
export const pluginServiceRegistry = new PluginServiceRegistry<CanvasPluginServices>(

View file

@ -0,0 +1,17 @@
/*
* 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 { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { CanvasUiActionsService } from '../ui_actions';
type UiActionsServiceFactory = PluginServiceFactory<CanvasUiActionsService>;
export const uiActionsServiceFactory: UiActionsServiceFactory = () => {
const pluginMock = uiActionsPluginMock.createStartContract();
return { getTriggerCompatibleActions: pluginMock.getTriggerCompatibleActions };
};

View file

@ -0,0 +1,12 @@
/*
* 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 type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
export interface CanvasUiActionsService {
getTriggerCompatibleActions: UiActionsStart['getTriggerCompatibleActions'];
}

View file

@ -0,0 +1,20 @@
/*
* 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 { i18n } from '@kbn/i18n';
import type { Trigger } from '@kbn/ui-actions-plugin/public';
export const ADD_CANVAS_ELEMENT_TRIGGER = 'ADD_CANVAS_ELEMENT_TRIGGER';
export const addCanvasElementTrigger: Trigger = {
id: ADD_CANVAS_ELEMENT_TRIGGER,
title: i18n.translate('xpack.canvas.addCanvasElementTrigger.title', {
defaultMessage: 'Add panel menu',
}),
description: i18n.translate('xpack.canvas.addCanvasElementTrigger.description', {
defaultMessage: 'A new action will appear in the Canvas add panel menu',
}),
};

View file

@ -88,6 +88,8 @@
"@kbn/reporting-export-types-pdf-common",
"@kbn/code-editor",
"@kbn/shared-ux-markdown",
"@kbn/presentation-containers",
"@kbn/presentation-publishing",
],
"exclude": [
"target/**/*",

View file

@ -8,9 +8,12 @@
import type { TimeRange } from '@kbn/es-query';
import { Filter } from '@kbn/es-query';
import { EmbeddableInput as Input } from '@kbn/embeddable-plugin/common';
import { CanAddNewPanel, PublishesViewMode } from '@kbn/presentation-publishing';
export type EmbeddableInput = Input & {
timeRange?: TimeRange;
filters?: Filter[];
savedObjectId?: string;
};
export type CanvasContainerApi = PublishesViewMode & CanAddNewPanel;