[Embeddables rebuild] Decouple add panel trigger (#176110)

Decouples the `ADD_PANEL_TRIGGER` from the Embeddables framework  by making it take a `PresentationContainer` as context instead.
This commit is contained in:
Devon Thomson 2024-02-07 09:57:58 -05:00 committed by GitHub
parent 2735882951
commit 23b4b538f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 391 additions and 310 deletions

View file

@ -62,7 +62,7 @@ export class SimpleEmbeddableFactoryDefinition
public getDisplayName() {
return i18n.translate('embeddableExamples.migrations.displayName', {
defaultMessage: 'hello world',
defaultMessage: 'simple migration embeddable',
});
}
}

View file

@ -31,7 +31,8 @@ import {
FilterDebuggerEmbeddableFactory,
FilterDebuggerEmbeddableFactoryDefinition,
} from './filter_debugger';
import { registerMarkdownEditorEmbeddable } from './react_embeddables/eui_markdown_react_embeddable';
import { registerMarkdownEditorEmbeddable } from './react_embeddables/eui_markdown/eui_markdown_react_embeddable';
import { registerCreateEuiMarkdownAction } from './react_embeddables/eui_markdown/create_eui_markdown_action';
export interface EmbeddableExamplesSetupDependencies {
embeddable: EmbeddableSetup;
@ -54,8 +55,6 @@ export interface EmbeddableExamplesStart {
factories: ExampleEmbeddableFactories;
}
registerMarkdownEditorEmbeddable();
export class EmbeddableExamplesPlugin
implements
Plugin<
@ -71,6 +70,9 @@ export class EmbeddableExamplesPlugin
core: CoreSetup<EmbeddableExamplesStartDependencies>,
deps: EmbeddableExamplesSetupDependencies
) {
registerMarkdownEditorEmbeddable();
registerCreateEuiMarkdownAction(deps.uiActions);
this.exampleEmbeddableFactories.getHelloWorldEmbeddableFactory =
deps.embeddable.registerEmbeddableFactory(
HELLO_WORLD_EMBEDDABLE,

View file

@ -0,0 +1,10 @@
/*
* 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 const EUI_MARKDOWN_ID = 'euiMarkdown';
export const ADD_EUI_MARKDOWN_ACTION_ID = 'create_eui_markdown';

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { IncompatibleActionError, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { ADD_EUI_MARKDOWN_ACTION_ID, EUI_MARKDOWN_ID } from './constants';
// -----------------------------------------------------------------------------
// Create and register an action which allows this embeddable to be created from
// the dashboard toolbar context menu.
// -----------------------------------------------------------------------------
export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => {
uiActions.registerAction<EmbeddableApiContext>({
id: ADD_EUI_MARKDOWN_ACTION_ID,
getIconType: () => 'editorCodeBlock',
isCompatible: async ({ embeddable }) => {
return apiIsPresentationContainer(embeddable);
},
execute: async ({ embeddable }) => {
if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
embeddable.addNewPanel(
{
panelType: EUI_MARKDOWN_ID,
initialState: { content: '# hello world!' },
},
true
);
},
getDisplayName: () =>
i18n.translate('embeddableExamples.euiMarkdownEditor.ariaLabel', {
defaultMessage: 'EUI Markdown',
}),
});
uiActions.attachAction('ADD_PANEL_TRIGGER', ADD_EUI_MARKDOWN_ACTION_ID);
};

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiMarkdownEditor, EuiMarkdownFormat } from '@elastic/eui';
import { css } from '@emotion/react';
import {
initializeReactEmbeddableTitles,
initializeReactEmbeddableUuid,
ReactEmbeddableFactory,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
useReactEmbeddableUnsavedChanges,
} from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { useInheritedViewMode, useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { euiThemeVars } from '@kbn/ui-theme';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { EUI_MARKDOWN_ID } from './constants';
import { MarkdownEditorSerializedState, MarkdownEditorApi } from './types';
export const registerMarkdownEditorEmbeddable = () => {
const markdownEmbeddableFactory: ReactEmbeddableFactory<
MarkdownEditorSerializedState,
MarkdownEditorApi
> = {
deserializeState: (state) => {
/**
* Here we can run migrations and inject references.
*/
return state.rawState as MarkdownEditorSerializedState;
},
getComponent: async (state, maybeId) => {
/**
* initialize state (source of truth)
*/
const uuid = initializeReactEmbeddableUuid(maybeId);
const { titlesApi, titleComparators, serializeTitles } =
initializeReactEmbeddableTitles(state);
const contentSubject = new BehaviorSubject(state.content);
/**
* getComponent is async so you can async import the component or load a saved object here.
* the loading will be handed gracefully by the Presentation Container.
*/
return RegisterReactEmbeddable((apiRef) => {
/**
* Unsaved changes logic is handled automatically by this hook. You only need to provide
* a subject, setter, and optional state comparator for each key in your state type.
*/
const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges(
uuid,
markdownEmbeddableFactory,
{
content: [contentSubject, (value) => contentSubject.next(value)],
...titleComparators,
}
);
/**
* Publish the API. This is what gets forwarded to the Actions framework, and to whatever the
* parent of this embeddable is.
*/
const thisApi = useReactEmbeddableApiHandle(
{
...titlesApi,
unsavedChanges,
resetUnsavedChanges,
serializeState: async () => {
return {
rawState: {
...serializeTitles(),
content: contentSubject.getValue(),
},
};
},
},
apiRef,
uuid
);
// get state for rendering
const content = useStateFromPublishingSubject(contentSubject);
const viewMode = useInheritedViewMode(thisApi) ?? 'view';
return viewMode === 'edit' ? (
<EuiMarkdownEditor
css={css`
width: 100%;
`}
value={content ?? ''}
onChange={(value) => contentSubject.next(value)}
aria-label={i18n.translate('embeddableExamples.euiMarkdownEditor.ariaLabel', {
defaultMessage: 'Dashboard markdown editor',
})}
height="full"
/>
) : (
<EuiMarkdownFormat
css={css`
padding: ${euiThemeVars.euiSizeS};
`}
>
{content ?? ''}
</EuiMarkdownFormat>
);
});
},
};
/**
* Register the defined Embeddable Factory - notice that this isn't defined
* on the plugin. Instead, it's a simple imported function. I.E to register an
* embeddable, you only need the embeddable plugin in your requiredBundles
*/
registerReactEmbeddableFactory(EUI_MARKDOWN_ID, markdownEmbeddableFactory);
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
DefaultEmbeddableApi,
SerializedReactEmbeddableTitles,
} from '@kbn/embeddable-plugin/public';
export type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & {
content: string;
};
export type MarkdownEditorApi = DefaultEmbeddableApi;

View file

@ -1,143 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiMarkdownEditor, EuiMarkdownFormat } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import {
ReactEmbeddableFactory,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
initializeReactEmbeddableUuid,
initializeReactEmbeddableTitles,
SerializedReactEmbeddableTitles,
DefaultEmbeddableApi,
useReactEmbeddableUnsavedChanges,
} from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { useInheritedViewMode, useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
// -----------------------------------------------------------------------------
// Types for this embeddable
// -----------------------------------------------------------------------------
type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & {
content: string;
};
type MarkdownEditorApi = DefaultEmbeddableApi;
const type = 'euiMarkdown';
// -----------------------------------------------------------------------------
// Define the Embeddable Factory
// -----------------------------------------------------------------------------
const markdownEmbeddableFactory: ReactEmbeddableFactory<
MarkdownEditorSerializedState,
MarkdownEditorApi
> = {
// -----------------------------------------------------------------------------
// Deserialize function
// -----------------------------------------------------------------------------
deserializeState: (state) => {
// We could run migrations here.
// We should inject references here. References are given as state.references
return state.rawState as MarkdownEditorSerializedState;
},
// -----------------------------------------------------------------------------
// Register the Embeddable component
// -----------------------------------------------------------------------------
getComponent: async (state, maybeId) => {
/**
* initialize state (source of truth)
*/
const uuid = initializeReactEmbeddableUuid(maybeId);
const { titlesApi, titleComparators, serializeTitles } = initializeReactEmbeddableTitles(state);
const contentSubject = new BehaviorSubject(state.content);
/**
* getComponent is async so you can async import the component or load a saved object here.
* the loading will be handed gracefully by the Presentation Container.
*/
return RegisterReactEmbeddable((apiRef) => {
/**
* Unsaved changes logic is handled automatically by this hook. You only need to provide
* a subject, setter, and optional state comparator for each key in your state type.
*/
const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges(
uuid,
markdownEmbeddableFactory,
{
content: [contentSubject, (value) => contentSubject.next(value)],
...titleComparators,
}
);
/**
* Publish the API. This is what gets forwarded to the Actions framework, and to whatever the
* parent of this embeddable is.
*/
const thisApi = useReactEmbeddableApiHandle(
{
...titlesApi,
unsavedChanges,
resetUnsavedChanges,
serializeState: async () => {
return {
rawState: {
...serializeTitles(),
content: contentSubject.getValue(),
},
};
},
},
apiRef,
uuid
);
// get state for rendering
const content = useStateFromPublishingSubject(contentSubject);
const viewMode = useInheritedViewMode(thisApi) ?? 'view';
return viewMode === 'edit' ? (
<EuiMarkdownEditor
css={css`
width: 100%;
`}
value={content ?? ''}
onChange={(value) => contentSubject.next(value)}
aria-label={i18n.translate('dashboard.test.markdownEditor.ariaLabel', {
defaultMessage: 'Dashboard markdown editor',
})}
height="full"
/>
) : (
<EuiMarkdownFormat
css={css`
padding: ${euiThemeVars.euiSizeS};
`}
>
{content ?? ''}
</EuiMarkdownFormat>
);
});
},
};
// -----------------------------------------------------------------------------
// Register the defined Embeddable Factory - notice that this isn't defined
// on the plugin. Instead, it's a simple imported function. I.E to register an
// Embeddable, you only need the embeddable plugin in your requiredBundles
// -----------------------------------------------------------------------------
export const registerMarkdownEditorEmbeddable = () =>
registerReactEmbeddableFactory(type, markdownEmbeddableFactory);

View file

@ -20,6 +20,7 @@
"@kbn/presentation-publishing",
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/es-query"
"@kbn/es-query",
"@kbn/presentation-containers"
]
}

View file

@ -30,7 +30,7 @@ export const getLastSavedStateSubjectForChild = <StateType extends unknown = unk
deserializer?: (state: SerializedPanelState) => StateType
): PublishingSubject<StateType | undefined> | undefined => {
if (!parentApi) return;
const fetchUnsavedChanges = (): StateType | undefined => {
const fetchLastSavedState = (): StateType | undefined => {
if (!apiPublishesLastSavedState(parentApi)) return;
const rawLastSavedState = parentApi.getLastSavedStateForChild(childId);
if (rawLastSavedState === undefined) return;
@ -39,11 +39,11 @@ export const getLastSavedStateSubjectForChild = <StateType extends unknown = unk
: (rawLastSavedState.rawState as StateType);
};
const lastSavedStateForChild = new BehaviorSubject<StateType | undefined>(fetchUnsavedChanges());
const lastSavedStateForChild = new BehaviorSubject<StateType | undefined>(fetchLastSavedState());
if (!apiPublishesLastSavedState(parentApi)) return;
parentApi.lastSavedState
.pipe(
map(() => fetchUnsavedChanges()),
map(() => fetchLastSavedState()),
filter((rawLastSavedState) => rawLastSavedState !== undefined)
)
.subscribe(lastSavedStateForChild);

View file

@ -11,11 +11,15 @@ import { PublishesLastSavedState } from './last_saved_state';
export interface PanelPackage {
panelType: string;
initialState: unknown;
initialState?: object;
}
export type PresentationContainer = Partial<PublishesViewMode> &
PublishesLastSavedState & {
addNewPanel: <ApiType extends unknown = unknown>(
panel: PanelPackage,
displaySuccessMessage?: boolean
) => Promise<ApiType | undefined>;
registerPanelApi: <ApiType extends unknown = unknown>(
panelId: string,
panelApi: ApiType
@ -31,7 +35,8 @@ export const apiIsPresentationContainer = (
return Boolean(
(unknownApi as PresentationContainer)?.removePanel !== undefined &&
(unknownApi as PresentationContainer)?.registerPanelApi !== undefined &&
(unknownApi as PresentationContainer)?.replacePanel !== undefined
(unknownApi as PresentationContainer)?.replacePanel !== undefined &&
(unknownApi as PresentationContainer)?.addNewPanel !== undefined
);
};

View file

@ -11,9 +11,10 @@ import { PresentationContainer } from './interfaces/presentation_container';
export const getMockPresentationContainer = (): PresentationContainer => {
return {
registerPanelApi: jest.fn(),
removePanel: jest.fn(),
addNewPanel: jest.fn(),
replacePanel: jest.fn(),
registerPanelApi: jest.fn(),
lastSavedState: new Subject<void>(),
getLastSavedStateForChild: jest.fn(),
};

View file

@ -7,6 +7,10 @@
*/
export interface EmbeddableApiContext {
/**
* TODO: once all actions are entirely decoupled from the embeddable system, this key should be renamed to "api"
* to reflect the fact that this context could contain any api.
*/
embeddable: unknown;
}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { getAddPanelActionMenuItems } from './add_panel_action_menu_items';
describe('getAddPanelActionMenuItems', () => {
@ -21,7 +22,11 @@ describe('getAddPanelActionMenuItems', () => {
execute: jest.fn(),
},
];
const items = getAddPanelActionMenuItems(registeredActions, jest.fn(), jest.fn(), jest.fn());
const items = getAddPanelActionMenuItems(
getMockPresentationContainer(),
registeredActions,
jest.fn()
);
expect(items).toStrictEqual([
{
'data-test-subj': 'create-action-Action name',
@ -34,7 +39,7 @@ describe('getAddPanelActionMenuItems', () => {
});
it('returns empty array if no actions have been registered', async () => {
const items = getAddPanelActionMenuItems([], jest.fn(), jest.fn(), jest.fn());
const items = getAddPanelActionMenuItems(getMockPresentationContainer(), [], jest.fn());
expect(items).toStrictEqual([]);
});
});

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { ActionExecutionContext, Action } from '@kbn/ui-actions-plugin/public';
import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { PresentationContainer } from '@kbn/presentation-containers';
import { addPanelMenuTrigger } from '../../triggers';
const onAddPanelActionClick =
@ -27,16 +27,14 @@ const onAddPanelActionClick =
};
export const getAddPanelActionMenuItems = (
api: PresentationContainer,
actions: Array<Action<object>> | undefined,
createNewEmbeddable: (embeddableFactory: EmbeddableFactory) => void,
deleteEmbeddable: (embeddableId: string) => void,
closePopover: () => void
) => {
return (
actions?.map((item) => {
const context = {
createNewEmbeddable,
deleteEmbeddable,
embeddable: api,
trigger: addPanelMenuTrigger,
};
const actionName = item.getDisplayName(context);

View file

@ -11,9 +11,7 @@ import { METRIC_TYPE } from '@kbn/analytics';
import { useEuiTheme } from '@elastic/eui';
import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { EmbeddableFactory, EmbeddableInput } from '@kbn/embeddable-plugin/public';
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { isExplicitInputWithAttributes } from '@kbn/embeddable-plugin/public';
import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings';
import { EditorMenu } from './editor_menu';
@ -21,13 +19,11 @@ import { useDashboardAPI } from '../dashboard_app';
import { pluginServices } from '../../services/plugin_services';
import { ControlsToolbarButton } from './controls_toolbar_button';
import { DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants';
import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }) {
const {
usageCollection,
data: { search },
notifications: { toasts },
embeddable: { getStateTransfer },
visualizations: { getAliases: getVisTypeAliases },
} = pluginServices.getServices();
@ -85,74 +81,9 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
* initialInput: Optional, use it in case you want to pass your own input to the factory
* dismissNotification: Optional, if not passed a toast will appear in the dashboard
*/
const createNewEmbeddable = useCallback(
async (
embeddableFactory: EmbeddableFactory,
initialInput?: Partial<EmbeddableInput>,
dismissNotification?: boolean
) => {
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, embeddableFactory.type);
}
let explicitInput: Partial<EmbeddableInput>;
let attributes: unknown;
try {
if (initialInput) {
explicitInput = initialInput;
} else {
const explicitInputReturn = await embeddableFactory.getExplicitInput(
undefined,
dashboard
);
if (isExplicitInputWithAttributes(explicitInputReturn)) {
explicitInput = explicitInputReturn.newInput;
attributes = explicitInputReturn.attributes;
} else {
explicitInput = explicitInputReturn;
}
}
} catch (e) {
// error likely means user canceled embeddable creation
return;
}
const newEmbeddable = await dashboard.addNewEmbeddable(
embeddableFactory.type,
explicitInput,
attributes
);
if (newEmbeddable) {
dashboard.setScrollToPanelId(newEmbeddable.id);
dashboard.setHighlightPanelId(newEmbeddable.id);
if (!dismissNotification) {
toasts.addSuccess({
title: dashboardReplacePanelActionStrings.getSuccessMessage(newEmbeddable.getTitle()),
'data-test-subj': 'addEmbeddableToDashboardSuccess',
});
}
}
return newEmbeddable;
},
[trackUiMetric, dashboard, toasts]
);
const deleteEmbeddable = useCallback(
(embeddableId: string) => {
dashboard.removeEmbeddable(embeddableId);
},
[dashboard]
);
const extraButtons = [
<EditorMenu
createNewVisType={createNewVisType}
createNewEmbeddable={createNewEmbeddable}
deleteEmbeddable={deleteEmbeddable}
isDisabled={isDisabled}
/>,
<EditorMenu createNewVisType={createNewVisType} isDisabled={isDisabled} api={dashboard} />,
<AddFromLibraryButton
onClick={() => dashboard.addFromLibrary()}
size="s"

View file

@ -17,25 +17,15 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ToolbarPopover } from '@kbn/shared-ux-button-toolbar';
import type { Action } from '@kbn/ui-actions-plugin/public';
import { ToolbarPopover } from '@kbn/shared-ux-button-toolbar';
import { PresentationContainer } from '@kbn/presentation-containers';
import { type BaseVisType, VisGroups, type VisTypeAlias } from '@kbn/visualizations-plugin/public';
import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { pluginServices } from '../../services/plugin_services';
import { DASHBOARD_APP_ID } from '../../dashboard_constants';
import { ADD_PANEL_TRIGGER } from '../../triggers';
import { getAddPanelActionMenuItems } from './add_panel_action_menu_items';
interface Props {
isDisabled?: boolean;
/** Handler for creating new visualization of a specified type */
createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void;
/** Handler for creating a new embeddable of a specified type */
createNewEmbeddable: (embeddableFactory: EmbeddableFactory) => void;
/** Handler for deleting an embeddable */
deleteEmbeddable: (embeddableId: string) => void;
}
interface FactoryGroup {
id: string;
appName: string;
@ -51,10 +41,14 @@ interface UnwrappedEmbeddableFactory {
export const EditorMenu = ({
createNewVisType,
createNewEmbeddable,
deleteEmbeddable,
isDisabled,
}: Props) => {
api,
}: {
api: PresentationContainer;
isDisabled?: boolean;
/** Handler for creating new visualization of a specified type */
createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void;
}) => {
const isMounted = useRef(false);
const {
embeddable,
@ -148,16 +142,15 @@ export const EditorMenu = ({
// Retrieve ADD_PANEL_TRIGGER actions
useEffect(() => {
async function loadPanelActions() {
const registeredActions = await uiActions?.getTriggerCompatibleActions?.(
ADD_PANEL_TRIGGER,
{}
);
const registeredActions = await uiActions?.getTriggerCompatibleActions?.(ADD_PANEL_TRIGGER, {
embeddable: api,
});
if (isMounted.current) {
setAddPanelActions(registeredActions);
}
}
loadPanelActions();
}, [uiActions]);
}, [uiActions, api]);
factories.forEach(({ factory }) => {
const { grouping } = factory;
@ -249,7 +242,7 @@ export const EditorMenu = ({
toolTipContent,
onClick: async () => {
closePopover();
createNewEmbeddable(factory);
api.addNewPanel({ panelType: factory.type }, true);
},
'data-test-subj': `createNew-${factory.type}`,
};
@ -274,12 +267,7 @@ export const EditorMenu = ({
})),
...promotedVisTypes.map(getVisTypeMenuItem),
...getAddPanelActionMenuItems(
addPanelActions,
createNewEmbeddable,
deleteEmbeddable,
closePopover
),
...getAddPanelActionMenuItems(api, addPanelActions, closePopover),
];
if (aggsBasedVisTypes.length > 0) {
initialPanelItems.push({

View file

@ -187,7 +187,8 @@ export async function runQuickSave(this: DashboardContainer) {
if (managed) return;
const nextPanels = await serializeAllPanelState(this);
let stateToSave: SavedDashboardInput = { ...currentState, panels: nextPanels };
const dashboardStateToSave: DashboardContainerInput = { ...currentState, panels: nextPanels };
let stateToSave: SavedDashboardInput = dashboardStateToSave;
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
if (this.controlGroup) {
persistableControlGroupInput = this.controlGroup.getPersistableInput();
@ -200,7 +201,7 @@ export async function runQuickSave(this: DashboardContainer) {
saveOptions: {},
});
this.dispatch.setLastSavedInput(currentState);
this.dispatch.setLastSavedInput(dashboardStateToSave);
this.lastSavedState.next();
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput);

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { v4 } from 'uuid';
import { omit } from 'lodash';
import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
@ -20,6 +21,8 @@ import type { DataView } from '@kbn/data-views-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import {
Container,
EmbeddableFactoryNotFoundError,
isExplicitInputWithAttributes,
DefaultEmbeddableApi,
PanelNotFoundError,
ReactEmbeddableParentContext,
@ -30,8 +33,9 @@ import {
type EmbeddableOutput,
type IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { METRIC_TYPE } from '@kbn/analytics';
import { I18nProvider } from '@kbn/i18n-react';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { PanelPackage } from '@kbn/presentation-containers';
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
@ -40,7 +44,13 @@ import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-f
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..';
import { DashboardContainerInput, DashboardPanelState } from '../../../common';
import { DASHBOARD_APP_ID, DASHBOARD_LOADED_EVENT } from '../../dashboard_constants';
import {
DASHBOARD_APP_ID,
DASHBOARD_LOADED_EVENT,
DASHBOARD_UI_METRIC_ID,
DEFAULT_PANEL_HEIGHT,
DEFAULT_PANEL_WIDTH,
} from '../../dashboard_constants';
import { DashboardAnalyticsService } from '../../services/analytics/types';
import { DashboardCapabilitiesService } from '../../services/dashboard_capabilities/types';
import { pluginServices } from '../../services/plugin_services';
@ -70,6 +80,8 @@ import {
dashboardTypeDisplayLowercase,
dashboardTypeDisplayName,
} from './dashboard_container_factory';
import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
import { panelPlacementStrategies } from '../component/panel_placement/place_new_panel_strategies';
export interface InheritedChildInput {
filters: Filter[];
@ -142,6 +154,9 @@ export class DashboardContainer
private chrome;
private customBranding;
private trackPanelAddMetric:
| ((type: string, eventNames: string | string[], count?: number | undefined) => void)
| undefined;
// new embeddable framework
public reactEmbeddableChildren: BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }> =
new BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }>({});
@ -156,9 +171,9 @@ export class DashboardContainer
initialComponentState?: DashboardPublicState
) {
const {
usageCollection,
embeddable: { getEmbeddableFactory },
} = pluginServices.getServices();
super(
{
...initialInput,
@ -168,6 +183,11 @@ export class DashboardContainer
parent
);
this.trackPanelAddMetric = usageCollection.reportUiCounter?.bind(
usageCollection,
DASHBOARD_UI_METRIC_ID
);
({
analytics: this.analyticsService,
settings: {
@ -396,6 +416,88 @@ export class DashboardContainer
return newId;
}
public async addNewPanel<ApiType extends unknown = unknown>(
panelPackage: PanelPackage,
displaySuccessMessage?: boolean
) {
const {
notifications: { toasts },
embeddable: { getEmbeddableFactory },
} = pluginServices.getServices();
const onSuccess = (id?: string, title?: string) => {
if (!displaySuccessMessage) return;
toasts.addSuccess({
title: dashboardReplacePanelActionStrings.getSuccessMessage(title),
'data-test-subj': 'addEmbeddableToDashboardSuccess',
});
this.setScrollToPanelId(id);
this.setHighlightPanelId(id);
};
if (this.trackPanelAddMetric) {
this.trackPanelAddMetric(METRIC_TYPE.CLICK, panelPackage.panelType);
}
if (reactEmbeddableRegistryHasKey(panelPackage.panelType)) {
const newId = v4();
const { newPanelPlacement, otherPanels } = panelPlacementStrategies.findTopLeftMostOpenSpace({
currentPanels: this.getInput().panels,
height: DEFAULT_PANEL_HEIGHT,
width: DEFAULT_PANEL_WIDTH,
});
const newPanel: DashboardPanelState = {
type: panelPackage.panelType,
gridData: {
...newPanelPlacement,
i: newId,
},
explicitInput: {
...panelPackage.initialState,
id: newId,
},
};
this.updateInput({ panels: { ...otherPanels, [newId]: newPanel } });
onSuccess(newId, newPanel.explicitInput.title);
return;
}
const embeddableFactory = getEmbeddableFactory(panelPackage.panelType);
if (!embeddableFactory) {
throw new EmbeddableFactoryNotFoundError(panelPackage.panelType);
}
const initialInput = panelPackage.initialState as Partial<EmbeddableInput>;
let explicitInput: Partial<EmbeddableInput>;
let attributes: unknown;
try {
if (initialInput) {
explicitInput = initialInput;
} else {
const explicitInputReturn = await embeddableFactory.getExplicitInput(undefined, this);
if (isExplicitInputWithAttributes(explicitInputReturn)) {
explicitInput = explicitInputReturn.newInput;
attributes = explicitInputReturn.attributes;
} else {
explicitInput = explicitInputReturn;
}
}
} catch (e) {
// error likely means user canceled embeddable creation
return;
}
const newEmbeddable = await this.addNewEmbeddable(
embeddableFactory.type,
explicitInput,
attributes
);
if (newEmbeddable) {
onSuccess(newEmbeddable.id, newEmbeddable.getTitle());
}
return newEmbeddable as ApiType;
}
public getDashboardPanelFromId = async (panelId: string) => {
const panel = this.getInput().panels[panelId];
if (reactEmbeddableRegistryHasKey(panel.type)) {

View file

@ -131,6 +131,16 @@ export abstract class Container<
this.removeEmbeddable(id);
}
public async addNewPanel<ApiType extends unknown = unknown>(
panelPackage: PanelPackage
): Promise<ApiType | undefined> {
const newEmbeddable = await this.addNewEmbeddable(
panelPackage.panelType,
panelPackage.initialState as Partial<EmbeddableInput>
);
return newEmbeddable as ApiType;
}
public async replacePanel(idToRemove: string, { panelType, initialState }: PanelPackage) {
return await this.replaceEmbeddable(
idToRemove,

View file

@ -8,16 +8,20 @@ import type { CoreStart } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import type { LensPluginStartDependencies } from '../../plugin';
import { createMockStartDependencies } from '../../editor_frame_service/mocks';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { CreateESQLPanelAction } from './create_action';
describe('create Lens panel action', () => {
const core = coreMock.createStart();
const mockStartDependencies =
createMockStartDependencies() as unknown as LensPluginStartDependencies;
const mockPresentationContainer = getMockPresentationContainer();
describe('compatibility check', () => {
it('is incompatible if ui setting for ES|QL is off', async () => {
const configurablePanelAction = new CreateESQLPanelAction(mockStartDependencies, core);
const isCompatible = await configurablePanelAction.isCompatible();
const isCompatible = await configurablePanelAction.isCompatible({
embeddable: mockPresentationContainer,
});
expect(isCompatible).toBeFalsy();
});
@ -33,7 +37,9 @@ describe('create Lens panel action', () => {
},
} as CoreStart;
const createESQLAction = new CreateESQLPanelAction(mockStartDependencies, updatedCore);
const isCompatible = await createESQLAction.isCompatible();
const isCompatible = await createESQLAction.isCompatible({
embeddable: mockPresentationContainer,
});
expect(isCompatible).toBeTruthy();
});

View file

@ -6,29 +6,16 @@
*/
import { i18n } from '@kbn/i18n';
import type { CoreStart } from '@kbn/core/public';
import { Action } from '@kbn/ui-actions-plugin/public';
import type {
EmbeddableFactory,
EmbeddableInput,
IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import type { LensPluginStartDependencies } from '../../plugin';
const ACTION_CREATE_ESQL_CHART = 'ACTION_CREATE_ESQL_CHART';
interface Context {
createNewEmbeddable: (
embeddableFactory: EmbeddableFactory,
initialInput?: Partial<EmbeddableInput>,
dismissNotification?: boolean
) => Promise<undefined | IEmbeddable>;
deleteEmbeddable: (embeddableId: string) => void;
initialInput?: Partial<EmbeddableInput>;
}
export const getAsyncHelpers = async () => await import('../../async_services');
export class CreateESQLPanelAction implements Action<Context> {
export class CreateESQLPanelAction implements Action<EmbeddableApiContext> {
public type = ACTION_CREATE_ESQL_CHART;
public id = ACTION_CREATE_ESQL_CHART;
public order = 50;
@ -49,19 +36,20 @@ export class CreateESQLPanelAction implements Action<Context> {
return 'esqlVis';
}
public async isCompatible() {
public async isCompatible({ embeddable }: EmbeddableApiContext) {
if (!apiIsPresentationContainer(embeddable)) return false;
// compatible only when ES|QL advanced setting is enabled
const { isCreateActionCompatible } = await getAsyncHelpers();
return isCreateActionCompatible(this.core);
}
public async execute({ createNewEmbeddable, deleteEmbeddable }: Context) {
public async execute({ embeddable }: EmbeddableApiContext) {
if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
const { executeCreateAction } = await getAsyncHelpers();
executeCreateAction({
deps: this.startDependencies,
core: this.core,
createNewEmbeddable,
deleteEmbeddable,
api: embeddable,
});
}
}

View file

@ -6,13 +6,9 @@
*/
import { createGetterSetter } from '@kbn/kibana-utils-plugin/common';
import type { CoreStart } from '@kbn/core/public';
import type {
EmbeddableFactory,
EmbeddableInput,
IEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils';
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { PresentationContainer } from '@kbn/presentation-containers';
import { getESQLAdHocDataview, getIndexForESQLQuery } from '@kbn/esql-utils';
import type { Datasource, Visualization } from '../../types';
import type { LensPluginStartDependencies } from '../../plugin';
@ -20,6 +16,7 @@ import { fetchDataFromAggregateQuery } from '../../datasources/text_based/fetch_
import { suggestionsApi } from '../../lens_suggestions_api';
import { generateId } from '../../id_generator';
import { executeEditAction } from './edit_action_helpers';
import { Embeddable } from '../../embeddable';
// datasourceMap and visualizationMap setters/getters
export const [getVisualizationMap, setVisualizationMap] = createGetterSetter<
@ -37,17 +34,11 @@ export function isCreateActionCompatible(core: CoreStart) {
export async function executeCreateAction({
deps,
core,
createNewEmbeddable,
deleteEmbeddable,
api,
}: {
deps: LensPluginStartDependencies;
core: CoreStart;
createNewEmbeddable: (
embeddableFactory: EmbeddableFactory,
initialInput?: Partial<EmbeddableInput>,
dismissNotification?: boolean
) => Promise<undefined | IEmbeddable>;
deleteEmbeddable: (embeddableId: string) => void;
api: PresentationContainer;
}) {
const isCompatibleAction = isCreateActionCompatible(core);
const defaultDataView = await deps.dataViews.getDefaultDataView({
@ -110,20 +101,17 @@ export async function executeCreateAction({
dataView,
});
const input = {
attributes: attrs,
id: generateId(),
};
const embeddableStart = deps.embeddable;
const factory = embeddableStart.getEmbeddableFactory('lens');
if (!factory) {
return undefined;
}
const embeddable = await createNewEmbeddable(factory, input, true);
const embeddable = await api.addNewPanel<Embeddable>({
panelType: 'lens',
initialState: {
attributes: attrs,
id: generateId(),
},
});
// open the flyout if embeddable has been created successfully
if (embeddable) {
const deletePanel = () => {
deleteEmbeddable(embeddable.id);
api.removePanel(embeddable.id);
};
executeEditAction({