[Embeddable] Refactor embeddable panel (#159837)

Update the Embeddable panel and all sub-components to be react function components & removes the embeddable panel HOC in favour of a direct import.
This commit is contained in:
Devon Thomson 2023-07-17 12:14:31 -04:00 committed by GitHub
parent 48ec52b202
commit a1be033734
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 2255 additions and 3296 deletions

View file

@ -17,7 +17,7 @@ export class ListContainer extends Container<{}, ContainerInput> {
public readonly type = LIST_CONTAINER;
private node?: HTMLElement;
constructor(input: ContainerInput, private embeddableServices: EmbeddableStart) {
constructor(input: ContainerInput, embeddableServices: EmbeddableStart) {
super(input, { embeddableLoaded: {} }, embeddableServices.getEmbeddableFactory);
}
@ -32,10 +32,7 @@ export class ListContainer extends Container<{}, ContainerInput> {
ReactDOM.unmountComponentAtNode(this.node);
}
this.node = node;
ReactDOM.render(
<ListContainerComponent embeddable={this} embeddableServices={this.embeddableServices} />,
node
);
ReactDOM.render(<ListContainerComponent embeddable={this} />, node);
}
public destroy() {

View file

@ -14,22 +14,16 @@ import {
withEmbeddableSubscription,
ContainerInput,
ContainerOutput,
EmbeddableStart,
EmbeddableChildPanel,
EmbeddablePanel,
} from '@kbn/embeddable-plugin/public';
interface Props {
embeddable: IContainer;
input: ContainerInput;
output: ContainerOutput;
embeddableServices: EmbeddableStart;
}
function renderList(
embeddable: IContainer,
panels: ContainerInput['panels'],
embeddableServices: EmbeddableStart
) {
function renderList(embeddable: IContainer, panels: ContainerInput['panels']) {
let number = 0;
const list = Object.values(panels).map((panel) => {
number++;
@ -42,10 +36,8 @@ function renderList(
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EmbeddableChildPanel
PanelComponent={embeddableServices.EmbeddablePanel}
embeddableId={panel.explicitInput.id}
container={embeddable}
<EmbeddablePanel
embeddable={() => embeddable.untilEmbeddableLoaded(panel.explicitInput.id)}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -55,12 +47,12 @@ function renderList(
return list;
}
export function ListContainerComponentInner({ embeddable, input, embeddableServices }: Props) {
export function ListContainerComponentInner({ embeddable, input }: Props) {
return (
<div>
<h2 data-test-subj="listContainerTitle">{embeddable.getTitle()}</h2>
<EuiSpacer size="l" />
{renderList(embeddable, input.panels, embeddableServices)}
{renderList(embeddable, input.panels)}
</div>
);
}
@ -73,6 +65,5 @@ export function ListContainerComponentInner({ embeddable, input, embeddableServi
export const ListContainerComponent = withEmbeddableSubscription<
ContainerInput,
ContainerOutput,
IContainer,
{ embeddableServices: EmbeddableStart }
IContainer
>(ListContainerComponentInner);

View file

@ -25,6 +25,8 @@ import {
ContainerOutput,
EmbeddableOutput,
EmbeddableStart,
EmbeddablePanel,
openAddPanelFlyout,
} from '@kbn/embeddable-plugin/public';
import { SearchableListContainer, SearchableContainerInput } from './searchable_list_container';
@ -120,7 +122,7 @@ export class SearchableListContainerComponentInner extends Component<Props, Stat
};
public renderControls() {
const { input } = this.props;
const { input, embeddable } = this.props;
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
@ -150,6 +152,17 @@ export class SearchableListContainerComponentInner extends Component<Props, Stat
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow hasEmptyLabelSpace>
<EuiButton
data-test-subj="addPanelToListContainer"
disabled={input.search === ''}
onClick={() => openAddPanelFlyout({ container: embeddable })}
>
Add panel
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem />
</EuiFlexGroup>
);
@ -171,7 +184,7 @@ export class SearchableListContainerComponentInner extends Component<Props, Stat
}
private renderList() {
const { embeddableServices, input, embeddable } = this.props;
const { input, embeddable } = this.props;
let id = 0;
const list = Object.values(input.panels).map((panel) => {
const childEmbeddable = embeddable.getChild(panel.explicitInput.id);
@ -189,7 +202,7 @@ export class SearchableListContainerComponentInner extends Component<Props, Stat
/>
</EuiFlexItem>
<EuiFlexItem>
<embeddableServices.EmbeddablePanel embeddable={childEmbeddable} />
<EmbeddablePanel embeddable={childEmbeddable} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -112,7 +112,6 @@ const EmbeddableExplorerApp = ({
id: 'embeddablePanelExample',
component: (
<EmbeddablePanelExample
embeddableServices={embeddableApi}
searchListContainerFactory={embeddableExamples.factories.getSearchableListContainerEmbeddableFactory()}
/>
),

View file

@ -9,7 +9,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { EuiPanel, EuiText, EuiPageTemplate } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { EmbeddableStart, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { IEmbeddable, EmbeddablePanel } from '@kbn/embeddable-plugin/public';
import {
HELLO_WORLD_EMBEDDABLE,
TODO_EMBEDDABLE,
@ -19,11 +19,10 @@ import {
} from '@kbn/embeddable-examples-plugin/public';
interface Props {
embeddableServices: EmbeddableStart;
searchListContainerFactory: SearchableListContainerFactory;
}
export function EmbeddablePanelExample({ embeddableServices, searchListContainerFactory }: Props) {
export function EmbeddablePanelExample({ searchListContainerFactory }: Props) {
const searchableInput = {
id: '1',
title: 'My searchable todo list',
@ -120,7 +119,7 @@ export function EmbeddablePanelExample({ embeddableServices, searchListContainer
</EuiText>
<EuiPanel data-test-subj="embeddedPanelExample" paddingSize="none" role="figure">
{embeddable ? (
<embeddableServices.EmbeddablePanel embeddable={embeddable} />
<EmbeddablePanel embeddable={embeddable} />
) : (
<EuiText>Loading...</EuiText>
)}

View file

@ -10,11 +10,7 @@ import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { EuiLoadingChart } from '@elastic/eui';
import classNames from 'classnames';
import {
EmbeddableChildPanel,
EmbeddablePhaseEvent,
ViewMode,
} from '@kbn/embeddable-plugin/public';
import { EmbeddablePhaseEvent, EmbeddablePanel, ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardPanelState } from '../../../../common';
import { pluginServices } from '../../../services/plugin_services';
@ -52,9 +48,6 @@ const Item = React.forwardRef<HTMLDivElement, Props>(
},
ref
) => {
const {
embeddable: { EmbeddablePanel: PanelComponent },
} = pluginServices.getServices();
const container = useDashboardContainer();
const scrollToPanelId = container.select((state) => state.componentState.scrollToPanelId);
const highlightPanelId = container.select((state) => state.componentState.highlightPanelId);
@ -90,13 +83,13 @@ const Item = React.forwardRef<HTMLDivElement, Props>(
>
{isRenderable ? (
<>
<EmbeddableChildPanel
// This key is used to force rerendering on embeddable type change while the id remains the same
<EmbeddablePanel
key={type}
embeddableId={id}
index={index}
showBadges={true}
showNotifications={true}
onPanelStatusChange={onPanelStatusChange}
{...{ container, PanelComponent }}
embeddable={() => container.untilEmbeddableLoaded(id)}
/>
{children}
</>

View file

@ -6,41 +6,14 @@
* Side Public License, v 1.
*/
import { HttpStart } from '@kbn/core/public';
import { isErrorEmbeddable, openAddPanelFlyout } from '@kbn/embeddable-plugin/public';
import { getSavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public';
import { pluginServices } from '../../../services/plugin_services';
import { DashboardContainer } from '../dashboard_container';
export function addFromLibrary(this: DashboardContainer) {
const {
overlays,
notifications,
usageCollection,
settings: { uiSettings, theme },
embeddable: { getEmbeddableFactories, getEmbeddableFactory },
http,
savedObjectsManagement,
savedObjectsTagging,
} = pluginServices.getServices();
if (isErrorEmbeddable(this)) return;
this.openOverlay(
openAddPanelFlyout({
SavedObjectFinder: getSavedObjectFinder(
uiSettings,
http as HttpStart,
savedObjectsManagement,
savedObjectsTagging.api
),
reportUiCounter: usageCollection.reportUiCounter,
getAllFactories: getEmbeddableFactories,
getFactory: getEmbeddableFactory,
embeddable: this,
notifications,
overlays,
theme,
container: this,
onAddPanel: (id: string) => {
this.setScrollToPanelId(id);
this.setHighlightPanelId(id);

View file

@ -7,47 +7,40 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { mount, ReactWrapper } from 'enzyme';
import type { TimeRange } from '@kbn/es-query';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
import { findTestSubject, nextTick } from '@kbn/test-jest-helpers';
import { I18nProvider } from '@kbn/i18n-react';
import {
CONTEXT_MENU_TRIGGER,
ViewMode,
EmbeddablePanel,
isErrorEmbeddable,
ViewMode,
CONTEXT_MENU_TRIGGER,
} from '@kbn/embeddable-plugin/public';
import {
EMPTY_EMBEDDABLE,
ContactCardEmbeddable,
ContactCardEmbeddableFactory,
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
CONTACT_CARD_EMBEDDABLE,
EMPTY_EMBEDDABLE,
ContactCardEmbeddableFactory,
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
import { applicationServiceMock, coreMock } from '@kbn/core/public/mocks';
import { I18nProvider } from '@kbn/i18n-react';
import type { TimeRange } from '@kbn/es-query';
import { findTestSubject, nextTick } from '@kbn/test-jest-helpers';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { setStubKibanaServices } from '@kbn/embeddable-plugin/public/mocks';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
import { createEditModeActionDefinition } from '@kbn/embeddable-plugin/public/lib/test_samples';
import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks';
import { pluginServices } from '../../services/plugin_services';
import { ApplicationStart } from '@kbn/core-application-browser';
import { DashboardContainer } from './dashboard_container';
const theme = coreMock.createStart().theme;
let application: ApplicationStart | undefined;
import { pluginServices } from '../../services/plugin_services';
import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks';
const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(embeddableFactory);
beforeEach(() => {
application = applicationServiceMock.createStartContract();
});
test('DashboardContainer initializes embeddables', (done) => {
const container = buildMockDashboard({
panels: {
@ -191,6 +184,8 @@ test('searchSessionId propagates to children', async () => {
});
test('DashboardContainer in edit mode shows edit mode actions', async () => {
// mock embeddable dependencies so that the embeddable panel renders
setStubKibanaServices();
const uiActionsSetup = uiActionsPluginMock.createSetupContract();
const editModeAction = createEditModeActionDefinition();
@ -207,29 +202,26 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
firstName: 'Bob',
});
const DashboardServicesProvider = pluginServices.getContextProvider();
const component = mount(
<I18nProvider>
<DashboardServicesProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={(() => []) as any}
getEmbeddableFactory={(() => null) as any}
notifications={{} as any}
application={application}
SavedObjectFinder={() => null}
theme={theme}
/>
</DashboardServicesProvider>
</I18nProvider>
);
let wrapper: ReactWrapper;
await act(async () => {
wrapper = await mount(
<I18nProvider>
<EmbeddablePanel embeddable={embeddable} />
</I18nProvider>
);
});
const component = wrapper!;
await component.update();
await nextTick();
const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon');
expect(button.length).toBe(1);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
act(() => {
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
});
await nextTick();
await component.update();
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
@ -237,16 +229,26 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
expect(editAction.length).toBe(0);
container.updateInput({ viewMode: ViewMode.EDIT });
await nextTick();
component.update();
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
act(() => {
container.updateInput({ viewMode: ViewMode.EDIT });
});
await nextTick();
await component.update();
act(() => {
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
});
await nextTick();
component.update();
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(0);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
act(() => {
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
});
await nextTick();
component.update();
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1);
await nextTick();

View file

@ -77,9 +77,9 @@ export const isKeyEqual = (
*/
export const unsavedChangesDiffingFunctions: DashboardDiffFunctions = {
panels: async ({ currentValue, lastValue, container }) => {
if (!getPanelLayoutsAreEqual(currentValue, lastValue)) return false;
if (!getPanelLayoutsAreEqual(currentValue ?? {}, lastValue ?? {})) return false;
const explicitInputComparePromises = Object.values(currentValue).map(
const explicitInputComparePromises = Object.values(currentValue ?? {}).map(
(panel) =>
new Promise<boolean>((resolve, reject) => {
const embeddableId = panel.explicitInput.id;
@ -111,8 +111,8 @@ export const unsavedChangesDiffingFunctions: DashboardDiffFunctions = {
// exclude pinned filters from comparision because pinned filters are not part of application state
filters: ({ currentValue, lastValue }) =>
compareFilters(
currentValue.filter((f) => !isFilterPinned(f)),
lastValue.filter((f) => !isFilterPinned(f)),
(currentValue ?? []).filter((f) => !isFilterPinned(f)),
(lastValue ?? []).filter((f) => !isFilterPinned(f)),
COMPARE_ALL_OPTIONS
),

View file

@ -20,7 +20,6 @@ export const embeddableServiceFactory: EmbeddableServiceFactory = () => {
getEmbeddableFactory: pluginMock.getEmbeddableFactory,
getStateTransfer: pluginMock.getStateTransfer,
getAllMigrations: pluginMock.getAllMigrations,
EmbeddablePanel: pluginMock.EmbeddablePanel,
telemetry: pluginMock.telemetry,
extract: pluginMock.extract,
inject: pluginMock.inject,

View file

@ -24,7 +24,6 @@ export const embeddableServiceFactory: EmbeddableServiceFactory = ({ startPlugin
'getEmbeddableFactory',
'getEmbeddableFactories',
'getStateTransfer',
'EmbeddablePanel',
'getAllMigrations',
'telemetry',
'extract',

View file

@ -14,7 +14,6 @@ export type DashboardEmbeddableService = Pick<
| 'getEmbeddableFactory'
| 'getAllMigrations'
| 'getStateTransfer'
| 'EmbeddablePanel'
| 'telemetry'
| 'extract'
| 'inject'

View file

@ -470,7 +470,6 @@ This can ne achieved in two ways by implementing one of the following:
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.
- `EmbeddableChildPanel` is a higher-order component for the `EmbeddablePanel` that provides a way to render that inside a container.
- `EmbeddableRoot` is the most straightforward wrapper performing rendering of an embeddable.
- `EmbeddableRenderer` is a helper component to render an embeddable or an embeddable factory.

View file

@ -12,5 +12,6 @@ module.exports = {
roots: ['<rootDir>/src/plugins/embeddable'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/embeddable',
coverageReporters: ['text', 'html'],
setupFiles: ['<rootDir>/src/plugins/embeddable/jest_setup.ts'],
collectCoverageFrom: ['<rootDir>/src/plugins/embeddable/{common,public,server}/**/*.{ts,tsx}'],
};

View file

@ -6,5 +6,5 @@
* Side Public License, v 1.
*/
export * from './embeddable_panel';
export * from './panel_header';
import { setStubKibanaServices } from './public/mocks';
setStubKibanaServices();

View file

@ -14,14 +14,8 @@
"savedObjectsFinder",
"savedObjectsManagement"
],
"optionalPlugins": ["savedObjectsTaggingOss"],
"requiredBundles": [
"savedObjects",
"kibanaReact",
"kibanaUtils"
],
"extraPublicDirs": [
"common"
]
"optionalPlugins": ["savedObjectsTaggingOss", "usageCollection"],
"requiredBundles": ["savedObjects", "kibanaReact", "kibanaUtils"],
"extraPublicDirs": ["common"]
}
}

View file

@ -113,7 +113,6 @@ const HelloWorldEmbeddablePanel = forwardRef<
getActions={getActions}
hideHeader={hideHeader}
showShadow={showShadow}
theme={{ theme$ }}
/>
);
}

View file

@ -0,0 +1,119 @@
/*
* 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 * as React from 'react';
import { EuiFlyout } from '@elastic/eui';
import { coreMock } from '@kbn/core/public/mocks';
import { mountWithIntl as mount } from '@kbn/test-jest-helpers';
import { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common';
import { AddPanelFlyout } from './add_panel_flyout';
import { core, embeddableStart, usageCollection } from '../kibana_services';
import { HelloWorldContainer, ContactCardEmbeddableFactory } from '../lib/test_samples';
// Mock saved objects finder component so we can call the onChoose method.
jest.mock('@kbn/saved-objects-finder-plugin/public', () => {
return {
SavedObjectFinder: jest
.fn()
.mockImplementation(
({
onChoose,
}: {
onChoose: (id: string, type: string, name: string, so: unknown) => Promise<void>;
}) => (
<button
id="soFinderDummyButton"
onClick={() =>
onChoose?.(
'testId',
'CONTACT_CARD_EMBEDDABLE',
'test name',
{} as unknown as SavedObjectCommon<unknown>
)
}
>
Dummy Button!
</button>
)
),
};
});
describe('add panel flyout', () => {
let container: HelloWorldContainer;
beforeEach(() => {
const { overlays } = coreMock.createStart();
const contactCardEmbeddableFactory = new ContactCardEmbeddableFactory(
(() => null) as any,
overlays
);
embeddableStart.getEmbeddableFactories = jest
.fn()
.mockReturnValue([contactCardEmbeddableFactory]);
container = new HelloWorldContainer(
{
id: '1',
panels: {},
},
{
getEmbeddableFactory: embeddableStart.getEmbeddableFactory,
}
);
container.addNewEmbeddable = jest.fn();
});
test('add panel flyout renders SavedObjectFinder', async () => {
const component = mount(<AddPanelFlyout container={container} />);
// https://github.com/elastic/kibana/issues/64789
expect(component.exists(EuiFlyout)).toBe(false);
expect(component.find('#soFinderDummyButton').length).toBe(1);
});
test('add panel adds embeddable to container', async () => {
const component = mount(<AddPanelFlyout container={container} />);
expect(Object.values(container.getInput().panels).length).toBe(0);
component.find('#soFinderDummyButton').simulate('click');
// flush promises
await new Promise((r) => setTimeout(r, 1));
expect(container.addNewEmbeddable).toHaveBeenCalled();
});
test('shows a success toast on add', async () => {
const component = mount(<AddPanelFlyout container={container} />);
component.find('#soFinderDummyButton').simulate('click');
// flush promises
await new Promise((r) => setTimeout(r, 1));
expect(core.notifications.toasts.addSuccess).toHaveBeenCalledWith({
'data-test-subj': 'addObjectToContainerSuccess',
title: 'test name was added',
});
});
test('runs telemetry function on add', async () => {
const component = mount(<AddPanelFlyout container={container} />);
expect(Object.values(container.getInput().panels).length).toBe(0);
component.find('#soFinderDummyButton').simulate('click');
// flush promises
await new Promise((r) => setTimeout(r, 1));
expect(usageCollection.reportUiCounter).toHaveBeenCalledWith(
'HELLO_WORLD_CONTAINER',
'click',
'CONTACT_CARD_EMBEDDABLE:add'
);
});
});

View file

@ -0,0 +1,143 @@
/*
* 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 React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { Toast } from '@kbn/core/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common';
import {
SavedObjectFinder,
SavedObjectFinderProps,
type SavedObjectMetaData,
} from '@kbn/saved-objects-finder-plugin/public';
import {
core,
embeddableStart,
usageCollection,
savedObjectsTaggingOss,
savedObjectsManagement,
} from '../kibana_services';
import {
IContainer,
EmbeddableFactory,
SavedObjectEmbeddableInput,
EmbeddableFactoryNotFoundError,
} from '../lib';
type FactoryMap = { [key: string]: EmbeddableFactory };
let lastToast: string | Toast;
const showSuccessToast = (name: string) => {
if (lastToast) core.notifications.toasts.remove(lastToast);
lastToast = core.notifications.toasts.addSuccess({
title: i18n.translate('embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle', {
defaultMessage: '{savedObjectName} was added',
values: {
savedObjectName: name,
},
}),
'data-test-subj': 'addObjectToContainerSuccess',
});
};
const runAddTelemetry = (
parentType: string,
factory: EmbeddableFactory,
savedObject: SavedObjectCommon
) => {
const type = factory.savedObjectMetaData?.getSavedObjectSubType
? factory.savedObjectMetaData.getSavedObjectSubType(savedObject)
: factory.type;
usageCollection?.reportUiCounter?.(parentType, METRIC_TYPE.CLICK, `${type}:add`);
};
export const AddPanelFlyout = ({
container,
onAddPanel,
}: {
container: IContainer;
onAddPanel?: (id: string) => void;
}) => {
const factoriesBySavedObjectType: FactoryMap = useMemo(() => {
return [...embeddableStart.getEmbeddableFactories()]
.filter((embeddableFactory) => Boolean(embeddableFactory.savedObjectMetaData?.type))
.reduce((acc, factory) => {
acc[factory.savedObjectMetaData!.type] = factory;
return acc;
}, {} as FactoryMap);
}, []);
const metaData = useMemo(
() =>
Object.values(factoriesBySavedObjectType)
.filter(
(embeddableFactory) =>
Boolean(embeddableFactory.savedObjectMetaData) && !embeddableFactory.isContainerType
)
.map(({ savedObjectMetaData }) => savedObjectMetaData as SavedObjectMetaData<unknown>),
[factoriesBySavedObjectType]
);
const onChoose: SavedObjectFinderProps['onChoose'] = useCallback(
async (
id: SavedObjectCommon['id'],
type: SavedObjectCommon['type'],
name: string,
savedObject: SavedObjectCommon
) => {
const factoryForSavedObjectType = factoriesBySavedObjectType[type];
if (!factoryForSavedObjectType) {
throw new EmbeddableFactoryNotFoundError(type);
}
const embeddable = await container.addNewEmbeddable<SavedObjectEmbeddableInput>(
factoryForSavedObjectType.type,
{ savedObjectId: id }
);
onAddPanel?.(embeddable.id);
showSuccessToast(name);
runAddTelemetry(container.type, factoryForSavedObjectType, savedObject);
},
[container, factoriesBySavedObjectType, onAddPanel]
);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
{i18n.translate('embeddableApi.addPanel.Title', { defaultMessage: 'Add from library' })}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SavedObjectFinder
services={{
http: core.http,
savedObjectsManagement,
uiSettings: core.uiSettings,
savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(),
}}
onChoose={onChoose}
savedObjectMetaData={metaData}
showFilter={true}
noItemsMessage={i18n.translate('embeddableApi.addPanel.noMatchingObjectsMessage', {
defaultMessage: 'No matching objects found.',
})}
/>
</EuiFlyoutBody>
</>
);
};

View file

@ -0,0 +1,43 @@
/*
* 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 React, { Suspense } from 'react';
import { OverlayRef } from '@kbn/core/public';
import { EuiLoadingSpinner } from '@elastic/eui';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { IContainer } from '../lib';
import { core } from '../kibana_services';
const LazyAddPanelFlyout = React.lazy(async () => {
const module = await import('./add_panel_flyout');
return { default: module.AddPanelFlyout };
});
export const openAddPanelFlyout = ({
container,
onAddPanel,
}: {
container: IContainer;
onAddPanel?: (id: string) => void;
}): OverlayRef => {
const flyoutSession = core.overlays.openFlyout(
toMountPoint(
<Suspense fallback={<EuiLoadingSpinner />}>
<LazyAddPanelFlyout container={container} onAddPanel={onAddPanel} />
</Suspense>,
{ theme$: core.theme.theme$ }
),
{
'data-test-subj': 'dashboardAddPanel',
ownFocus: true,
}
);
return flyoutSession;
};

View file

@ -1,50 +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 { action } from '@storybook/addon-actions';
import * as React from 'react';
import { PanelOptionsMenu } from '..';
export default {
title: 'components/PanelOptionsMenu',
component: PanelOptionsMenu,
argTypes: {
isViewMode: {
control: { type: 'boolean' },
},
},
decorators: [
(Story: React.ComponentType) => (
<div style={{ height: 150 }}>
<Story />
</div>
),
],
};
export function Default({ isViewMode }: React.ComponentProps<typeof PanelOptionsMenu>) {
const euiContextDescriptors = {
id: 'mainMenu',
title: 'Options',
items: [
{
name: 'Inspect',
icon: 'inspect',
onClick: action('onClick(inspect)'),
},
{
name: 'Full screen',
icon: 'expand',
onClick: action('onClick(expand)'),
},
],
};
return <PanelOptionsMenu panelDescriptor={euiContextDescriptors} isViewMode={isViewMode} />;
}
Default.args = { isViewMode: false } as React.ComponentProps<typeof PanelOptionsMenu>;

View file

@ -1,79 +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 { i18n } from '@kbn/i18n';
import React, { useState, useEffect } from 'react';
import {
EuiButtonIcon,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
} from '@elastic/eui';
export interface PanelOptionsMenuProps {
panelDescriptor?: EuiContextMenuPanelDescriptor;
close?: boolean;
isViewMode?: boolean;
title?: string;
}
export const PanelOptionsMenu: React.FC<PanelOptionsMenuProps> = ({
panelDescriptor,
close,
isViewMode,
title,
}) => {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!close) setOpen(false);
}, [close]);
const handleContextMenuClick = () => {
setOpen((isOpen) => !isOpen);
};
const handlePopoverClose = () => {
setOpen(false);
};
const enhancedAriaLabel = i18n.translate(
'embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel',
{
defaultMessage: 'Panel options for {title}',
values: { title },
}
);
const ariaLabelWithoutTitle = i18n.translate(
'embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel',
{ defaultMessage: 'Panel options' }
);
const button = (
<EuiButtonIcon
iconType={isViewMode ? 'boxesHorizontal' : 'gear'}
color="text"
className="embPanel__optionsMenuButton"
aria-label={title ? enhancedAriaLabel : ariaLabelWithoutTitle}
data-test-subj="embeddablePanelToggleMenuIcon"
onClick={handleContextMenuClick}
/>
);
return (
<EuiPopover
button={button}
isOpen={open}
closePopover={handlePopoverClose}
panelPaddingSize="none"
anchorPosition="downRight"
data-test-subj={open ? 'embeddablePanelContextMenuOpen' : 'embeddablePanelContextMenuClosed'}
>
<EuiContextMenu initialPanelId="mainMenu" panels={panelDescriptor ? [panelDescriptor] : []} />
</EuiPopover>
);
};

View file

@ -112,6 +112,10 @@
}
.embPanel__optionsMenuPopover-loading {
width: $euiSizeS * 32;
}
.embPanel__optionsMenuPopover-notification::after {
position: absolute;
top: 0;

View file

@ -0,0 +1,24 @@
/*
* 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 React from 'react';
import { EuiLoadingChart, EuiPanel } from '@elastic/eui';
export const EmbeddableLoadingIndicator = ({ showShadow }: { showShadow?: boolean }) => {
return (
<EuiPanel
role="figure"
paddingSize="none"
hasShadow={showShadow}
className={'embPanel embPanel--loading embPanel-isLoading'}
data-test-subj="embeddablePanelLoadingIndicator"
>
<EuiLoadingChart size="l" mono />
</EuiPanel>
);
};

View file

@ -0,0 +1,516 @@
/*
* 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 React from 'react';
import { act } from 'react-dom/test-utils';
import { ReactWrapper, mount } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';
import { nextTick } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { Action, UiActionsStart, ActionInternal, Trigger } from '@kbn/ui-actions-plugin/public';
import {
ContactCardEmbeddable,
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddableFactory,
CONTACT_CARD_EMBEDDABLE_REACT,
createEditModeActionDefinition,
ContactCardEmbeddableReactFactory,
HelloWorldContainer,
} from '../lib/test_samples';
import { EuiBadge } from '@elastic/eui';
import { embeddablePluginMock } from '../mocks';
import { EmbeddablePanel } from './embeddable_panel';
import { core, inspector } from '../kibana_services';
import { CONTEXT_MENU_TRIGGER, ViewMode } from '..';
import { UnwrappedEmbeddablePanelProps } from './types';
const actionRegistry = new Map<string, Action>();
const triggerRegistry = new Map<string, Trigger>();
const { setup, doStart } = embeddablePluginMock.createInstance();
const editModeAction = createEditModeActionDefinition();
const trigger: Trigger = {
id: CONTEXT_MENU_TRIGGER,
};
const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
const embeddableReactFactory = new ContactCardEmbeddableReactFactory(
(() => null) as any,
{} as any
);
actionRegistry.set(editModeAction.id, new ActionInternal(editModeAction));
triggerRegistry.set(trigger.id, trigger);
setup.registerEmbeddableFactory(embeddableFactory.type, embeddableFactory);
setup.registerEmbeddableFactory(embeddableReactFactory.type, embeddableReactFactory);
const start = doStart();
const getEmbeddableFactory = start.getEmbeddableFactory;
const renderEmbeddableInPanel = async (
props: UnwrappedEmbeddablePanelProps
): Promise<ReactWrapper> => {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = mount(
<I18nProvider>
<EmbeddablePanel {...props} />
</I18nProvider>
);
});
return wrapper!;
};
const setupContainerAndEmbeddable = async (viewMode?: ViewMode, hidePanelTitles?: boolean) => {
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: viewMode ?? ViewMode.VIEW, hidePanelTitles },
{
getEmbeddableFactory,
} as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Jack',
lastName: 'Orange',
});
return { container, embeddable };
};
const renderInEditModeAndOpenContextMenu = async (
embeddableInputs: any,
getActions: UiActionsStart['getTriggerCompatibleActions'] = () => Promise.resolve([])
) => {
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, embeddableInputs);
let component: ReactWrapper;
await act(async () => {
component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={getActions}
showNotifications={true}
showBadges={true}
/>
</I18nProvider>
);
});
findTestSubject(component!, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component!.update();
return { component: component! };
};
describe('Error states', () => {
let component: ReactWrapper<unknown>;
let embeddable: ContactCardEmbeddable;
beforeEach(async () => {
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
embeddable = (await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {})) as ContactCardEmbeddable;
await act(async () => {
component = mount(
<I18nProvider>
<EmbeddablePanel embeddable={embeddable} />
</I18nProvider>
);
});
jest.spyOn(embeddable, 'catchError');
});
test('renders a custom error', () => {
act(() => {
embeddable.triggerError(new Error('something'));
component.update();
component.mount();
});
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddable.catchError).toHaveBeenCalledWith(
new Error('something'),
expect.any(HTMLElement)
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
});
test('renders a custom fatal error', () => {
act(() => {
embeddable.triggerError(new Error('something'));
component.update();
component.mount();
});
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddable.catchError).toHaveBeenCalledWith(
new Error('something'),
expect.any(HTMLElement)
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
});
test('destroys previous error', () => {
const { catchError } = embeddable as Required<typeof embeddable>;
let destroyError: jest.MockedFunction<ReturnType<typeof catchError>>;
(embeddable.catchError as jest.MockedFunction<typeof catchError>).mockImplementationOnce(
(...args) => {
destroyError = jest.fn(catchError(...args));
return destroyError;
}
);
act(() => {
embeddable.triggerError(new Error('something'));
component.update();
component.mount();
});
act(() => {
embeddable.triggerError(new Error('another error'));
component.update();
component.mount();
});
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('another error');
expect(destroyError!).toHaveBeenCalledTimes(1);
});
test('renders a default error', async () => {
embeddable.catchError = undefined;
act(() => {
embeddable.triggerError(new Error('something'));
component.update();
component.mount();
});
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.children.length).toBeGreaterThan(0);
});
test('renders a React node', () => {
(embeddable.catchError as jest.Mock).mockReturnValueOnce(<div>Something</div>);
act(() => {
embeddable.triggerError(new Error('something'));
component.update();
component.mount();
});
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('Something');
});
});
test('Render method is called on Embeddable', async () => {
const { embeddable } = await setupContainerAndEmbeddable();
jest.spyOn(embeddable, 'render');
await renderEmbeddableInPanel({ embeddable });
expect(embeddable.render).toHaveBeenCalledTimes(1);
});
test('Actions which are disabled via disabledActions are hidden', async () => {
const action = {
id: 'FOO',
type: 'FOO',
getIconType: () => undefined,
getDisplayName: () => 'foo',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return Promise.resolve(undefined);
},
};
const getActions = () => Promise.resolve([action]);
const { component: component1 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
},
getActions
);
const { component: component2 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
disabledActions: ['FOO'],
},
getActions
);
const fooContextMenuActionItem1 = findTestSubject(component1, 'embeddablePanelAction-FOO');
const fooContextMenuActionItem2 = findTestSubject(component2, 'embeddablePanelAction-FOO');
expect(fooContextMenuActionItem1.length).toBe(1);
expect(fooContextMenuActionItem2.length).toBe(0);
});
test('Badges which are disabled via disabledActions are hidden', async () => {
const action = {
id: 'BAR',
type: 'BAR',
getIconType: () => undefined,
getDisplayName: () => 'bar',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return Promise.resolve(undefined);
},
};
const getActions = () => Promise.resolve([action]);
const { component: component1 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
},
getActions
);
const { component: component2 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
disabledActions: ['BAR'],
},
getActions
);
expect(component1.find(EuiBadge).length).toBe(1);
expect(component2.find(EuiBadge).length).toBe(0);
});
test('Edit mode actions are hidden if parent is in view mode', async () => {
const { embeddable } = await setupContainerAndEmbeddable();
const component = await renderEmbeddableInPanel({ embeddable });
await act(async () => {
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component.update();
});
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
await nextTick();
component.update();
expect(findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`).length).toBe(0);
});
test('Edit mode actions are shown in edit mode', async () => {
const { container, embeddable } = await setupContainerAndEmbeddable();
const component = await renderEmbeddableInPanel({ embeddable });
const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon');
expect(button.length).toBe(1);
await act(async () => {
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component.update();
});
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
await nextTick();
act(() => {
component.update();
});
expect(findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`).length).toBe(0);
await act(async () => {
container.updateInput({ viewMode: ViewMode.EDIT });
await nextTick();
component.update();
});
// Need to close and re-open to refresh. It doesn't update automatically.
await act(async () => {
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component.update();
});
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1);
await act(async () => {
container.updateInput({ viewMode: ViewMode.VIEW });
await nextTick();
component.update();
});
// TODO: Fix this.
// const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`);
// expect(action.length).toBe(1);
});
test('Panel title customize link does not exist in view mode', async () => {
const { embeddable } = await setupContainerAndEmbeddable(ViewMode.VIEW, false);
const component = await renderEmbeddableInPanel({ embeddable });
const titleLink = findTestSubject(component, 'embeddablePanelTitleLink');
expect(titleLink.length).toBe(0);
});
test('Runs customize panel action on title click when in edit mode', async () => {
// spy on core openFlyout to check that the flyout is opened correctly.
core.overlays.openFlyout = jest.fn();
const { embeddable } = await setupContainerAndEmbeddable(ViewMode.EDIT, false);
const component = await renderEmbeddableInPanel({ embeddable });
const titleLink = findTestSubject(component, 'embeddablePanelTitleLink');
expect(titleLink.length).toBe(1);
act(() => {
titleLink.simulate('click');
});
await nextTick();
expect(core.overlays.openFlyout).toHaveBeenCalledTimes(1);
expect(core.overlays.openFlyout).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ 'data-test-subj': 'customizePanel' })
);
});
test('Updates when hidePanelTitles is toggled', async () => {
const { container, embeddable } = await setupContainerAndEmbeddable(ViewMode.VIEW, false);
const component = await renderEmbeddableInPanel({ embeddable });
await component.update();
let title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
expect(title.length).toBe(1);
await act(async () => {
await container.updateInput({ hidePanelTitles: true });
});
await nextTick();
await component.update();
title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
expect(title.length).toBe(0);
await act(async () => {
await container.updateInput({ hidePanelTitles: false });
await nextTick();
component.update();
});
title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
expect(title.length).toBe(1);
});
test('Respects options from SelfStyledEmbeddable', async () => {
const { container, embeddable } = await setupContainerAndEmbeddable(ViewMode.VIEW, false);
const selfStyledEmbeddable = embeddablePluginMock.mockSelfStyledEmbeddable(embeddable, {
hideTitle: true,
});
// make sure the title is being hidden because of the self styling, not the container
container.updateInput({ hidePanelTitles: false });
const component = await renderEmbeddableInPanel({ embeddable: selfStyledEmbeddable });
const title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
expect(title.length).toBe(0);
});
test('Does not hide header when parent hide header option is false', async () => {
const { embeddable } = await setupContainerAndEmbeddable(ViewMode.VIEW, false);
const component = await renderEmbeddableInPanel({ embeddable });
const title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
expect(title.length).toBe(1);
});
test('Hides title when parent hide header option is true', async () => {
const { embeddable } = await setupContainerAndEmbeddable(ViewMode.VIEW, true);
const component = await renderEmbeddableInPanel({ embeddable });
const title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
expect(title.length).toBe(0);
});
test('Should work in minimal way rendering only the inspector action', async () => {
inspector.isAvailable = jest.fn(() => true);
const { embeddable } = await setupContainerAndEmbeddable(ViewMode.VIEW, true);
const component = await renderEmbeddableInPanel({ embeddable });
await act(async () => {
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component.update();
});
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
await act(async () => {
await nextTick();
component.update();
});
expect(findTestSubject(component, `embeddablePanelAction-openInspector`).length).toBe(1);
const action = findTestSubject(component, `embeddablePanelAction-ACTION_CUSTOMIZE_PANEL`);
expect(action.length).toBe(0);
});
test('Renders an embeddable returning a React node', async () => {
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE_REACT, {
firstName: 'Bran',
lastName: 'Stark',
});
const component = await renderEmbeddableInPanel({ embeddable });
expect(component.find('.embPanel__titleText').text()).toBe('Hello Bran Stark');
});

View file

@ -0,0 +1,221 @@
/*
* 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 { isNil } from 'lodash';
import classNames from 'classnames';
import { distinct, map } from 'rxjs';
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, htmlIdGenerator } from '@elastic/eui';
import { isPromise } from '@kbn/std';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import {
EditPanelAction,
RemovePanelAction,
InspectPanelAction,
CustomizePanelAction,
} from './panel_actions';
import {
EmbeddablePhase,
EmbeddablePhaseEvent,
PanelUniversalActions,
UnwrappedEmbeddablePanelProps,
} from './types';
import {
useSelectFromEmbeddableInput,
useSelectFromEmbeddableOutput,
} from './use_select_from_embeddable';
import { EmbeddablePanelError } from './embeddable_panel_error';
import { core, embeddableStart, inspector } from '../kibana_services';
import { ViewMode, EmbeddableErrorHandler, EmbeddableOutput } from '../lib';
import { EmbeddablePanelHeader } from './panel_header/embeddable_panel_header';
const getEventStatus = (output: EmbeddableOutput): EmbeddablePhase => {
if (!isNil(output.error)) {
return 'error';
} else if (output.rendered === true) {
return 'rendered';
} else if (output.loading === false) {
return 'loaded';
} else {
return 'loading';
}
};
export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => {
const {
hideHeader,
showShadow,
embeddable,
hideInspector,
containerContext,
onPanelStatusChange,
} = panelProps;
const [node, setNode] = useState<ReactNode | undefined>();
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const headerId = useMemo(() => htmlIdGenerator()(), []);
const [outputError, setOutputError] = useState<Error>();
/**
* Universal actions are exposed on the context menu for every embeddable, they
* bypass the trigger registry.
*/
const universalActions = useMemo<PanelUniversalActions>(() => {
const commonlyUsedRanges = core.uiSettings.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES);
const dateFormat = core.uiSettings.get(UI_SETTINGS.DATE_FORMAT);
const stateTransfer = embeddableStart.getStateTransfer();
const actions: PanelUniversalActions = {
customizePanel: new CustomizePanelAction(
core.overlays,
core.theme,
commonlyUsedRanges,
dateFormat
),
removePanel: new RemovePanelAction(),
editPanel: new EditPanelAction(
embeddableStart.getEmbeddableFactory,
core.application,
stateTransfer,
containerContext?.getCurrentPath
),
};
if (!hideInspector) actions.inspectPanel = new InspectPanelAction(inspector);
return actions;
}, [containerContext?.getCurrentPath, hideInspector]);
/**
* Track panel status changes
*/
useEffect(() => {
if (!onPanelStatusChange) return;
let loadingStartTime = 0;
const subscription = embeddable
.getOutput$()
.pipe(
// Map loaded event properties
map((output) => {
if (output.loading === true) {
loadingStartTime = performance.now();
}
return {
id: embeddable.id,
status: getEventStatus(output),
error: output.error,
};
}),
// Dedupe
distinct((output) => loadingStartTime + output.id + output.status + !!output.error),
// Map loaded event properties
map((output): EmbeddablePhaseEvent => {
return {
...output,
timeToEvent: performance.now() - loadingStartTime,
};
})
)
.subscribe((statusOutput) => {
onPanelStatusChange(statusOutput);
});
return () => subscription?.unsubscribe();
// Panel status change subscription should only be run on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Select state from the embeddable
*/
const loading = useSelectFromEmbeddableOutput('loading', embeddable);
const viewMode = useSelectFromEmbeddableInput('viewMode', embeddable);
/**
* Render embeddable into ref, set up error subscription
*/
useEffect(() => {
if (!embeddableRoot.current) return;
const nextNode = embeddable.render(embeddableRoot.current) ?? undefined;
if (isPromise(nextNode)) {
nextNode.then((resolved) => setNode(resolved));
} else {
setNode(nextNode);
}
const errorSubscription = embeddable.getOutput$().subscribe({
next: (output) => {
setOutputError(output.error);
},
error: (error) => setOutputError(error),
});
return () => {
embeddable?.destroy();
errorSubscription?.unsubscribe();
};
}, [embeddable, embeddableRoot]);
const classes = useMemo(
() =>
classNames('embPanel', {
'embPanel--editing': viewMode !== ViewMode.VIEW,
'embPanel--loading': loading,
}),
[viewMode, loading]
);
const contentAttrs = useMemo(() => {
const attrs: { [key: string]: boolean } = {};
if (loading) attrs['data-loading'] = true;
if (outputError) attrs['data-error'] = true;
return attrs;
}, [loading, outputError]);
return (
<EuiPanel
role="figure"
paddingSize="none"
className={classes}
hasShadow={showShadow}
aria-labelledby={headerId}
data-test-subj="embeddablePanel"
data-test-embeddable-id={embeddable.id}
>
{!hideHeader && (
<EmbeddablePanelHeader
{...panelProps}
headerId={headerId}
universalActions={universalActions}
/>
)}
{outputError && (
<EuiFlexGroup
alignItems="center"
className="eui-fullHeight embPanel__error"
data-test-subj="embeddableError"
justifyContent="center"
>
<EuiFlexItem>
<EmbeddableErrorHandler embeddable={embeddable} error={outputError}>
{(error) => (
<EmbeddablePanelError
editPanelAction={universalActions.editPanel}
embeddable={embeddable}
error={error}
/>
)}
</EmbeddableErrorHandler>
</EuiFlexItem>
</EuiFlexGroup>
)}
<div className="embPanel__content" ref={embeddableRoot} {...contentAttrs}>
{node}
</div>
</EuiPanel>
);
};

View file

@ -6,15 +6,17 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { distinctUntilChanged, merge, of, switchMap } from 'rxjs';
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { EuiButtonEmpty, EuiEmptyPrompt, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import type { MaybePromise } from '@kbn/utility-types';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import { ErrorLike } from '@kbn/expressions-plugin/common';
import { distinctUntilChanged, merge, of, switchMap } from 'rxjs';
import { EditPanelAction } from '../actions';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../embeddables';
import { EditPanelAction } from './panel_actions';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../lib/embeddables';
interface EmbeddablePanelErrorProps {
editPanelAction?: EditPanelAction;

View file

@ -0,0 +1,49 @@
/*
* 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';
export const placeholderTitle = i18n.translate('embeddableApi.panel.placeholderTitle', {
defaultMessage: '[No Title]',
});
export const getAriaLabelForTitle = (title?: string) => {
if (title) {
return i18n.translate('embeddableApi.panel.enhancedDashboardPanelAriaLabel', {
defaultMessage: 'Dashboard panel: {title}',
values: { title: title || placeholderTitle },
});
}
return i18n.translate('embeddableApi.panel.dashboardPanelAriaLabel', {
defaultMessage: 'Dashboard panel',
});
};
export const getEditTitleAriaLabel = (title?: string) =>
i18n.translate('embeddableApi.panel.editTitleAriaLabel', {
defaultMessage: 'Click to edit title: {title}',
values: { title: title || placeholderTitle },
});
export const getContextMenuAriaLabel = (title?: string, index?: number) => {
if (title) {
return i18n.translate('embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel', {
defaultMessage: 'Panel options for {title}',
values: { title },
});
}
if (index) {
return i18n.translate('embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex', {
defaultMessage: 'Options for panel {index}',
values: { index },
});
}
return i18n.translate('embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel', {
defaultMessage: 'Panel options',
});
};

View file

@ -0,0 +1,23 @@
/*
* 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 React from 'react';
import { EmbeddablePanelProps } from './types';
import { useEmbeddablePanel } from './use_embeddable_panel';
import { EmbeddableLoadingIndicator } from './embeddable_loading_indicator';
/**
* Loads and renders an embeddable.
*/
export const EmbeddablePanel = (props: EmbeddablePanelProps) => {
const result = useEmbeddablePanel({ embeddable: props.embeddable });
if (!result) return <EmbeddableLoadingIndicator />;
const { embeddable, ...passThroughProps } = props;
return <result.Panel embeddable={result.unwrappedEmbeddable} {...passThroughProps} />;
};

View file

@ -11,8 +11,8 @@ import {
HelloWorldContainer,
TimeRangeContainer,
TimeRangeEmbeddable,
} from '../../../../test_samples';
import { HelloWorldEmbeddable } from '../../../../../tests/fixtures';
} from '../../../lib/test_samples';
import { HelloWorldEmbeddable } from '../../../tests/fixtures';
test('canInheritTimeRange returns false if embeddable is inside container without a time range', () => {
const embeddable = new TimeRangeEmbeddable(

View file

@ -7,8 +7,9 @@
*/
import type { TimeRange } from '@kbn/es-query';
import { Embeddable, IContainer, ContainerInput } from '../../../../..';
import { TimeRangeInput } from './customize_panel_action';
import { Embeddable, IContainer, ContainerInput } from '../../..';
interface ContainerTimeRangeInput extends ContainerInput<TimeRangeInput> {
timeRange: TimeRange;

View file

@ -6,13 +6,14 @@
* Side Public License, v 1.
*/
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import {
TimeRangeEmbeddable,
TimeRangeContainer,
TIME_RANGE_EMBEDDABLE,
} from '../../../../test_samples/embeddables';
} from '../../../lib/test_samples/embeddables';
import { CustomTimeRangeBadge } from './custom_time_range_badge';
test(`badge is not compatible with embeddable that inherits from parent`, async () => {

View file

@ -7,11 +7,12 @@
*/
import React from 'react';
import { renderToString } from 'react-dom/server';
import { PrettyDuration } from '@elastic/eui';
import { renderToString } from 'react-dom/server';
import { Action } from '@kbn/ui-actions-plugin/public';
import { Embeddable } from '../../..';
import { doesInheritTimeRange } from './does_inherit_time_range';
import { Embeddable } from '../../../../..';
import { TimeRangeInput, hasTimeRange, CustomizePanelAction } from './customize_panel_action';
export const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE';

View file

@ -8,19 +8,19 @@
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { Container, isErrorEmbeddable } from '../../../..';
import { Container, isErrorEmbeddable } from '../../..';
import { CustomizePanelAction } from './customize_panel_action';
import {
ContactCardEmbeddable,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
} from '../../../../test_samples/embeddables/contact_card/contact_card_embeddable';
} from '../../../lib/test_samples/embeddables/contact_card/contact_card_embeddable';
import {
CONTACT_CARD_EMBEDDABLE,
ContactCardEmbeddableFactory,
} from '../../../../test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { HelloWorldContainer } from '../../../../test_samples/embeddables/hello_world_container';
import { embeddablePluginMock } from '../../../../../mocks';
} from '../../../lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { HelloWorldContainer } from '../../../lib/test_samples/embeddables/hello_world_container';
import { embeddablePluginMock } from '../../../mocks';
let container: Container;
let embeddable: ContactCardEmbeddable;

View file

@ -8,19 +8,14 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { TimeRange } from '@kbn/es-query';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { OverlayRef, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { TimeRange } from '@kbn/es-query';
import { ViewMode } from '../../../../types';
import {
IEmbeddable,
Embeddable,
EmbeddableInput,
CommonlyUsedRange,
EmbeddableOutput,
} from '../../../..';
import { CustomizePanelEditor } from './customize_panel_editor';
import { ViewMode, CommonlyUsedRange } from '../../../lib/types';
import { IEmbeddable, Embeddable, EmbeddableInput, EmbeddableOutput } from '../../..';
export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL';

View file

@ -24,13 +24,14 @@ import {
EuiFlexItem,
EuiSuperDatePicker,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { TimeRange } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import { TimeRangeInput } from './customize_panel_action';
import { doesInheritTimeRange } from './does_inherit_time_range';
import { IEmbeddable, Embeddable, CommonlyUsedRange, ViewMode } from '../../../..';
import { canInheritTimeRange } from './can_inherit_time_range';
import { doesInheritTimeRange } from './does_inherit_time_range';
import { IEmbeddable, Embeddable, CommonlyUsedRange, ViewMode } from '../../../lib';
type PanelSettings = {
title?: string;

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { Embeddable, IContainer, ContainerInput } from '../../../..';
import { Embeddable, IContainer, ContainerInput } from '../../../lib';
import { TimeRangeInput } from './customize_panel_action';
export function doesInheritTimeRange(embeddable: Embeddable<TimeRangeInput>) {

View file

@ -6,14 +6,15 @@
* Side Public License, v 1.
*/
import { EditPanelAction } from './edit_panel_action';
import { Embeddable, EmbeddableInput } from '../embeddables';
import { ViewMode } from '../types';
import { ContactCardEmbeddable } from '../test_samples';
import { embeddablePluginMock } from '../../mocks';
import { applicationServiceMock } from '@kbn/core/public/mocks';
import { of } from 'rxjs';
import { ViewMode } from '../../../lib';
import { EditPanelAction } from './edit_panel_action';
import { embeddablePluginMock } from '../../../mocks';
import { applicationServiceMock } from '@kbn/core/public/mocks';
import { ContactCardEmbeddable } from '../../../lib/test_samples';
import { Embeddable, EmbeddableInput } from '../../../lib/embeddables';
const { doStart } = embeddablePluginMock.createInstance();
const start = doStart();
const getFactory = start.getEmbeddableFactory;

View file

@ -7,19 +7,21 @@
*/
import { i18n } from '@kbn/i18n';
import { take } from 'rxjs/operators';
import { ApplicationStart } from '@kbn/core/public';
import { Action } from '@kbn/ui-actions-plugin/public';
import { take } from 'rxjs/operators';
import { ViewMode } from '../types';
import { EmbeddableFactoryNotFoundError } from '../errors';
import { EmbeddableStart } from '../../plugin';
import {
Container,
IEmbeddable,
EmbeddableInput,
EmbeddableEditorState,
EmbeddableStateTransfer,
EmbeddableInput,
Container,
} from '../..';
} from '../../../lib';
import { ViewMode } from '../../../lib/types';
import { EmbeddableStart } from '../../../plugin';
import { EmbeddableFactoryNotFoundError } from '../../../lib/errors';
export const ACTION_EDIT_PANEL = 'editPanel';

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 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 {
InspectPanelAction,
ACTION_INSPECT_PANEL,
} from './inspect_panel_action/inspect_panel_action';
export {
CustomizePanelAction,
CustomTimeRangeBadge,
ACTION_CUSTOMIZE_PANEL,
} from './customize_panel_action';
export { EditPanelAction, ACTION_EDIT_PANEL } from './edit_panel_action/edit_panel_action';
export { RemovePanelAction, REMOVE_PANEL_ACTION } from './remove_panel_action/remove_panel_action';

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import { InspectPanelAction } from './inspect_panel_action';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import {
FilterableContainer,
FILTERABLE_EMBEDDABLE,
@ -14,12 +15,12 @@ import {
FilterableEmbeddableInput,
FilterableEmbeddable,
ContactCardEmbeddable,
} from '../../../test_samples';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { EmbeddableOutput, isErrorEmbeddable, ErrorEmbeddable } from '../../../embeddables';
import { of } from '../../../../tests/helpers';
import { embeddablePluginMock } from '../../../../mocks';
import { EmbeddableStart } from '../../../../plugin';
} from '../../../lib/test_samples';
import { of } from '../../../tests/helpers';
import { EmbeddableStart } from '../../../plugin';
import { embeddablePluginMock } from '../../../mocks';
import { InspectPanelAction } from './inspect_panel_action';
import { EmbeddableOutput, isErrorEmbeddable, ErrorEmbeddable } from '../../../lib/embeddables';
const setupTests = async () => {
const { setup, doStart } = embeddablePluginMock.createInstance();

View file

@ -9,7 +9,8 @@
import { i18n } from '@kbn/i18n';
import { Action } from '@kbn/ui-actions-plugin/public';
import { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import { IEmbeddable } from '../../../embeddables';
import { IEmbeddable } from '../../../lib/embeddables';
export const ACTION_INSPECT_PANEL = 'openInspector';

View file

@ -7,19 +7,20 @@
*/
import { EmbeddableOutput, isErrorEmbeddable } from '../../..';
import { RemovePanelAction } from './remove_panel_action';
import { EmbeddableStart } from '../../../../plugin';
import {
MockFilter,
FILTERABLE_EMBEDDABLE,
FilterableEmbeddable,
FilterableEmbeddableInput,
} from '../../../test_samples/embeddables/filterable_embeddable';
import { FilterableEmbeddableFactory } from '../../../test_samples/embeddables/filterable_embeddable_factory';
import { FilterableContainer } from '../../../test_samples/embeddables/filterable_container';
import { ViewMode } from '../../../types';
import { ContactCardEmbeddable } from '../../../test_samples/embeddables/contact_card/contact_card_embeddable';
import { embeddablePluginMock } from '../../../../mocks';
} from '../../../lib/test_samples/embeddables/filterable_embeddable';
import { ViewMode } from '../../../lib/types';
import { EmbeddableStart } from '../../../plugin';
import { embeddablePluginMock } from '../../../mocks';
import { RemovePanelAction } from './remove_panel_action';
import { FilterableContainer } from '../../../lib/test_samples/embeddables/filterable_container';
import { FilterableEmbeddableFactory } from '../../../lib/test_samples/embeddables/filterable_embeddable_factory';
import { ContactCardEmbeddable } from '../../../lib/test_samples/embeddables/contact_card/contact_card_embeddable';
const { setup, doStart } = embeddablePluginMock.createInstance();
setup.registerEmbeddableFactory(FILTERABLE_EMBEDDABLE, new FilterableEmbeddableFactory());

View file

@ -7,9 +7,10 @@
*/
import { i18n } from '@kbn/i18n';
import { Action, IncompatibleActionError } from '../../../ui_actions';
import { ViewMode } from '../../../types';
import { IEmbeddable } from '../../../embeddables';
import { ViewMode } from '../../../lib/types';
import { IEmbeddable } from '../../../lib/embeddables';
import { Action, IncompatibleActionError } from '../../../lib/ui_actions';
export const REMOVE_PANEL_ACTION = 'deletePanel';

View file

@ -0,0 +1,171 @@
/*
* 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 classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import {
EuiButtonIcon,
EuiContextMenu,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiContextMenuPanelDescriptor,
EuiPopover,
EuiSkeletonText,
} from '@elastic/eui';
import { Action, buildContextMenuForActions } from '@kbn/ui-actions-plugin/public';
import { uiActions } from '../../kibana_services';
import { EmbeddablePanelProps, PanelUniversalActions } from '../types';
import { getContextMenuAriaLabel } from '../embeddable_panel_strings';
import { useSelectFromEmbeddableInput } from '../use_select_from_embeddable';
import { IEmbeddable, contextMenuTrigger, CONTEXT_MENU_TRIGGER, ViewMode } from '../..';
const sortByOrderField = (
{ order: orderA }: { order?: number },
{ order: orderB }: { order?: number }
) => (orderB || 0) - (orderA || 0);
const removeById =
(disabledActions: string[]) =>
({ id }: { id: string }) =>
disabledActions.indexOf(id) === -1;
export const EmbeddablePanelContextMenu = ({
index,
embeddable,
getActions,
actionPredicate,
universalActions,
}: {
index?: number;
embeddable: IEmbeddable;
universalActions: PanelUniversalActions;
getActions: EmbeddablePanelProps['getActions'];
actionPredicate?: (actionId: string) => boolean;
}) => {
const [menuPanelsLoading, setMenuPanelsLoading] = useState(false);
const [contextMenuActions, setContextMenuActions] = useState<Array<Action<object>>>([]);
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean | undefined>(undefined);
const [contextMenuPanels, setContextMenuPanels] = useState<EuiContextMenuPanelDescriptor[]>([]);
const title = useSelectFromEmbeddableInput('title', embeddable);
const viewMode = useSelectFromEmbeddableInput('viewMode', embeddable);
useEffect(() => {
/**
* isContextMenuOpen starts as undefined which allows this use effect to run on mount. This
* is required so that showNotification is calculated on mount.
*/
if (isContextMenuOpen === false) return;
setMenuPanelsLoading(true);
let canceled = false;
(async () => {
/**
* Build and update all actions
*/
const regularActions = await (async () => {
if (getActions) return await getActions(CONTEXT_MENU_TRIGGER, { embeddable });
return (
(await uiActions.getTriggerCompatibleActions(CONTEXT_MENU_TRIGGER, {
embeddable,
})) ?? []
);
})();
if (canceled) return;
let allActions = regularActions.concat(
Object.values(universalActions ?? {}) as Array<Action<object>>
);
const { disabledActions } = embeddable.getInput();
if (disabledActions) {
const removeDisabledActions = removeById(disabledActions);
allActions = allActions.filter(removeDisabledActions);
}
allActions.sort(sortByOrderField);
if (actionPredicate) {
allActions = allActions.filter(({ id }) => actionPredicate(id));
}
/**
* Build context menu panel from actions
*/
const panels = await buildContextMenuForActions({
actions: allActions.map((action) => ({
action,
context: { embeddable },
trigger: contextMenuTrigger,
})),
closeMenu: () => setIsContextMenuOpen(false),
});
if (canceled) return;
setMenuPanelsLoading(false);
setContextMenuActions(allActions);
setContextMenuPanels(panels);
})();
return () => {
canceled = true;
};
}, [actionPredicate, embeddable, getActions, isContextMenuOpen, universalActions]);
const showNotification = useMemo(
() => contextMenuActions.some((action) => action.showNotification),
[contextMenuActions]
);
const contextMenuClasses = classNames({
// eslint-disable-next-line @typescript-eslint/naming-convention
embPanel__optionsMenuPopover: true,
'embPanel__optionsMenuPopover-notification': showNotification,
});
const ContextMenuButton = (
<EuiButtonIcon
color="text"
className="embPanel__optionsMenuButton"
data-test-subj="embeddablePanelToggleMenuIcon"
aria-label={getContextMenuAriaLabel(title, index)}
onClick={() => setIsContextMenuOpen((isOpen) => !isOpen)}
iconType={viewMode === ViewMode.VIEW ? 'boxesHorizontal' : 'gear'}
/>
);
return (
<EuiPopover
repositionOnScroll
panelPaddingSize="none"
anchorPosition="downRight"
button={ContextMenuButton}
isOpen={isContextMenuOpen}
className={contextMenuClasses}
closePopover={() => setIsContextMenuOpen(false)}
data-test-subj={
isContextMenuOpen ? 'embeddablePanelContextMenuOpen' : 'embeddablePanelContextMenuClosed'
}
>
{menuPanelsLoading ? (
<EuiContextMenuPanel
className="embPanel__optionsMenuPopover-loading"
title={i18n.translate('embeddableApi.panel.contextMenu.loadingTitle', {
defaultMessage: 'Options',
})}
>
<EuiContextMenuItem>
<EuiSkeletonText />
</EuiContextMenuItem>
</EuiContextMenuPanel>
) : (
<EuiContextMenu initialPanelId="mainMenu" panels={contextMenuPanels} />
)}
</EuiPopover>
);
};

View file

@ -0,0 +1,113 @@
/*
* 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 classNames from 'classnames';
import React, { useMemo } from 'react';
import { EuiScreenReaderOnly } from '@elastic/eui';
import { isSelfStyledEmbeddable, ViewMode } from '../../lib';
import { EmbeddablePanelTitle } from './embeddable_panel_title';
import { getAriaLabelForTitle } from '../embeddable_panel_strings';
import { useEmbeddablePanelBadges } from './use_embeddable_panel_badges';
import { useSelectFromEmbeddableInput } from '../use_select_from_embeddable';
import { EmbeddablePanelContextMenu } from './embeddable_panel_context_menu';
import { UnwrappedEmbeddablePanelProps, PanelUniversalActions } from '../types';
export const EmbeddablePanelHeader = ({
index,
headerId,
getActions,
embeddable,
actionPredicate,
universalActions,
showBadges = true,
showNotifications = true,
}: UnwrappedEmbeddablePanelProps & {
headerId: string;
universalActions: PanelUniversalActions;
}) => {
const selfStyledEmbeddableOptions = useMemo(
() => (isSelfStyledEmbeddable(embeddable) ? embeddable.getSelfStyledOptions() : undefined),
[embeddable]
);
const { notificationComponents, badgeComponents } = useEmbeddablePanelBadges(
embeddable,
getActions
);
const title = embeddable.getTitle();
const viewMode = useSelectFromEmbeddableInput('viewMode', embeddable);
const description = useSelectFromEmbeddableInput('description', embeddable);
const hidePanelTitle = useSelectFromEmbeddableInput('hidePanelTitles', embeddable);
const parentHidePanelTitle = useSelectFromEmbeddableInput('hidePanelTitles', embeddable.parent);
const hideTitle =
Boolean(hidePanelTitle) ||
Boolean(parentHidePanelTitle) ||
Boolean(selfStyledEmbeddableOptions?.hideTitle) ||
(viewMode === ViewMode.VIEW && !Boolean(title));
const showPanelBar =
!hideTitle ||
description ||
viewMode !== ViewMode.VIEW ||
(badgeComponents?.length ?? 0) > 0 ||
(notificationComponents?.length ?? 0) > 0;
const ariaLabel = getAriaLabelForTitle(showPanelBar ? title : undefined);
const ariaLabelElement = (
<EuiScreenReaderOnly>
<span id={headerId}>{ariaLabel}</span>
</EuiScreenReaderOnly>
);
const headerClasses = classNames('embPanel__header', {
'embPanel__header--floater': !showPanelBar,
});
const titleClasses = classNames('embPanel__title', {
'embPanel--dragHandle': viewMode === ViewMode.EDIT,
});
const embeddablePanelContextMenu = (
<EmbeddablePanelContextMenu
{...{ index, embeddable, getActions, actionPredicate, universalActions }}
/>
);
if (!showPanelBar) {
return (
<div className={headerClasses}>
{embeddablePanelContextMenu}
{ariaLabelElement}
</div>
);
}
return (
<figcaption
className={headerClasses}
data-test-subj={`embeddablePanelHeading-${(title || '').replace(/\s/g, '')}`}
>
<h2 data-test-subj="dashboardPanelTitle" className={titleClasses}>
{ariaLabelElement}
<EmbeddablePanelTitle
viewMode={viewMode}
hideTitle={hideTitle}
embeddable={embeddable}
description={description}
customizePanelAction={universalActions.customizePanel}
/>
{showBadges && badgeComponents}
</h2>
{showNotifications && notificationComponents}
{embeddablePanelContextMenu}
</figcaption>
);
};

View file

@ -0,0 +1,75 @@
/*
* 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 classNames from 'classnames';
import React, { useMemo } from 'react';
import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { IEmbeddable, ViewMode } from '../../lib';
import { CustomizePanelAction } from '../panel_actions';
import { getEditTitleAriaLabel, placeholderTitle } from '../embeddable_panel_strings';
export const EmbeddablePanelTitle = ({
viewMode,
hideTitle,
embeddable,
description,
customizePanelAction,
}: {
hideTitle?: boolean;
viewMode?: ViewMode;
description?: string;
embeddable: IEmbeddable;
customizePanelAction?: CustomizePanelAction;
}) => {
const title = embeddable.getTitle();
const titleComponent = useMemo(() => {
if (hideTitle) return null;
const titleClassNames = classNames('embPanel__titleText', {
// eslint-disable-next-line @typescript-eslint/naming-convention
embPanel__placeholderTitleText: !title,
});
if (viewMode === ViewMode.VIEW) {
return <span className={titleClassNames}>{title}</span>;
}
if (customizePanelAction) {
return (
<EuiLink
color="text"
className={titleClassNames}
aria-label={getEditTitleAriaLabel(title)}
data-test-subj={'embeddablePanelTitleLink'}
onClick={() => customizePanelAction.execute({ embeddable })}
>
{title || placeholderTitle}
</EuiLink>
);
}
return null;
}, [customizePanelAction, embeddable, title, viewMode, hideTitle]);
const titleComponentWithDescription = useMemo(() => {
if (!description) return <span className="embPanel__titleInner">{titleComponent}</span>;
return (
<EuiToolTip
content={description}
delay="regular"
position="top"
anchorClassName="embPanel__titleTooltipAnchor"
>
<span className="embPanel__titleInner">
{titleComponent} <EuiIcon type="iInCircle" color="subdued" />
</span>
</EuiToolTip>
);
}, [description, titleComponent]);
return titleComponentWithDescription;
};

View file

@ -0,0 +1,165 @@
/*
* 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 { Subscription } from 'rxjs';
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiBadge, EuiNotificationBadge, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
IEmbeddable,
panelBadgeTrigger,
panelNotificationTrigger,
PANEL_BADGE_TRIGGER,
PANEL_NOTIFICATION_TRIGGER,
} from '../..';
import { uiActions } from '../../kibana_services';
import {
EmbeddableBadgeAction,
EmbeddableNotificationAction,
EmbeddablePanelProps,
} from '../types';
export const useEmbeddablePanelBadges = (
embeddable: IEmbeddable,
getActions: EmbeddablePanelProps['getActions']
) => {
const getActionsForTrigger = getActions ?? uiActions.getTriggerCompatibleActions;
const [badges, setBadges] = useState<EmbeddableBadgeAction[]>();
const [notifications, setNotifications] = useState<EmbeddableNotificationAction[]>();
const getAllBadgesFromEmbeddable = useCallback(async () => {
let currentBadges: EmbeddableBadgeAction[] =
((await getActionsForTrigger(PANEL_BADGE_TRIGGER, {
embeddable,
})) as EmbeddableBadgeAction[]) ?? [];
const { disabledActions } = embeddable.getInput();
if (disabledActions) {
currentBadges = currentBadges.filter((badge) => disabledActions.indexOf(badge.id) === -1);
}
return currentBadges;
}, [embeddable, getActionsForTrigger]);
const getAllNotificationsFromEmbeddable = useCallback(async () => {
let currentNotifications: EmbeddableNotificationAction[] =
((await getActionsForTrigger(PANEL_NOTIFICATION_TRIGGER, {
embeddable,
})) as EmbeddableNotificationAction[]) ?? [];
const { disabledActions } = embeddable.getInput();
if (disabledActions) {
currentNotifications = currentNotifications.filter(
(badge) => disabledActions.indexOf(badge.id) === -1
);
}
return currentNotifications;
}, [embeddable, getActionsForTrigger]);
/**
* On embeddable creation get initial badges & notifications then subscribe to all
* input updates to refresh them
*/
useEffect(() => {
let canceled = false;
let subscription: Subscription;
const updateNotificationsAndBadges = async () => {
const [newBadges, newNotifications] = await Promise.all([
getAllBadgesFromEmbeddable(),
getAllNotificationsFromEmbeddable(),
]);
if (canceled) return;
setBadges(newBadges);
setNotifications(newNotifications);
};
updateNotificationsAndBadges().then(() => {
if (canceled) return;
/**
* since any piece of state could theoretically change which actions are available we need to
* recalculate them on any input change or any parent input change.
*/
subscription = embeddable.getInput$().subscribe(() => updateNotificationsAndBadges());
if (embeddable.parent) {
subscription.add(
embeddable.parent.getInput$().subscribe(() => updateNotificationsAndBadges())
);
}
});
return () => {
subscription?.unsubscribe();
canceled = true;
};
}, [embeddable, getAllBadgesFromEmbeddable, getAllNotificationsFromEmbeddable]);
const badgeComponents = useMemo(
() =>
badges?.map((badge) => (
<EuiBadge
key={badge.id}
className="embPanel__headerBadge"
iconType={badge.getIconType({ embeddable, trigger: panelBadgeTrigger })}
onClick={() => badge.execute({ embeddable, trigger: panelBadgeTrigger })}
onClickAriaLabel={badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })}
data-test-subj={`embeddablePanelBadge-${badge.id}`}
>
{badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })}
</EuiBadge>
)),
[badges, embeddable]
);
const notificationComponents = useMemo(
() =>
notifications?.map((notification) => {
const context = { embeddable };
let badge = notification.MenuItem ? (
React.createElement(notification.MenuItem, {
key: notification.id,
context: {
embeddable,
trigger: panelNotificationTrigger,
},
})
) : (
<EuiNotificationBadge
data-test-subj={`embeddablePanelNotification-${notification.id}`}
key={notification.id}
style={{ marginTop: euiThemeVars.euiSizeXS, marginRight: euiThemeVars.euiSizeXS }}
onClick={() => notification.execute({ ...context, trigger: panelNotificationTrigger })}
>
{notification.getDisplayName({ ...context, trigger: panelNotificationTrigger })}
</EuiNotificationBadge>
);
if (notification.getDisplayNameTooltip) {
const tooltip = notification.getDisplayNameTooltip({
...context,
trigger: panelNotificationTrigger,
});
if (tooltip) {
badge = (
<EuiToolTip position="top" delay="regular" content={tooltip} key={notification.id}>
{badge}
</EuiToolTip>
);
}
}
return badge;
}),
[embeddable, notifications]
);
return { badgeComponents, notificationComponents };
};

View file

@ -0,0 +1,86 @@
/*
* 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 { ReactNode } from 'react';
import { MaybePromise } from '@kbn/utility-types';
import { Action, UiActionsService } from '@kbn/ui-actions-plugin/public';
import {
EditPanelAction,
RemovePanelAction,
InspectPanelAction,
CustomizePanelAction,
} from './panel_actions';
import { EmbeddableError } from '../lib/embeddables/i_embeddable';
import { EmbeddableContext, EmbeddableInput, EmbeddableOutput, IEmbeddable } from '..';
export interface EmbeddableContainerContext {
/**
* Current app's path including query and hash starting from {appId}
*/
getCurrentPath?: () => string;
}
/**
* Performance tracking types
*/
export type EmbeddablePhase = 'loading' | 'loaded' | 'rendered' | 'error';
export interface EmbeddablePhaseEvent {
id: string;
status: EmbeddablePhase;
error?: EmbeddableError;
timeToEvent: number;
}
export type EmbeddableBadgeAction = Action<
EmbeddableContext<IEmbeddable<EmbeddableInput, EmbeddableOutput>>
>;
export type EmbeddableNotificationAction = Action<
EmbeddableContext<IEmbeddable<EmbeddableInput, EmbeddableOutput>>
>;
type PanelEmbeddable = IEmbeddable<EmbeddableInput, EmbeddableOutput, MaybePromise<ReactNode>>;
export interface EmbeddablePanelProps {
showBadges?: boolean;
showShadow?: boolean;
hideHeader?: boolean;
hideInspector?: boolean;
showNotifications?: boolean;
containerContext?: EmbeddableContainerContext;
actionPredicate?: (actionId: string) => boolean;
onPanelStatusChange?: (info: EmbeddablePhaseEvent) => void;
getActions?: UiActionsService['getTriggerCompatibleActions'];
embeddable: PanelEmbeddable | (() => Promise<PanelEmbeddable>);
/**
* Ordinal number of the embeddable in the container, used as a
* "title" when the panel has no title, i.e. "Panel {index}".
*/
index?: number;
}
export type UnwrappedEmbeddablePanelProps = Omit<EmbeddablePanelProps, 'embeddable'> & {
embeddable: PanelEmbeddable;
};
export interface InspectorPanelAction {
inspectPanel: InspectPanelAction;
}
export interface BasePanelActions {
customizePanel: CustomizePanelAction;
inspectPanel: InspectPanelAction;
removePanel: RemovePanelAction;
editPanel: EditPanelAction;
}
export interface PanelUniversalActions
extends Partial<InspectorPanelAction>,
Partial<BasePanelActions> {}

View file

@ -0,0 +1,62 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import * as kibanaServices from '../kibana_services';
import { ErrorEmbeddable, IEmbeddable } from '../lib';
import { useEmbeddablePanel } from './use_embeddable_panel';
jest.mock('../kibana_services');
describe('useEmbeddablePanel', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('returns the correct values when an unwrapped embeddable is provided', async () => {
const embeddable: IEmbeddable = { id: 'supertest' } as unknown as IEmbeddable;
const { result, waitForNextUpdate } = renderHook(() => useEmbeddablePanel({ embeddable }));
await waitForNextUpdate();
expect(result.current).toBeDefined();
expect(result.current!.unwrappedEmbeddable).toEqual(embeddable);
expect(result.current!.Panel).toBeDefined();
});
it('returns the correct values when embeddable is provided as an async function', async () => {
const unwrappedEmbeddable: IEmbeddable = { id: 'supertest' } as unknown as IEmbeddable;
const embeddable = jest
.fn<Promise<IEmbeddable | ErrorEmbeddable>, []>()
.mockResolvedValue(unwrappedEmbeddable);
const { result, waitForNextUpdate } = renderHook(() => useEmbeddablePanel({ embeddable }));
await waitForNextUpdate();
expect(result.current).toBeDefined();
expect(embeddable).toHaveBeenCalled();
expect(result.current!.Panel).toBeDefined();
expect(result.current!.unwrappedEmbeddable).toEqual(unwrappedEmbeddable);
});
it('calls untilPluginStartServicesReady', async () => {
const embeddable: IEmbeddable = { id: 'supertest' } as unknown as IEmbeddable;
const untilPluginStartServicesReadySpy = jest.spyOn(
kibanaServices,
'untilPluginStartServicesReady'
);
const { waitForNextUpdate } = renderHook(() => useEmbeddablePanel({ embeddable }));
await waitForNextUpdate();
expect(untilPluginStartServicesReadySpy).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 useAsync from 'react-use/lib/useAsync';
import { IEmbeddable } from '../lib';
import { EmbeddablePanelProps, UnwrappedEmbeddablePanelProps } from './types';
import { untilPluginStartServicesReady } from '../kibana_services';
export type UseEmbeddablePanelResult =
| {
unwrappedEmbeddable: IEmbeddable;
Panel: (props: UnwrappedEmbeddablePanelProps) => JSX.Element;
}
| undefined;
export const useEmbeddablePanel = ({
embeddable,
}: {
embeddable: EmbeddablePanelProps['embeddable'];
}): UseEmbeddablePanelResult => {
const { loading, value } = useAsync(async () => {
const startServicesPromise = untilPluginStartServicesReady();
const modulePromise = import('./embeddable_panel');
const embeddablePromise =
typeof embeddable === 'function' ? embeddable() : Promise.resolve(embeddable);
const [, unwrappedEmbeddable, panelModule] = await Promise.all([
startServicesPromise,
embeddablePromise,
modulePromise,
]);
return { panelModule, unwrappedEmbeddable };
}, []);
if (loading || !value?.panelModule || !value?.unwrappedEmbeddable) return;
return {
unwrappedEmbeddable: value.unwrappedEmbeddable,
Panel: value.panelModule.EmbeddablePanel,
};
};

View file

@ -0,0 +1,49 @@
/*
* 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 { useEffect, useState } from 'react';
import { distinctUntilKeyChanged } from 'rxjs';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../lib';
export const useSelectFromEmbeddableInput = <
InputType extends EmbeddableInput,
KeyType extends keyof InputType
>(
key: KeyType,
embeddable?: IEmbeddable<InputType>
): InputType[KeyType] | undefined => {
const [value, setValue] = useState<InputType[KeyType] | undefined>(embeddable?.getInput()[key]);
useEffect(() => {
const subscription = embeddable
?.getInput$()
.pipe(distinctUntilKeyChanged(key))
.subscribe(() => setValue(embeddable.getInput()[key]));
return () => subscription?.unsubscribe();
}, [embeddable, key]);
return value;
};
export const useSelectFromEmbeddableOutput = <
OutputType extends EmbeddableOutput,
KeyType extends keyof OutputType
>(
key: KeyType,
embeddable: IEmbeddable<EmbeddableInput, OutputType>
): OutputType[KeyType] => {
const [value, setValue] = useState<OutputType[KeyType]>(embeddable.getOutput()[key]);
useEffect(() => {
const subscription = embeddable
.getOutput$()
.pipe(distinctUntilKeyChanged(key))
.subscribe(() => setValue(embeddable.getOutput()[key]));
return () => subscription.unsubscribe();
}, [embeddable, key]);
return value;
};

View file

@ -1,3 +1,2 @@
@import './variables';
@import './lib/panel/index';
@import './lib/panel/panel_header/index';
@import './embeddable_panel/embeddable_panel';

View file

@ -19,10 +19,7 @@ export type {
ChartActionContext,
ContainerInput,
ContainerOutput,
EmbeddableChildPanelProps,
EmbeddableContext,
EmbeddablePhaseEvent,
EmbeddablePhase,
EmbeddableFactory,
EmbeddableFactoryDefinition,
EmbeddableInput,
@ -41,28 +38,20 @@ export type {
EmbeddableEditorState,
EmbeddablePackageState,
EmbeddableRendererProps,
EmbeddableContainerContext,
EmbeddableContainerSettings,
} from './lib';
export {
ACTION_ADD_PANEL,
ACTION_EDIT_PANEL,
AddPanelAction,
isReferenceOrValueEmbeddable,
Container,
CONTEXT_MENU_TRIGGER,
contextMenuTrigger,
defaultEmbeddableFactoryProvider,
EditPanelAction,
Embeddable,
EmbeddableChildPanel,
EmbeddableFactoryNotFoundError,
EmbeddablePanel,
EmbeddableRoot,
ErrorEmbeddable,
isEmbeddable,
isErrorEmbeddable,
openAddPanelFlyout,
PANEL_BADGE_TRIGGER,
panelBadgeTrigger,
PANEL_NOTIFICATION_TRIGGER,
@ -93,6 +82,24 @@ export {
panelHoverTrigger,
} from './lib';
export { EmbeddablePanel } from './embeddable_panel';
export {
InspectPanelAction,
ACTION_INSPECT_PANEL,
CustomizePanelAction,
ACTION_CUSTOMIZE_PANEL,
EditPanelAction,
ACTION_EDIT_PANEL,
RemovePanelAction,
REMOVE_PANEL_ACTION,
} from './embeddable_panel/panel_actions';
export type {
EmbeddablePhase,
EmbeddablePhaseEvent,
EmbeddableContainerContext,
} from './embeddable_panel/types';
export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service';
export type { EnhancementRegistryDefinition } from './types';
@ -101,10 +108,11 @@ export function plugin(initializerContext: PluginInitializerContext) {
return new EmbeddablePublicPlugin(initializerContext);
}
export { openAddPanelFlyout } from './add_panel_flyout/open_add_panel_flyout';
export type {
EmbeddableSetup,
EmbeddableStart,
EmbeddableSetupDependencies,
EmbeddableStartDependencies,
EmbeddablePanelHOC,
} from './plugin';

View file

@ -0,0 +1,50 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import { CoreStart } from '@kbn/core/public';
import { EmbeddableStart, EmbeddableStartDependencies } from '.';
export let core: CoreStart;
export let embeddableStart: EmbeddableStart;
export let uiActions: EmbeddableStartDependencies['uiActions'];
export let inspector: EmbeddableStartDependencies['inspector'];
export let usageCollection: EmbeddableStartDependencies['usageCollection'];
export let savedObjectsManagement: EmbeddableStartDependencies['savedObjectsManagement'];
export let savedObjectsTaggingOss: EmbeddableStartDependencies['savedObjectsTaggingOss'];
const servicesReady$ = new BehaviorSubject(false);
export const untilPluginStartServicesReady = () => {
if (servicesReady$.value) return Promise.resolve();
return new Promise<void>((resolve) => {
const subscription = servicesReady$.subscribe((isInitialized) => {
if (isInitialized) {
subscription.unsubscribe();
resolve();
}
});
});
};
export const setKibanaServices = (
kibanaCore: CoreStart,
selfStart: EmbeddableStart,
deps: EmbeddableStartDependencies
) => {
core = kibanaCore;
uiActions = deps.uiActions;
inspector = deps.inspector;
embeddableStart = selfStart;
usageCollection = deps.usageCollection;
savedObjectsManagement = deps.savedObjectsManagement;
savedObjectsTaggingOss = deps.savedObjectsTaggingOss;
servicesReady$.next(true);
};

View file

@ -1,9 +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.
*/
export * from './edit_panel_action';

View file

@ -1,97 +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 React from 'react';
import { EmbeddableChildPanel } from './embeddable_child_panel';
import { CONTACT_CARD_EMBEDDABLE } from '../test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { SlowContactCardEmbeddableFactory } from '../test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory';
import { HelloWorldContainer } from '../test_samples/embeddables/hello_world_container';
import {
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable,
} from '../test_samples/embeddables/contact_card/contact_card_embeddable';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { mount } from 'enzyme';
import { embeddablePluginMock, createEmbeddablePanelMock } from '../../mocks';
test('EmbeddableChildPanel renders an embeddable when it is done loading', async () => {
const inspector = inspectorPluginMock.createStartContract();
const { setup, doStart } = embeddablePluginMock.createInstance();
setup.registerEmbeddableFactory(
CONTACT_CARD_EMBEDDABLE,
new SlowContactCardEmbeddableFactory({ execAction: (() => null) as any })
);
const start = doStart();
const getEmbeddableFactory = start.getEmbeddableFactory;
const container = new HelloWorldContainer({ id: 'hello', panels: {} }, {
getEmbeddableFactory,
} as any);
const newEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Theon',
lastName: 'Greyjoy',
id: '123',
});
expect(newEmbeddable.id).toBeDefined();
const testPanel = createEmbeddablePanelMock({
getAllEmbeddableFactories: start.getEmbeddableFactories,
getEmbeddableFactory,
inspector,
});
const component = mount(
<EmbeddableChildPanel
container={container}
embeddableId={newEmbeddable.id}
PanelComponent={testPanel}
/>
);
await new Promise((r) => setTimeout(r, 1));
component.update();
// Due to the way embeddables mount themselves on the dom node, they are not forced to be
// react components, and hence, we can't use the usual
// findTestSubject(component, 'embeddablePanelHeading-HelloTheonGreyjoy');
expect(
component
.getDOMNode()
.querySelectorAll('[data-test-subj="embeddablePanelHeading-HelloTheonGreyjoy"]').length
).toBe(1);
});
test(`EmbeddableChildPanel renders an error message if the factory doesn't exist`, async () => {
const inspector = inspectorPluginMock.createStartContract();
const getEmbeddableFactory = () => undefined;
const container = new HelloWorldContainer(
{
id: 'hello',
panels: { '1': { type: 'idontexist', explicitInput: { id: '1' } } },
},
{ getEmbeddableFactory } as any
);
const testPanel = createEmbeddablePanelMock({ inspector });
const component = mount(
<EmbeddableChildPanel container={container} embeddableId={'1'} PanelComponent={testPanel} />
);
await new Promise((r) => setTimeout(r, 1));
component.update();
expect(
component.getDOMNode().querySelectorAll('[data-test-subj="embeddableStackError"]').length
).toBe(1);
});

View file

@ -1,140 +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 classNames from 'classnames';
import React from 'react';
import { EuiLoadingChart } from '@elastic/eui';
import { Subscription } from 'rxjs';
import { distinct, map } from 'rxjs/operators';
import { isNil } from 'lodash';
import { ErrorEmbeddable, IEmbeddable } from '../embeddables';
import { IContainer } from './i_container';
import { EmbeddableStart } from '../../plugin';
import { EmbeddableError, EmbeddableOutput } from '../embeddables/i_embeddable';
export type EmbeddablePhase = 'loading' | 'loaded' | 'rendered' | 'error';
export interface EmbeddablePhaseEvent {
id: string;
status: EmbeddablePhase;
error?: EmbeddableError;
timeToEvent: number;
}
export interface EmbeddableChildPanelProps {
embeddableId: string;
index?: number;
className?: string;
container: IContainer;
PanelComponent: EmbeddableStart['EmbeddablePanel'];
onPanelStatusChange?: (info: EmbeddablePhaseEvent) => void;
}
interface State {
firstTimeLoading: boolean;
}
/**
* This component can be used by embeddable containers using react to easily render children. It waits
* for the child to be initialized, showing a loading indicator until that is complete.
*/
export class EmbeddableChildPanel extends React.Component<EmbeddableChildPanelProps, State> {
[panel: string]: any;
public mounted: boolean;
public embeddable!: IEmbeddable | ErrorEmbeddable;
private subscription: Subscription = new Subscription();
constructor(props: EmbeddableChildPanelProps) {
super(props);
this.state = {
firstTimeLoading: true,
};
this.mounted = false;
}
private getEventStatus(output: EmbeddableOutput): EmbeddablePhase {
if (!isNil(output.error)) {
return 'error';
} else if (output.rendered === true) {
return 'rendered';
} else if (output.loading === false) {
return 'loaded';
} else {
return 'loading';
}
}
public async componentDidMount() {
this.mounted = true;
const { container } = this.props;
this.embeddable = await container.untilEmbeddableLoaded(this.props.embeddableId);
if (this.mounted) {
let loadingStartTime = 0;
this.subscription?.add(
this.embeddable
.getOutput$()
.pipe(
// Map loaded event properties
map((output) => {
if (output.loading === true) {
loadingStartTime = performance.now();
}
return {
id: this.embeddable.id,
status: this.getEventStatus(output),
error: output.error,
};
}),
// Dedupe
distinct((output) => loadingStartTime + output.id + output.status + !!output.error),
// Map loaded event properties
map((output): EmbeddablePhaseEvent => {
return {
...output,
timeToEvent: performance.now() - loadingStartTime,
};
})
)
.subscribe((statusOutput) => {
if (this.props.onPanelStatusChange) {
this.props.onPanelStatusChange(statusOutput);
}
})
);
this.setState({ firstTimeLoading: false });
}
}
public componentWillUnmount() {
this.mounted = false;
if (this.subscription) {
this.subscription.unsubscribe();
}
}
public render() {
const { PanelComponent, index } = this.props;
const classes = classNames('embPanel', {
'embPanel-isLoading': this.state.firstTimeLoading,
});
return (
<div className={classes}>
{this.state.firstTimeLoading || !this.embeddable ? (
<EuiLoadingChart size="l" mono />
) : (
<PanelComponent embeddable={this.embeddable} index={index} />
)}
</div>
);
}
}

View file

@ -14,4 +14,3 @@ export type {
EmbeddableContainerSettings,
} from './i_container';
export { Container } from './container';
export * from './embeddable_child_panel';

View file

@ -7,10 +7,12 @@
*/
import React, { ReactNode } from 'react';
import { EmbeddablePanelError } from '../panel/embeddable_panel_error';
import { Embeddable } from './embeddable';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { IContainer } from '../containers';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { EmbeddablePanelError } from '../../embeddable_panel/embeddable_panel_error';
import './error_embeddable.scss';
export const ERROR_EMBEDDABLE_TYPE = 'error';

View file

@ -9,10 +9,8 @@
export * from './errors';
export * from './embeddables';
export * from './types';
export * from './actions';
export * from './triggers';
export * from './containers';
export * from './panel';
export * from './state_transfer';
export * from './reference_or_value_embeddable';
export * from './self_styled_embeddable';

View file

@ -1 +0,0 @@
@import './embeddable_panel';

View file

@ -1,787 +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 React from 'react';
import { ReactWrapper, mount } from 'enzyme';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { I18nProvider } from '@kbn/i18n-react';
import { CONTEXT_MENU_TRIGGER } from '../triggers';
import { Action, UiActionsStart, ActionInternal } from '@kbn/ui-actions-plugin/public';
import { Trigger, ViewMode } from '../types';
import { isErrorEmbeddable } from '../embeddables';
import { EmbeddablePanel } from './embeddable_panel';
import {
createEditModeActionDefinition,
ContactCardEmbeddable,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddableFactory,
ContactCardEmbeddableReactFactory,
CONTACT_CARD_EMBEDDABLE,
CONTACT_CARD_EMBEDDABLE_REACT,
HelloWorldContainer,
} from '../test_samples';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { EuiBadge } from '@elastic/eui';
import { embeddablePluginMock } from '../../mocks';
import { applicationServiceMock, themeServiceMock } from '@kbn/core/public/mocks';
const actionRegistry = new Map<string, Action>();
const triggerRegistry = new Map<string, Trigger>();
const { setup, doStart } = embeddablePluginMock.createInstance();
const editModeAction = createEditModeActionDefinition();
const trigger: Trigger = {
id: CONTEXT_MENU_TRIGGER,
};
const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
const embeddableReactFactory = new ContactCardEmbeddableReactFactory(
(() => null) as any,
{} as any
);
const applicationMock = applicationServiceMock.createStartContract();
const theme = themeServiceMock.createStartContract();
actionRegistry.set(editModeAction.id, new ActionInternal(editModeAction));
triggerRegistry.set(trigger.id, trigger);
setup.registerEmbeddableFactory(embeddableFactory.type, embeddableFactory);
setup.registerEmbeddableFactory(embeddableReactFactory.type, embeddableReactFactory);
const start = doStart();
const getEmbeddableFactory = start.getEmbeddableFactory;
test('HelloWorldContainer initializes embeddables', (done) => {
const container = new HelloWorldContainer(
{
id: '123',
panels: {
'123': {
explicitInput: { id: '123', firstName: 'Sam' },
type: CONTACT_CARD_EMBEDDABLE,
},
},
},
{ getEmbeddableFactory } as any
);
const subscription = container.getOutput$().subscribe(() => {
if (container.getOutput().embeddableLoaded['123']) {
const embeddable = container.getChild<ContactCardEmbeddable>('123');
expect(embeddable).toBeDefined();
expect(embeddable.id).toBe('123');
done();
}
});
if (container.getOutput().embeddableLoaded['123']) {
const embeddable = container.getChild<ContactCardEmbeddable>('123');
expect(embeddable).toBeDefined();
expect(embeddable.id).toBe('123');
subscription.unsubscribe();
done();
}
});
test('HelloWorldContainer.addNewEmbeddable', async () => {
const container = new HelloWorldContainer({ id: '123', panels: {} }, {
getEmbeddableFactory,
} as any);
const embeddable = await container.addNewEmbeddable<ContactCardEmbeddableInput>(
CONTACT_CARD_EMBEDDABLE,
{
firstName: 'Kibana',
}
);
expect(embeddable).toBeDefined();
if (!isErrorEmbeddable(embeddable)) {
expect(embeddable.getInput().firstName).toBe('Kibana');
} else {
expect(false).toBe(true);
}
const embeddableInContainer = container.getChild<ContactCardEmbeddable>(embeddable.id);
expect(embeddableInContainer).toBeDefined();
expect(embeddableInContainer.id).toBe(embeddable.id);
});
test('Container view mode change propagates to children', async () => {
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Bob',
});
expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW);
container.updateInput({ viewMode: ViewMode.EDIT });
expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT);
});
test('HelloWorldContainer in view mode hides edit mode actions', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Bob',
});
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
application={applicationMock}
overlays={{} as any}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
await nextTick();
component.update();
expect(findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`).length).toBe(0);
});
describe('HelloWorldContainer in error state', () => {
let component: ReactWrapper<unknown>;
let embeddable: ContactCardEmbeddable;
beforeEach(async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
embeddable = (await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {})) as ContactCardEmbeddable;
component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
application={applicationMock}
overlays={{} as any}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
jest.spyOn(embeddable, 'catchError');
});
test('renders a custom error', () => {
embeddable.triggerError(new Error('something'));
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddable.catchError).toHaveBeenCalledWith(
new Error('something'),
expect.any(HTMLElement)
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
});
test('renders a custom fatal error', () => {
embeddable.triggerError(new Error('something'), true);
component.update();
component.mount();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddable.catchError).toHaveBeenCalledWith(
new Error('something'),
expect.any(HTMLElement)
);
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('something');
});
test('destroys previous error', () => {
const { catchError } = embeddable as Required<typeof embeddable>;
let destroyError: jest.MockedFunction<ReturnType<typeof catchError>>;
(embeddable.catchError as jest.MockedFunction<typeof catchError>).mockImplementationOnce(
(...args) => {
destroyError = jest.fn(catchError(...args));
return destroyError;
}
);
embeddable.triggerError(new Error('something'));
component.update();
embeddable.triggerError(new Error('another error'));
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('another error');
expect(destroyError!).toHaveBeenCalledTimes(1);
});
test('renders a default error', async () => {
embeddable.catchError = undefined;
embeddable.triggerError(new Error('something'));
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.children.length).toBeGreaterThan(0);
});
test('renders a React node', () => {
(embeddable.catchError as jest.Mock).mockReturnValueOnce(<div>Something</div>);
embeddable.triggerError(new Error('something'));
component.update();
const embeddableError = findTestSubject(component, 'embeddableError');
expect(embeddableError).toHaveProperty('length', 1);
expect(embeddableError.text()).toBe('Something');
});
});
const renderInEditModeAndOpenContextMenu = async (
embeddableInputs: any,
getActions: UiActionsStart['getTriggerCompatibleActions'] = () => Promise.resolve([])
) => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, embeddableInputs);
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={getActions}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
component.update();
return { component };
};
test('HelloWorldContainer in edit mode hides disabledActions', async () => {
const action = {
id: 'FOO',
type: 'FOO',
getIconType: () => undefined,
getDisplayName: () => 'foo',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return Promise.resolve(undefined);
},
};
const getActions = () => Promise.resolve([action]);
const { component: component1 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
},
getActions
);
const { component: component2 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
disabledActions: ['FOO'],
},
getActions
);
const fooContextMenuActionItem1 = findTestSubject(component1, 'embeddablePanelAction-FOO');
const fooContextMenuActionItem2 = findTestSubject(component2, 'embeddablePanelAction-FOO');
expect(fooContextMenuActionItem1.length).toBe(1);
expect(fooContextMenuActionItem2.length).toBe(0);
});
test('HelloWorldContainer hides disabled badges', async () => {
const action = {
id: 'BAR',
type: 'BAR',
getIconType: () => undefined,
getDisplayName: () => 'bar',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return Promise.resolve(undefined);
},
};
const getActions = () => Promise.resolve([action]);
const { component: component1 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
},
getActions
);
const { component: component2 } = await renderInEditModeAndOpenContextMenu(
{
firstName: 'Bob',
disabledActions: ['BAR'],
},
getActions
);
expect(component1.find(EuiBadge).length).toBe(1);
expect(component2.find(EuiBadge).length).toBe(0);
});
test('HelloWorldContainer in edit mode shows edit mode actions', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Bob',
});
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon');
expect(button.length).toBe(1);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
await nextTick();
component.update();
expect(findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`).length).toBe(0);
container.updateInput({ viewMode: ViewMode.EDIT });
await nextTick();
component.update();
// Need to close and re-open to refresh. It doesn't update automatically.
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
await nextTick();
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1);
container.updateInput({ viewMode: ViewMode.VIEW });
await nextTick();
component.update();
// TODO: Fix this.
// const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`);
// expect(action.length).toBe(1);
});
test('Panel title customize link does not exist in view mode', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Vayon',
lastName: 'Poole',
});
const component = mountWithIntl(
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
);
const titleLink = findTestSubject(component, 'embeddablePanelTitleLink');
expect(titleLink.length).toBe(0);
});
test('Runs customize panel action on title click when in edit mode', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.EDIT, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Vayon',
lastName: 'Poole',
});
const component = mountWithIntl(
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
);
const titleExecute = jest.fn();
component.setState((s: any) => ({
...s,
universalActions: {
...s.universalActions,
customizePanel: { execute: titleExecute, isCompatible: jest.fn() },
},
}));
const titleLink = findTestSubject(component, 'embeddablePanelTitleLink');
expect(titleLink.length).toBe(1);
titleLink.simulate('click');
await nextTick();
expect(titleExecute).toHaveBeenCalledTimes(1);
});
test('Updates when hidePanelTitles is toggled', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Rob',
lastName: 'Stark',
});
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
let title = findTestSubject(component, `embeddablePanelHeading-HelloRobStark`);
expect(title.length).toBe(1);
await container.updateInput({ hidePanelTitles: true });
await nextTick();
component.update();
title = findTestSubject(component, `embeddablePanelHeading-HelloRobStark`);
expect(title.length).toBe(0);
await container.updateInput({ hidePanelTitles: false });
await nextTick();
component.update();
title = findTestSubject(component, `embeddablePanelHeading-HelloRobStark`);
expect(title.length).toBe(1);
});
test('Respects options from SelfStyledEmbeddable', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const contactCardEmbeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Rob',
lastName: 'Stark',
});
const selfStyledEmbeddable = embeddablePluginMock.mockSelfStyledEmbeddable(
contactCardEmbeddable,
{ hideTitle: true }
);
// make sure the title is being hidden because of the self styling, not the container
container.updateInput({ hidePanelTitles: false });
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={selfStyledEmbeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
const title = findTestSubject(component, `embeddablePanelHeading-HelloRobStark`);
expect(title.length).toBe(0);
});
test('Check when hide header option is false', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Arya',
lastName: 'Stark',
});
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
inspector={inspector}
SavedObjectFinder={() => null}
hideHeader={false}
theme={theme}
/>
</I18nProvider>
);
const title = findTestSubject(component, `embeddablePanelHeading-HelloAryaStark`);
expect(title.length).toBe(1);
});
test('Check when hide header option is true', async () => {
const inspector = inspectorPluginMock.createStartContract();
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Arya',
lastName: 'Stark',
});
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={{} as any}
inspector={inspector}
SavedObjectFinder={() => null}
hideHeader={true}
theme={theme}
/>
</I18nProvider>
);
const title = findTestSubject(component, `embeddablePanelHeading-HelloAryaStark`);
expect(title.length).toBe(0);
});
test('Should work in minimal way rendering only the inspector action', async () => {
const inspector = inspectorPluginMock.createStartContract();
inspector.isAvailable = jest.fn(() => true);
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
getEmbeddableFactory,
} as any);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Arya',
lastName: 'Stark',
});
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
inspector={inspector}
hideHeader={false}
theme={theme}
/>
</I18nProvider>
);
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
await nextTick();
component.update();
expect(findTestSubject(component, `embeddablePanelAction-openInspector`).length).toBe(1);
const action = findTestSubject(component, `embeddablePanelAction-ACTION_CUSTOMIZE_PANEL`);
expect(action.length).toBe(0);
});
test('Renders an embeddable returning a React node', async () => {
const container = new HelloWorldContainer(
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
{ getEmbeddableFactory } as any
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE_REACT, {
firstName: 'Bran',
lastName: 'Stark',
});
const component = mount(
<I18nProvider>
<EmbeddablePanel
embeddable={embeddable}
getActions={() => Promise.resolve([])}
getAllEmbeddableFactories={start.getEmbeddableFactories}
getEmbeddableFactory={start.getEmbeddableFactory}
notifications={{} as any}
overlays={{} as any}
application={applicationMock}
SavedObjectFinder={() => null}
theme={theme}
/>
</I18nProvider>
);
expect(component.find('.embPanel__titleText').text()).toBe('Hello Bran Stark');
});

View file

@ -1,476 +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 {
EuiContextMenuPanelDescriptor,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
htmlIdGenerator,
} from '@elastic/eui';
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import { Subscription } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { CoreStart, ThemeServiceStart } from '@kbn/core/public';
import { isPromise } from '@kbn/std';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { MaybePromise } from '@kbn/utility-types';
import { buildContextMenuForActions, UiActionsService, Action } from '../ui_actions';
import { Start as InspectorStartContract } from '../inspector';
import {
CONTEXT_MENU_TRIGGER,
PANEL_BADGE_TRIGGER,
PANEL_NOTIFICATION_TRIGGER,
EmbeddableContext,
contextMenuTrigger,
} from '../triggers';
import {
EmbeddableErrorHandler,
EmbeddableInput,
EmbeddableOutput,
IEmbeddable,
} from '../embeddables';
import { ViewMode } from '../types';
import { EmbeddablePanelError } from './embeddable_panel_error';
import { RemovePanelAction } from './panel_header/panel_actions';
import { AddPanelAction } from './panel_header/panel_actions/add_panel/add_panel_action';
import { CustomizePanelAction } from './panel_header/panel_actions/customize_panel/customize_panel_action';
import { PanelHeader } from './panel_header/panel_header';
import { InspectPanelAction } from './panel_header/panel_actions/inspect_panel_action';
import { EditPanelAction } from '../actions';
import { EmbeddableStart } from '../../plugin';
import { EmbeddableStateTransfer, isSelfStyledEmbeddable, CommonlyUsedRange } from '..';
const sortByOrderField = (
{ order: orderA }: { order?: number },
{ order: orderB }: { order?: number }
) => (orderB || 0) - (orderA || 0);
const removeById =
(disabledActions: string[]) =>
({ id }: { id: string }) =>
disabledActions.indexOf(id) === -1;
/**
* Embeddable container may provide information about its environment,
* Use it for drilling down data that is static or doesn't have to be reactive,
* otherwise prefer passing data with input$
* */
export interface EmbeddableContainerContext {
/**
* Current app's path including query and hash starting from {appId}
*/
getCurrentPath?: () => string;
}
interface Props {
embeddable: IEmbeddable<EmbeddableInput, EmbeddableOutput, MaybePromise<ReactNode>>;
/**
* Ordinal number of the embeddable in the container, used as a
* "title" when the panel has no title, i.e. "Panel {index}".
*/
index?: number;
getActions?: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'];
getAllEmbeddableFactories?: EmbeddableStart['getEmbeddableFactories'];
dateFormat?: string;
commonlyUsedRanges?: CommonlyUsedRange[];
overlays?: CoreStart['overlays'];
notifications?: CoreStart['notifications'];
application?: CoreStart['application'];
inspector?: InspectorStartContract;
SavedObjectFinder?: React.ComponentType<any>;
stateTransfer?: EmbeddableStateTransfer;
hideHeader?: boolean;
actionPredicate?: (actionId: string) => boolean;
reportUiCounter?: UsageCollectionStart['reportUiCounter'];
showShadow?: boolean;
showBadges?: boolean;
showNotifications?: boolean;
containerContext?: EmbeddableContainerContext;
theme: ThemeServiceStart;
}
interface State {
panels: EuiContextMenuPanelDescriptor[];
universalActions: PanelUniversalActions;
focusedPanelIndex?: string;
viewMode: ViewMode;
hidePanelTitle: boolean;
closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;
loading?: boolean;
error?: Error;
destroyError?(): void;
node?: ReactNode;
}
interface InspectorPanelAction {
inspectPanel: InspectPanelAction;
}
interface BasePanelActions {
customizePanel: CustomizePanelAction;
addPanel: AddPanelAction;
inspectPanel: InspectPanelAction;
removePanel: RemovePanelAction;
editPanel: EditPanelAction;
}
interface PanelUniversalActions extends Partial<InspectorPanelAction>, Partial<BasePanelActions> {}
export class EmbeddablePanel extends React.Component<Props, State> {
private embeddableRoot = React.createRef<HTMLDivElement>();
private parentSubscription?: Subscription;
private subscription: Subscription = new Subscription();
private mounted: boolean = false;
private generateId = htmlIdGenerator();
constructor(props: Props) {
super(props);
const { embeddable } = this.props;
const viewMode = embeddable.getInput().viewMode ?? ViewMode.EDIT;
const hidePanelTitle =
Boolean(embeddable.parent?.getInput()?.hidePanelTitles) ||
Boolean(embeddable.getInput()?.hidePanelTitles);
this.state = {
universalActions: this.getUniversalActions(),
panels: [],
viewMode,
hidePanelTitle,
closeContextMenu: false,
badges: [],
notifications: [],
};
}
private async refreshBadges() {
if (this.props.showBadges === false) {
return;
}
type BadgeAction = Action<
EmbeddableContext<IEmbeddable<EmbeddableInput, EmbeddableOutput, any>>
>;
let badges: BadgeAction[] =
((await this.props.getActions?.(PANEL_BADGE_TRIGGER, {
embeddable: this.props.embeddable,
})) as BadgeAction[]) ?? [];
if (!this.mounted) {
return;
}
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
badges = badges.filter((badge) => disabledActions.indexOf(badge.id) === -1);
}
if (!deepEqual(this.state.badges, badges)) {
this.setState({
badges,
});
}
}
private async refreshNotifications() {
if (this.props.showNotifications === false) {
return;
}
type NotificationAction = Action<
EmbeddableContext<IEmbeddable<EmbeddableInput, EmbeddableOutput, any>>
>;
let notifications: NotificationAction[] =
((await this.props.getActions?.(PANEL_NOTIFICATION_TRIGGER, {
embeddable: this.props.embeddable,
})) as NotificationAction[]) ?? [];
if (!this.mounted) {
return;
}
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
notifications = notifications.filter((badge) => disabledActions.indexOf(badge.id) === -1);
}
if (!deepEqual(this.state.notifications, notifications)) {
this.setState({
notifications,
});
}
}
public UNSAFE_componentWillMount() {
this.mounted = true;
const { embeddable } = this.props;
const { parent } = embeddable;
this.subscription.add(
embeddable.getInput$().subscribe(async () => {
if (this.mounted) {
this.setState({
viewMode: embeddable.getInput().viewMode ?? ViewMode.EDIT,
});
this.refreshBadges();
this.refreshNotifications();
}
})
);
if (parent) {
this.parentSubscription = parent.getInput$().subscribe(async () => {
if (this.mounted && parent) {
this.setState({
hidePanelTitle:
Boolean(embeddable.parent?.getInput()?.hidePanelTitles) ||
Boolean(embeddable.getInput()?.hidePanelTitles),
});
this.refreshBadges();
this.refreshNotifications();
}
});
}
}
public componentWillUnmount() {
this.mounted = false;
this.subscription.unsubscribe();
if (this.parentSubscription) {
this.parentSubscription.unsubscribe();
}
this.state.destroyError?.();
this.props.embeddable.destroy();
}
public onFocus = (focusedPanelIndex: string) => {
this.setState({ focusedPanelIndex });
};
public onBlur = (blurredPanelIndex: string) => {
if (this.state.focusedPanelIndex === blurredPanelIndex) {
this.setState({ focusedPanelIndex: undefined });
}
};
public render() {
const viewOnlyMode = [ViewMode.VIEW, ViewMode.PRINT].includes(this.state.viewMode);
const classes = classNames('embPanel', {
'embPanel--editing': !viewOnlyMode,
'embPanel--loading': this.state.loading,
});
const contentAttrs: { [key: string]: boolean } = {};
if (this.state.loading) contentAttrs['data-loading'] = true;
if (this.state.error) contentAttrs['data-error'] = true;
const title = this.props.embeddable.getTitle();
const description = this.props.embeddable.getDescription();
const headerId = this.generateId();
const selfStyledOptions = isSelfStyledEmbeddable(this.props.embeddable)
? this.props.embeddable.getSelfStyledOptions()
: undefined;
return (
<EuiPanel
className={classes}
data-test-subj="embeddablePanel"
data-test-embeddable-id={this.props.embeddable.id}
paddingSize="none"
role="figure"
aria-labelledby={headerId}
hasShadow={this.props.showShadow}
>
{!this.props.hideHeader && (
<PanelHeader
getActionContextMenuPanel={this.getActionContextMenuPanel}
hidePanelTitle={this.state.hidePanelTitle || !!selfStyledOptions?.hideTitle}
isViewMode={viewOnlyMode}
customizePanel={
'customizePanel' in this.state.universalActions
? this.state.universalActions.customizePanel
: undefined
}
closeContextMenu={this.state.closeContextMenu}
title={title}
description={description}
index={this.props.index}
badges={this.state.badges}
notifications={this.state.notifications}
embeddable={this.props.embeddable}
headerId={headerId}
/>
)}
{this.state.error && (
<EuiFlexGroup
alignItems="center"
className="eui-fullHeight embPanel__error"
data-test-subj="embeddableError"
justifyContent="center"
>
<EuiFlexItem>
<EmbeddableErrorHandler embeddable={this.props.embeddable} error={this.state.error}>
{(error) => (
<EmbeddablePanelError
editPanelAction={this.state.universalActions.editPanel}
embeddable={this.props.embeddable}
error={error}
/>
)}
</EmbeddableErrorHandler>
</EuiFlexItem>
</EuiFlexGroup>
)}
<div className="embPanel__content" ref={this.embeddableRoot} {...contentAttrs}>
{this.state.node}
</div>
</EuiPanel>
);
}
public componentDidMount() {
if (!this.embeddableRoot.current) {
return;
}
this.subscription.add(
this.props.embeddable.getOutput$().subscribe(
(output: EmbeddableOutput) => {
if (this.mounted) {
this.setState({
error: output.error,
loading: output.loading,
});
}
},
(error) => {
if (this.mounted) {
this.setState({ error });
}
}
)
);
const node = this.props.embeddable.render(this.embeddableRoot.current) ?? undefined;
if (isPromise(node)) {
node.then((resolved) => {
if (this.mounted) {
this.setState({ node: resolved });
}
});
} else {
this.setState({ node });
}
}
closeMyContextMenuPanel = () => {
if (this.mounted) {
this.setState({ closeContextMenu: true }, () => {
if (this.mounted) {
this.setState({ closeContextMenu: false });
}
});
}
};
private getUniversalActions = (): PanelUniversalActions => {
let actions = {};
if (this.props.inspector) {
actions = {
inspectPanel: new InspectPanelAction(this.props.inspector),
};
}
if (
!this.props.getEmbeddableFactory ||
!this.props.getAllEmbeddableFactories ||
!this.props.overlays ||
!this.props.notifications ||
!this.props.SavedObjectFinder ||
!this.props.application
) {
return actions;
}
// Universal actions are exposed on the context menu for every embeddable, they bypass the trigger
// registry.
return {
...actions,
customizePanel: new CustomizePanelAction(
this.props.overlays,
this.props.theme,
this.props.commonlyUsedRanges,
this.props.dateFormat
),
addPanel: new AddPanelAction(
this.props.getEmbeddableFactory,
this.props.getAllEmbeddableFactories,
this.props.overlays,
this.props.notifications,
this.props.SavedObjectFinder,
this.props.theme,
this.props.reportUiCounter
),
removePanel: new RemovePanelAction(),
editPanel: new EditPanelAction(
this.props.getEmbeddableFactory,
this.props.application,
this.props.stateTransfer,
this.props.containerContext?.getCurrentPath
),
};
};
private getActionContextMenuPanel = async () => {
let regularActions =
(await this.props.getActions?.(CONTEXT_MENU_TRIGGER, {
embeddable: this.props.embeddable,
})) ?? [];
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
const removeDisabledActions = removeById(disabledActions);
regularActions = regularActions.filter(removeDisabledActions);
}
let sortedActions = regularActions
.concat(Object.values(this.state.universalActions || {}) as Array<Action<object>>)
.sort(sortByOrderField);
if (this.props.actionPredicate) {
sortedActions = sortedActions.filter(({ id }) => this.props.actionPredicate!(id));
}
const panels = await buildContextMenuForActions({
actions: sortedActions.map((action) => ({
action,
context: { embeddable: this.props.embeddable },
trigger: contextMenuTrigger,
})),
closeMenu: this.closeMyContextMenuPanel,
});
return {
panels,
actions: sortedActions,
};
};
}

View file

@ -1 +0,0 @@
@import './panel_options_menu_form';

View file

@ -1,3 +0,0 @@
.embPanel__optionsMenuForm {
padding: $euiSize;
}

View file

@ -1,9 +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.
*/
export * from './panel_actions';

View file

@ -1,125 +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 { ViewMode, EmbeddableOutput, isErrorEmbeddable } from '../../../..';
import { AddPanelAction } from './add_panel_action';
import {
MockFilter,
FILTERABLE_EMBEDDABLE,
FilterableEmbeddable,
FilterableEmbeddableInput,
} from '../../../../test_samples/embeddables/filterable_embeddable';
import { FilterableEmbeddableFactory } from '../../../../test_samples/embeddables/filterable_embeddable_factory';
import { FilterableContainer } from '../../../../test_samples/embeddables/filterable_container';
import { coreMock, themeServiceMock } from '@kbn/core/public/mocks';
import { ContactCardEmbeddable } from '../../../../test_samples';
import { EmbeddableStart } from '../../../../../plugin';
import { embeddablePluginMock } from '../../../../../mocks';
import { defaultTrigger } from '@kbn/ui-actions-browser/src/triggers/default_trigger';
const { setup, doStart } = embeddablePluginMock.createInstance();
setup.registerEmbeddableFactory(FILTERABLE_EMBEDDABLE, new FilterableEmbeddableFactory());
const getFactory = doStart().getEmbeddableFactory;
const theme = themeServiceMock.createStartContract();
let container: FilterableContainer;
let embeddable: FilterableEmbeddable;
let action: AddPanelAction;
beforeEach(async () => {
const start = coreMock.createStart();
action = new AddPanelAction(
() => undefined,
() => [] as any,
start.overlays,
start.notifications,
() => null,
theme
);
const derivedFilter: MockFilter = {
$state: { store: 'appState' },
meta: { disabled: false, alias: 'name', negate: false },
query: { match: {} },
};
container = new FilterableContainer(
{ id: 'hello', panels: {}, filters: [derivedFilter] },
getFactory as EmbeddableStart['getEmbeddableFactory']
);
const filterableEmbeddable = await container.addNewEmbeddable<
FilterableEmbeddableInput,
EmbeddableOutput,
FilterableEmbeddable
>(FILTERABLE_EMBEDDABLE, {
id: '123',
});
if (isErrorEmbeddable<FilterableEmbeddable>(filterableEmbeddable)) {
throw new Error('Error creating new filterable embeddable');
} else {
embeddable = filterableEmbeddable;
}
});
test('Is not compatible when container is in view mode', async () => {
const start = coreMock.createStart();
const addPanelAction = new AddPanelAction(
() => undefined,
() => [] as any,
start.overlays,
start.notifications,
() => null,
theme
);
container.updateInput({ viewMode: ViewMode.VIEW });
expect(
await addPanelAction.isCompatible({ embeddable: container, trigger: defaultTrigger })
).toBe(false);
});
test('Is not compatible when embeddable is not a container', async () => {
expect(await action.isCompatible({ embeddable } as any)).toBe(false);
});
test('Is compatible when embeddable is a parent and in edit mode', async () => {
container.updateInput({ viewMode: ViewMode.EDIT });
expect(await action.isCompatible({ embeddable: container, trigger: defaultTrigger })).toBe(true);
});
test('Execute throws an error when called with an embeddable that is not a container', async () => {
async function check() {
await action.execute({
embeddable: new ContactCardEmbeddable(
{
firstName: 'sue',
id: '123',
viewMode: ViewMode.EDIT,
},
{} as any
),
trigger: defaultTrigger,
} as any);
}
await expect(check()).rejects.toThrow(Error);
});
test('Execute does not throw an error when called with a compatible container', async () => {
container.updateInput({ viewMode: ViewMode.EDIT });
await action.execute({
embeddable: container,
trigger: defaultTrigger,
});
});
test('Returns title', async () => {
expect(action.getDisplayName()).toBeDefined();
});
test('Returns an icon', async () => {
expect(action.getIconType()).toBeDefined();
});

View file

@ -1,70 +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 { i18n } from '@kbn/i18n';
import { Action, ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { NotificationsStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { EmbeddableStart } from '../../../../../plugin';
import { ViewMode } from '../../../../types';
import { openAddPanelFlyout } from './open_add_panel_flyout';
import { IContainer } from '../../../../containers';
export const ACTION_ADD_PANEL = 'ACTION_ADD_PANEL';
interface ActionContext {
embeddable: IContainer;
}
export class AddPanelAction implements Action<ActionContext> {
public readonly type = ACTION_ADD_PANEL;
public readonly id = ACTION_ADD_PANEL;
constructor(
private readonly getFactory: EmbeddableStart['getEmbeddableFactory'],
private readonly getAllFactories: EmbeddableStart['getEmbeddableFactories'],
private readonly overlays: OverlayStart,
private readonly notifications: NotificationsStart,
private readonly SavedObjectFinder: React.ComponentType<any>,
private readonly theme: ThemeServiceStart,
private readonly reportUiCounter?: UsageCollectionStart['reportUiCounter']
) {}
public getDisplayName() {
return i18n.translate('embeddableApi.addPanel.displayName', {
defaultMessage: 'Add panel',
});
}
public getIconType() {
return 'plusInCircleFilled';
}
public async isCompatible(context: ActionExecutionContext<ActionContext>) {
const { embeddable } = context;
return embeddable.getIsContainer() && embeddable.getInput().viewMode === ViewMode.EDIT;
}
public async execute(context: ActionExecutionContext<ActionContext>) {
const { embeddable } = context;
if (!embeddable.getIsContainer() || !(await this.isCompatible(context))) {
throw new Error('Context is incompatible');
}
openAddPanelFlyout({
embeddable,
getFactory: this.getFactory,
getAllFactories: this.getAllFactories,
overlays: this.overlays,
notifications: this.notifications,
SavedObjectFinder: this.SavedObjectFinder,
reportUiCounter: this.reportUiCounter,
theme: this.theme,
});
}
}

View file

@ -1,129 +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 * as React from 'react';
import { EuiFlyout } from '@elastic/eui';
import { AddPanelFlyout } from './add_panel_flyout';
import {
ContactCardEmbeddableFactory,
CONTACT_CARD_EMBEDDABLE,
} from '../../../../test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { HelloWorldContainer } from '../../../../test_samples/embeddables/hello_world_container';
import { ContactCardEmbeddable } from '../../../../test_samples/embeddables/contact_card/contact_card_embeddable';
import { ContainerInput } from '../../../../containers';
import { mountWithIntl as mount } from '@kbn/test-jest-helpers';
import { ReactWrapper } from 'enzyme';
import { coreMock } from '@kbn/core/public/mocks';
import { findTestSubject } from '@elastic/eui/lib/test';
import { embeddablePluginMock } from '../../../../../mocks';
function DummySavedObjectFinder(props: { children: React.ReactNode }) {
return (
<div>
<div>Hello World</div>
{props.children}
</div>
) as JSX.Element;
}
test('createNewEmbeddable() add embeddable to container', async () => {
const { setup, doStart } = embeddablePluginMock.createInstance();
const core = coreMock.createStart();
const { overlays } = core;
const contactCardEmbeddableFactory = new ContactCardEmbeddableFactory(
(() => null) as any,
overlays
);
contactCardEmbeddableFactory.getExplicitInput = () =>
({
firstName: 'foo',
lastName: 'bar',
} as any);
setup.registerEmbeddableFactory(CONTACT_CARD_EMBEDDABLE, contactCardEmbeddableFactory);
const start = doStart();
const getEmbeddableFactory = start.getEmbeddableFactory;
const input: ContainerInput<{ firstName: string; lastName: string }> = {
id: '1',
panels: {},
};
const container = new HelloWorldContainer(input, { getEmbeddableFactory } as any);
const onClose = jest.fn();
const component = mount(
<AddPanelFlyout
container={container}
onClose={onClose}
getFactory={getEmbeddableFactory}
getAllFactories={start.getEmbeddableFactories}
notifications={core.notifications}
SavedObjectFinder={() => null}
showCreateNewMenu
/>
) as ReactWrapper<unknown, unknown, AddPanelFlyout>;
// https://github.com/elastic/kibana/issues/64789
expect(component.exists(EuiFlyout)).toBe(false);
expect(Object.values(container.getInput().panels).length).toBe(0);
component.instance().createNewEmbeddable(CONTACT_CARD_EMBEDDABLE);
await new Promise((r) => setTimeout(r, 1));
const ids = Object.keys(container.getInput().panels);
const embeddableId = ids[0];
const child = container.getChild<ContactCardEmbeddable>(embeddableId);
expect(child.getInput()).toMatchObject({
firstName: 'foo',
lastName: 'bar',
});
});
test('selecting embeddable in "Create new ..." list calls createNewEmbeddable()', async () => {
const { setup, doStart } = embeddablePluginMock.createInstance();
const core = coreMock.createStart();
const { overlays } = core;
const contactCardEmbeddableFactory = new ContactCardEmbeddableFactory(
(() => null) as any,
overlays
);
contactCardEmbeddableFactory.getExplicitInput = () =>
({
firstName: 'foo',
lastName: 'bar',
} as any);
setup.registerEmbeddableFactory(CONTACT_CARD_EMBEDDABLE, contactCardEmbeddableFactory);
const start = doStart();
const getEmbeddableFactory = start.getEmbeddableFactory;
const input: ContainerInput<{ firstName: string; lastName: string }> = {
id: '1',
panels: {},
};
const container = new HelloWorldContainer(input, { getEmbeddableFactory } as any);
const onClose = jest.fn();
const component = mount(
<AddPanelFlyout
container={container}
onClose={onClose}
getFactory={getEmbeddableFactory}
getAllFactories={start.getEmbeddableFactories}
notifications={core.notifications}
SavedObjectFinder={(props) => <DummySavedObjectFinder {...props} />}
showCreateNewMenu
/>
) as ReactWrapper<any, {}, AddPanelFlyout>;
const spy = jest.fn();
component.instance().createNewEmbeddable = spy;
expect(spy).toHaveBeenCalledTimes(0);
findTestSubject(component, 'createNew').simulate('click');
findTestSubject(component, `createNew-${CONTACT_CARD_EMBEDDABLE}`).simulate('click');
expect(spy).toHaveBeenCalledTimes(1);
});

View file

@ -1,191 +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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { ReactElement } from 'react';
import { METRIC_TYPE } from '@kbn/analytics';
import { CoreSetup, SavedObjectAttributes, SimpleSavedObject, Toast } from '@kbn/core/public';
import { EuiContextMenuItem, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { EmbeddableFactory, EmbeddableStart } from '../../../../..';
import { IContainer } from '../../../../containers';
import { EmbeddableFactoryNotFoundError } from '../../../../errors';
import { SavedObjectFinderCreateNew } from './saved_object_finder_create_new';
import { SavedObjectEmbeddableInput } from '../../../../embeddables';
interface Props {
onClose: () => void;
container: IContainer;
getFactory: EmbeddableStart['getEmbeddableFactory'];
getAllFactories: EmbeddableStart['getEmbeddableFactories'];
notifications: CoreSetup['notifications'];
SavedObjectFinder: React.ComponentType<any>;
showCreateNewMenu?: boolean;
reportUiCounter?: UsageCollectionStart['reportUiCounter'];
onAddPanel?: (id: string) => void;
}
interface State {
isCreateMenuOpen: boolean;
}
function capitalize([first, ...letters]: string) {
return `${first.toUpperCase()}${letters.join('')}`;
}
export class AddPanelFlyout extends React.Component<Props, State> {
private lastToast?: string | Toast;
public state = {
isCreateMenuOpen: false,
};
constructor(props: Props) {
super(props);
}
public showToast = (name: string) => {
// To avoid the clutter of having toast messages cover flyout
// close previous toast message before creating a new one
if (this.lastToast) {
this.props.notifications.toasts.remove(this.lastToast);
}
this.lastToast = this.props.notifications.toasts.addSuccess({
title: i18n.translate(
'embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle',
{
defaultMessage: '{savedObjectName} was added',
values: {
savedObjectName: name,
},
}
),
'data-test-subj': 'addObjectToContainerSuccess',
});
};
public createNewEmbeddable = async (type: string) => {
this.props.onClose();
const factory = this.props.getFactory(type);
if (!factory) {
throw new EmbeddableFactoryNotFoundError(type);
}
const explicitInput = await factory.getExplicitInput();
const embeddable = await this.props.container.addNewEmbeddable(type, explicitInput);
if (embeddable) {
this.showToast(embeddable.getInput().title || '');
}
};
public onAddPanel = async (
savedObjectId: string,
savedObjectType: string,
name: string,
so: SimpleSavedObject<SavedObjectAttributes>
) => {
const factoryForSavedObjectType = [...this.props.getAllFactories()].find(
(factory) =>
factory.savedObjectMetaData && factory.savedObjectMetaData.type === savedObjectType
);
if (!factoryForSavedObjectType) {
throw new EmbeddableFactoryNotFoundError(savedObjectType);
}
const embeddable = await this.props.container.addNewEmbeddable<SavedObjectEmbeddableInput>(
factoryForSavedObjectType.type,
{ savedObjectId }
);
this.doTelemetryForAddEvent(this.props.container.type, factoryForSavedObjectType, so);
this.showToast(name);
if (this.props.onAddPanel) {
this.props.onAddPanel(embeddable.id);
}
};
private doTelemetryForAddEvent(
appName: string,
factoryForSavedObjectType: EmbeddableFactory,
so: SimpleSavedObject<SavedObjectAttributes>
) {
const { reportUiCounter } = this.props;
if (reportUiCounter) {
const type = factoryForSavedObjectType.savedObjectMetaData?.getSavedObjectSubType
? factoryForSavedObjectType.savedObjectMetaData.getSavedObjectSubType(so)
: factoryForSavedObjectType.type;
reportUiCounter(appName, METRIC_TYPE.CLICK, `${type}:add`);
}
}
private getCreateMenuItems(): ReactElement[] {
return [...this.props.getAllFactories()]
.filter(
// @ts-expect-error ts 4.5 upgrade
(factory) => factory.isEditable() && !factory.isContainerType && factory.canCreateNew()
)
.map((factory) => (
<EuiContextMenuItem
key={factory.type}
data-test-subj={`createNew-${factory.type}`}
onClick={() => this.createNewEmbeddable(factory.type)}
className="embPanel__addItem"
>
{capitalize(factory.getDisplayName())}
</EuiContextMenuItem>
));
}
public render() {
const SavedObjectFinder = this.props.SavedObjectFinder;
const metaData = [...this.props.getAllFactories()]
.filter(
(embeddableFactory) =>
Boolean(embeddableFactory.savedObjectMetaData) && !embeddableFactory.isContainerType
)
.map(({ savedObjectMetaData }) => savedObjectMetaData);
const savedObjectsFinder = (
<SavedObjectFinder
onChoose={this.onAddPanel}
savedObjectMetaData={metaData}
showFilter={true}
noItemsMessage={i18n.translate('embeddableApi.addPanel.noMatchingObjectsMessage', {
defaultMessage: 'No matching objects found.',
})}
>
{this.props.showCreateNewMenu ? (
<SavedObjectFinderCreateNew menuItems={this.getCreateMenuItems()} />
) : null}
</SavedObjectFinder>
);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="embeddableApi.addPanel.Title"
defaultMessage="Add from library"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>{savedObjectsFinder}</EuiFlyoutBody>
</>
);
}
}

View file

@ -1,10 +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.
*/
export * from './add_panel_action';
export * from './open_add_panel_flyout';

View file

@ -1,66 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 React from 'react';
import { NotificationsStart, OverlayRef, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { EmbeddableStart } from '../../../../../plugin';
import { IContainer } from '../../../../containers';
import { AddPanelFlyout } from './add_panel_flyout';
export function openAddPanelFlyout(options: {
embeddable: IContainer;
getFactory: EmbeddableStart['getEmbeddableFactory'];
getAllFactories: EmbeddableStart['getEmbeddableFactories'];
overlays: OverlayStart;
notifications: NotificationsStart;
SavedObjectFinder: React.ComponentType<any>;
showCreateNewMenu?: boolean;
reportUiCounter?: UsageCollectionStart['reportUiCounter'];
theme: ThemeServiceStart;
onAddPanel?: (id: string) => void;
}): OverlayRef {
const {
embeddable,
getFactory,
getAllFactories,
overlays,
notifications,
SavedObjectFinder,
showCreateNewMenu,
reportUiCounter,
theme,
onAddPanel,
} = options;
const flyoutSession = overlays.openFlyout(
toMountPoint(
<AddPanelFlyout
container={embeddable}
onAddPanel={onAddPanel}
onClose={() => {
if (flyoutSession) {
flyoutSession.close();
}
}}
getFactory={getFactory}
getAllFactories={getAllFactories}
notifications={notifications}
reportUiCounter={reportUiCounter}
SavedObjectFinder={SavedObjectFinder}
showCreateNewMenu={showCreateNewMenu}
/>,
{ theme$: theme.theme$ }
),
{
'data-test-subj': 'dashboardAddPanel',
ownFocus: true,
}
);
return flyoutSession;
}

View file

@ -1,52 +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 React, { ReactElement, useState } from 'react';
import { EuiButton } from '@elastic/eui';
import { EuiContextMenuPanel } from '@elastic/eui';
import { EuiPopover } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
interface Props {
menuItems: ReactElement[];
}
export function SavedObjectFinderCreateNew({ menuItems }: Props) {
const [isCreateMenuOpen, setCreateMenuOpen] = useState(false);
const toggleCreateMenu = () => {
setCreateMenuOpen(!isCreateMenuOpen);
};
const closeCreateMenu = () => {
setCreateMenuOpen(false);
};
return (
<EuiPopover
id="createNew"
button={
<EuiButton
data-test-subj="createNew"
iconType="plusInCircle"
iconSide="left"
onClick={toggleCreateMenu}
fill
>
<FormattedMessage
id="embeddableApi.addPanel.createNewDefaultOption"
defaultMessage="Create new"
/>
</EuiButton>
}
isOpen={isCreateMenuOpen}
closePopover={closeCreateMenu}
panelPaddingSize="none"
anchorPosition="downRight"
>
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
);
}

View file

@ -1,77 +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 React from 'react';
import { SavedObjectFinderCreateNew } from '../saved_object_finder_create_new';
import { shallow } from 'enzyme';
import { EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
describe('SavedObjectFinderCreateNew', () => {
test('renders correctly with no items', () => {
const wrapper = shallow(<SavedObjectFinderCreateNew menuItems={[]} />);
expect(wrapper.find(EuiPopover).length).toEqual(1);
const menuPanel = wrapper.find(EuiContextMenuPanel);
expect(menuPanel.length).toEqual(1);
const panelItems = menuPanel.prop('items');
if (panelItems) {
expect(panelItems.length).toEqual(0);
} else {
fail('Expect paneltems to be defined');
}
});
test('renders correctly with items', () => {
const items = [];
const onClick = jest.fn();
for (let i = 0; i < 3; i++) {
items.push(
<EuiContextMenuItem key={i + 1} data-test-subj={`item${i + 1}`} onClick={onClick}>{`item${
i + 1
}`}</EuiContextMenuItem>
);
}
const wrapper = shallow(<SavedObjectFinderCreateNew menuItems={items} />);
expect(wrapper.find(EuiPopover).length).toEqual(1);
const menuPanel = wrapper.find(EuiContextMenuPanel);
expect(menuPanel.length).toEqual(1);
const paneltems = menuPanel.prop('items');
if (paneltems) {
expect(paneltems.length).toEqual(3);
expect(paneltems[0].key).toEqual('1');
expect(paneltems[1].key).toEqual('2');
expect(paneltems[2].key).toEqual('3');
} else {
fail('Expect paneltems to be defined');
}
});
test('clicking the button opens/closes the popover', () => {
const items = [];
const onClick = jest.fn();
for (let i = 0; i < 3; i++) {
items.push(
<EuiContextMenuItem key={i + 1} data-test-subj={`item${i + 1}`} onClick={onClick}>{`item${
i + 1
}`}</EuiContextMenuItem>
);
}
const component = mountWithIntl(<SavedObjectFinderCreateNew menuItems={items} />);
let popover = component.find(EuiPopover);
expect(popover.prop('isOpen')).toBe(false);
const button = component.find('button');
button.simulate('click');
popover = component.find(EuiPopover);
expect(popover.prop('isOpen')).toBe(true);
button.simulate('click');
popover = component.find(EuiPopover);
expect(popover.prop('isOpen')).toBe(false);
});
});

View file

@ -1,12 +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.
*/
export * from './inspect_panel_action';
export * from './add_panel';
export * from './remove_panel_action';
export * from './customize_panel';

View file

@ -1,228 +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 { i18n } from '@kbn/i18n';
import {
EuiContextMenuPanelDescriptor,
EuiBadge,
EuiIcon,
EuiToolTip,
EuiScreenReaderOnly,
EuiNotificationBadge,
EuiLink,
} from '@elastic/eui';
import classNames from 'classnames';
import React from 'react';
import { Action } from '@kbn/ui-actions-plugin/public';
import { PanelOptionsMenu } from './panel_options_menu';
import { IEmbeddable } from '../../embeddables';
import { EmbeddableContext, panelBadgeTrigger, panelNotificationTrigger } from '../../triggers';
import { CustomizePanelAction } from '.';
export interface PanelHeaderProps {
title?: string;
description?: string;
index?: number;
isViewMode: boolean;
hidePanelTitle: boolean;
getActionContextMenuPanel: () => Promise<{
panels: EuiContextMenuPanelDescriptor[];
actions: Action[];
}>;
closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;
embeddable: IEmbeddable;
headerId?: string;
showPlaceholderTitle?: boolean;
customizePanel?: CustomizePanelAction;
}
function renderBadges(badges: Array<Action<EmbeddableContext>>, embeddable: IEmbeddable) {
return badges.map((badge) => (
<EuiBadge
key={badge.id}
className="embPanel__headerBadge"
iconType={badge.getIconType({ embeddable, trigger: panelBadgeTrigger })}
onClick={() => badge.execute({ embeddable, trigger: panelBadgeTrigger })}
onClickAriaLabel={badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })}
data-test-subj={`embeddablePanelBadge-${badge.id}`}
>
{badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })}
</EuiBadge>
));
}
function renderNotifications(
notifications: Array<Action<EmbeddableContext>>,
embeddable: IEmbeddable
) {
return notifications.map((notification) => {
const context = { embeddable };
let badge = notification.MenuItem ? (
React.createElement(notification.MenuItem, {
key: notification.id,
context: {
embeddable,
trigger: panelNotificationTrigger,
},
})
) : (
<EuiNotificationBadge
data-test-subj={`embeddablePanelNotification-${notification.id}`}
key={notification.id}
style={{ marginTop: '4px', marginRight: '4px' }}
onClick={() => notification.execute({ ...context, trigger: panelNotificationTrigger })}
>
{notification.getDisplayName({ ...context, trigger: panelNotificationTrigger })}
</EuiNotificationBadge>
);
if (notification.getDisplayNameTooltip) {
const tooltip = notification.getDisplayNameTooltip({
...context,
trigger: panelNotificationTrigger,
});
if (tooltip) {
badge = (
<EuiToolTip position="top" delay="regular" content={tooltip} key={notification.id}>
{badge}
</EuiToolTip>
);
}
}
return badge;
});
}
export function PanelHeader({
title,
description,
index,
isViewMode,
hidePanelTitle,
getActionContextMenuPanel,
closeContextMenu,
badges,
notifications,
embeddable,
headerId,
customizePanel,
}: PanelHeaderProps) {
const showTitle = !hidePanelTitle && (!isViewMode || title);
const showPanelBar =
!isViewMode || badges.length > 0 || notifications.length > 0 || showTitle || description;
const classes = classNames('embPanel__header', {
'embPanel__header--floater': !showPanelBar,
});
const placeholderTitle = i18n.translate('embeddableApi.panel.placeholderTitle', {
defaultMessage: '[No Title]',
});
const getAriaLabel = () => {
return (
<span id={headerId}>
{showPanelBar && title
? i18n.translate('embeddableApi.panel.enhancedDashboardPanelAriaLabel', {
defaultMessage: 'Dashboard panel: {title}',
values: { title: title || placeholderTitle },
})
: i18n.translate('embeddableApi.panel.dashboardPanelAriaLabel', {
defaultMessage: 'Dashboard panel',
})}
</span>
);
};
if (!showPanelBar) {
return (
<div className={classes}>
<PanelOptionsMenu
getActionContextMenuPanel={getActionContextMenuPanel}
isViewMode={isViewMode}
closeContextMenu={closeContextMenu}
title={title}
index={index}
/>
<EuiScreenReaderOnly>{getAriaLabel()}</EuiScreenReaderOnly>
</div>
);
}
const renderTitle = () => {
let titleComponent;
if (showTitle) {
titleComponent = isViewMode ? (
<span
className={classNames('embPanel__titleText', {
// eslint-disable-next-line @typescript-eslint/naming-convention
embPanel__placeholderTitleText: !title,
})}
>
{title || placeholderTitle}
</span>
) : customizePanel ? (
<EuiLink
color="text"
data-test-subj={'embeddablePanelTitleLink'}
className={classNames('embPanel__titleText', {
// eslint-disable-next-line @typescript-eslint/naming-convention
embPanel__placeholderTitleText: !title,
})}
aria-label={i18n.translate('embeddableApi.panel.editTitleAriaLabel', {
defaultMessage: 'Click to edit title: {title}',
values: { title: title || placeholderTitle },
})}
onClick={() => customizePanel.execute({ embeddable })}
>
{title || placeholderTitle}
</EuiLink>
) : null;
}
return description ? (
<EuiToolTip
content={description}
delay="regular"
position="top"
anchorClassName="embPanel__titleTooltipAnchor"
>
<span className="embPanel__titleInner">
{titleComponent} <EuiIcon type="iInCircle" color="subdued" />
</span>
</EuiToolTip>
) : (
<span className="embPanel__titleInner">{titleComponent}</span>
);
};
const titleClasses = classNames('embPanel__title', { 'embPanel--dragHandle': !isViewMode });
return (
<figcaption
className={classes}
data-test-subj={`embeddablePanelHeading-${(title || '').replace(/\s/g, '')}`}
>
<h2 data-test-subj="dashboardPanelTitle" className={titleClasses}>
<EuiScreenReaderOnly>{getAriaLabel()}</EuiScreenReaderOnly>
{renderTitle()}
{renderBadges(badges, embeddable)}
</h2>
{renderNotifications(notifications, embeddable)}
<PanelOptionsMenu
isViewMode={isViewMode}
getActionContextMenuPanel={getActionContextMenuPanel}
closeContextMenu={closeContextMenu}
title={title}
index={index}
/>
</figcaption>
);
}

View file

@ -1,166 +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 { i18n } from '@kbn/i18n';
import React from 'react';
import {
EuiButtonIcon,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
} from '@elastic/eui';
import { Action } from '@kbn/ui-actions-plugin/public';
export interface PanelOptionsMenuProps {
getActionContextMenuPanel: () => Promise<{
panels: EuiContextMenuPanelDescriptor[];
actions: Action[];
}>;
isViewMode: boolean;
closeContextMenu: boolean;
title?: string;
index?: number;
}
interface State {
actionContextMenuPanel?: {
panels: EuiContextMenuPanelDescriptor[];
actions: Action[];
};
isPopoverOpen: boolean;
showNotification: boolean;
}
export class PanelOptionsMenu extends React.Component<PanelOptionsMenuProps, State> {
private mounted = false;
public static getDerivedStateFromProps(props: PanelOptionsMenuProps, state: State) {
if (props.closeContextMenu) {
return {
...state,
isPopoverOpen: false,
};
} else {
return state;
}
}
constructor(props: PanelOptionsMenuProps) {
super(props);
this.state = {
actionContextMenuPanel: undefined,
isPopoverOpen: false,
showNotification: false,
};
}
public async componentDidMount() {
this.mounted = true;
this.setState({ actionContextMenuPanel: undefined });
const actionContextMenuPanel = await this.props.getActionContextMenuPanel();
const showNotification = actionContextMenuPanel.actions.some(
(action) => action.showNotification
);
if (this.mounted) {
this.setState({ actionContextMenuPanel, showNotification });
}
}
public async componentDidUpdate() {
const actionContextMenuPanel = await this.props.getActionContextMenuPanel();
const showNotification = actionContextMenuPanel.actions.some(
(action) => action.showNotification
);
if (this.mounted && this.state.showNotification !== showNotification) {
this.setState({ showNotification });
}
}
public componentWillUnmount() {
this.mounted = false;
}
public render() {
const { isViewMode, title, index } = this.props;
const enhancedAriaLabel = i18n.translate(
'embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel',
{
defaultMessage: 'Panel options for {title}',
values: { title },
}
);
const ariaLabelWithoutTitle =
index === undefined
? i18n.translate('embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabel', {
defaultMessage: 'Panel options',
})
: i18n.translate('embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex', {
defaultMessage: 'Options for panel {index}',
values: { index },
});
const button = (
<EuiButtonIcon
iconType={isViewMode ? 'boxesHorizontal' : 'gear'}
color="text"
className="embPanel__optionsMenuButton"
aria-label={title ? enhancedAriaLabel : ariaLabelWithoutTitle}
data-test-subj="embeddablePanelToggleMenuIcon"
onClick={this.toggleContextMenu}
/>
);
return (
<EuiPopover
className={
'embPanel__optionsMenuPopover' +
(this.state.showNotification ? ' embPanel__optionsMenuPopover-notification' : '')
}
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downRight"
data-test-subj={
this.state.isPopoverOpen
? 'embeddablePanelContextMenuOpen'
: 'embeddablePanelContextMenuClosed'
}
repositionOnScroll
>
<EuiContextMenu
initialPanelId="mainMenu"
panels={this.state.actionContextMenuPanel?.panels || []}
/>
</EuiPopover>
);
}
private closePopover = () => {
if (this.mounted) {
this.setState({
isPopoverOpen: false,
});
}
};
private toggleContextMenu = () => {
if (!this.mounted) return;
const after = () => {
if (!this.state.isPopoverOpen) return;
this.setState({ actionContextMenuPanel: undefined });
this.props
.getActionContextMenuPanel()
.then((actionContextMenuPanel) => {
if (!this.mounted) return;
this.setState({ actionContextMenuPanel });
})
.catch((error) => console.error(error)); // eslint-disable-line no-console
};
this.setState(({ isPopoverOpen }) => ({ isPopoverOpen: !isPopoverOpen }), after);
};
}

View file

@ -23,6 +23,11 @@ export class ContactCardEmbeddableFactory
implements EmbeddableFactoryDefinition<ContactCardEmbeddableInput>
{
public readonly type = CONTACT_CARD_EMBEDDABLE;
savedObjectMetaData = {
name: 'Contact card',
type: CONTACT_CARD_EMBEDDABLE,
getIconForSavedObject: () => 'document',
};
constructor(
protected readonly execTrigger: UiActionsStart['executeTriggerActions'],

View file

@ -33,7 +33,6 @@ interface HelloWorldContainerInput extends ContainerInput {
interface HelloWorldContainerOptions {
getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'];
panelComponent?: EmbeddableStart['EmbeddablePanel'];
}
export class HelloWorldContainer extends Container<InheritedInput, HelloWorldContainerInput> {
@ -41,7 +40,7 @@ export class HelloWorldContainer extends Container<InheritedInput, HelloWorldCon
constructor(
input: ContainerInput<{ firstName: string; lastName: string }>,
private readonly options: HelloWorldContainerOptions,
options: HelloWorldContainerOptions,
initializeSettings?: EmbeddableContainerSettings
) {
super(
@ -64,14 +63,7 @@ export class HelloWorldContainer extends Container<InheritedInput, HelloWorldCon
public render(node: HTMLElement) {
ReactDOM.render(
<I18nProvider>
{this.options.panelComponent ? (
<HelloWorldContainerComponent
container={this}
panelComponent={this.options.panelComponent}
/>
) : (
<div>Panel component not provided.</div>
)}
<HelloWorldContainerComponent container={this} />
</I18nProvider>,
node
);

View file

@ -10,12 +10,12 @@ import React, { Component, RefObject } from 'react';
import { Subscription } from 'rxjs';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { IContainer, PanelState, EmbeddableChildPanel } from '../..';
import { EmbeddableStart } from '../../../plugin';
import { IContainer, PanelState } from '../..';
import { EmbeddablePanel } from '../../../embeddable_panel';
interface Props {
container: IContainer;
panelComponent: EmbeddableStart['EmbeddablePanel'];
}
interface State {
@ -85,10 +85,10 @@ export class HelloWorldContainerComponent extends Component<Props, State> {
const list = Object.values(this.state.panels).map((panelState) => {
const item = (
<EuiFlexItem key={panelState.explicitInput.id}>
<EmbeddableChildPanel
container={this.props.container}
embeddableId={panelState.explicitInput.id}
PanelComponent={this.props.panelComponent}
<EmbeddablePanel
embeddable={() =>
this.props.container.untilEmbeddableLoaded(panelState.explicitInput.id)
}
/>
</EuiFlexItem>
);

View file

@ -6,21 +6,17 @@
* Side Public License, v 1.
*/
import React from 'react';
import { coreMock, themeServiceMock } from '@kbn/core/public/mocks';
import { CoreStart } from '@kbn/core/public';
import { Start as InspectorStart } from '@kbn/inspector-plugin/public';
import { type AggregateQuery, type Filter, type Query } from '@kbn/es-query';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import {
SavedObjectManagementTypeInfo,
SavedObjectsManagementPluginStart,
} from '@kbn/saved-objects-management-plugin/public';
import { coreMock } from '@kbn/core/public/mocks';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { type AggregateQuery, type Filter, type Query } from '@kbn/es-query';
import { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { UiActionsService } from './lib/ui_actions';
import { EmbeddablePublicPlugin } from './plugin';
import { savedObjectsManagementPluginMock } from '@kbn/saved-objects-management-plugin/public/mocks';
import {
EmbeddableStart,
EmbeddableSetup,
@ -28,58 +24,20 @@ import {
EmbeddableStartDependencies,
EmbeddableStateTransfer,
IEmbeddable,
EmbeddablePanel,
EmbeddableInput,
SavedObjectEmbeddableInput,
ReferenceOrValueEmbeddable,
SelfStyledEmbeddable,
FilterableEmbeddable,
} from '.';
import { EmbeddablePublicPlugin } from './plugin';
import { setKibanaServices } from './kibana_services';
import { SelfStyledOptions } from './lib/self_styled_embeddable/types';
export { mockAttributeService } from './lib/attribute_service/attribute_service.mock';
export type Setup = jest.Mocked<EmbeddableSetup>;
export type Start = jest.Mocked<EmbeddableStart>;
interface CreateEmbeddablePanelMockArgs {
getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'];
getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories'];
overlays: CoreStart['overlays'];
notifications: CoreStart['notifications'];
application: CoreStart['application'];
inspector: InspectorStart;
SavedObjectFinder: React.ComponentType<any>;
}
const theme = themeServiceMock.createStartContract();
export const createEmbeddablePanelMock = ({
getActions,
getEmbeddableFactory,
getAllEmbeddableFactories,
overlays,
notifications,
application,
inspector,
SavedObjectFinder,
}: Partial<CreateEmbeddablePanelMockArgs>) => {
return ({ embeddable }: { embeddable: IEmbeddable }) => (
<EmbeddablePanel
embeddable={embeddable}
getActions={getActions || (() => Promise.resolve([]))}
getAllEmbeddableFactories={getAllEmbeddableFactories || ((() => []) as any)}
getEmbeddableFactory={getEmbeddableFactory || ((() => undefined) as any)}
notifications={notifications || ({} as any)}
application={application || ({} as any)}
overlays={overlays || ({} as any)}
inspector={inspector || ({} as any)}
SavedObjectFinder={SavedObjectFinder || (() => null)}
theme={theme}
/>
);
};
export const createEmbeddableStateTransferMock = (): Partial<EmbeddableStateTransfer> => {
return {
clearEditorState: jest.fn(),
@ -149,7 +107,6 @@ const createStartContract = (): Start => {
extract: jest.fn(),
inject: jest.fn(),
getAllMigrations: jest.fn(),
EmbeddablePanel: jest.fn(),
getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer),
getAttributeService: jest.fn(),
};
@ -183,6 +140,7 @@ const createInstance = (setupPlugins: Partial<EmbeddableSetupDependencies> = {})
inspector: inspectorPluginMock.createStartContract(),
savedObjectsManagement:
savedObjectsManagementMock as unknown as SavedObjectsManagementPluginStart,
usageCollection: { reportUiCounter: jest.fn() },
});
return {
plugin,
@ -199,3 +157,15 @@ export const embeddablePluginMock = {
mockSelfStyledEmbeddable,
mockFilterableEmbeddable,
};
export const setStubKibanaServices = () => {
const core = coreMock.createStart();
const selfStart = embeddablePluginMock.createStartContract();
setKibanaServices(core, selfStart, {
uiActions: uiActionsPluginMock.createStartContract(),
inspector: inspectorPluginMock.createStartContract(),
savedObjectsManagement: savedObjectsManagementPluginMock.createStartContract(),
usageCollection: { reportUiCounter: jest.fn() },
});
};

View file

@ -6,12 +6,10 @@
* Side Public License, v 1.
*/
import React from 'react';
import { Subscription } from 'rxjs';
import { identity } from 'lodash';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import type { SerializableRecord } from '@kbn/utility-types';
import { getSavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public';
import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { Start as InspectorStart } from '@kbn/inspector-plugin/public';
import {
@ -22,6 +20,7 @@ import {
PublicAppInfo,
} from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { migrateToLatest, PersistableStateService } from '@kbn/kibana-utils-plugin/common';
import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public';
@ -39,9 +38,7 @@ import {
EmbeddableOutput,
defaultEmbeddableFactoryProvider,
IEmbeddable,
EmbeddablePanel,
SavedObjectEmbeddableInput,
EmbeddableContainerContext,
PANEL_BADGE_TRIGGER,
} from './lib';
import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition';
@ -57,7 +54,8 @@ import {
} from '../common/lib';
import { getAllMigrations } from '../common/lib/get_all_migrations';
import { setTheme } from './services';
import { CustomTimeRangeBadge } from './lib/panel/panel_header/panel_actions/customize_panel/custom_time_range_badge';
import { setKibanaServices } from './kibana_services';
import { CustomTimeRangeBadge } from './embeddable_panel/panel_actions';
export interface EmbeddableSetupDependencies {
uiActions: UiActionsSetup;
@ -66,6 +64,7 @@ export interface EmbeddableSetupDependencies {
export interface EmbeddableStartDependencies {
uiActions: UiActionsStart;
inspector: InspectorStart;
usageCollection: UsageCollectionStart;
savedObjectsManagement: SavedObjectsManagementPluginStart;
savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart;
}
@ -92,7 +91,6 @@ export interface EmbeddableStart extends PersistableStateService<EmbeddableState
embeddableFactoryId: string
) => EmbeddableFactory<I, O, E> | undefined;
getEmbeddableFactories: () => IterableIterator<EmbeddableFactory>;
EmbeddablePanel: EmbeddablePanelHOC;
getStateTransfer: (storage?: Storage) => EmbeddableStateTransfer;
getAttributeService: <
A extends { title: string },
@ -106,14 +104,6 @@ export interface EmbeddableStart extends PersistableStateService<EmbeddableState
options: AttributeServiceOptions<A, M>
) => AttributeService<A, V, R, M>;
}
export type EmbeddablePanelHOC = React.FC<{
embeddable: IEmbeddable;
hideHeader?: boolean;
containerContext?: EmbeddableContainerContext;
index?: number;
}>;
export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, EmbeddableStart> {
private readonly embeddableFactoryDefinitions: Map<string, EmbeddableFactoryDefinition> =
new Map();
@ -145,15 +135,7 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
};
}
public start(
core: CoreStart,
{
uiActions,
inspector,
savedObjectsManagement,
savedObjectsTaggingOss,
}: EmbeddableStartDependencies
): EmbeddableStart {
public start(core: CoreStart, deps: EmbeddableStartDependencies): EmbeddableStart {
this.embeddableFactoryDefinitions.forEach((def) => {
this.embeddableFactories.set(
def.type,
@ -163,6 +145,7 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
);
});
const { uiActions } = deps;
const { overlays, theme, uiSettings } = core;
const dateFormat = uiSettings.get(UI_SETTINGS.DATE_FORMAT);
@ -188,45 +171,6 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
);
this.isRegistryReady = true;
const getEmbeddablePanelHoc =
() =>
({
embeddable,
hideHeader,
containerContext,
index,
}: {
embeddable: IEmbeddable;
hideHeader?: boolean;
containerContext?: EmbeddableContainerContext;
index?: number;
}) =>
(
<EmbeddablePanel
hideHeader={hideHeader}
embeddable={embeddable}
index={index}
stateTransfer={this.stateTransferService}
getActions={uiActions.getTriggerCompatibleActions}
getEmbeddableFactory={this.getEmbeddableFactory}
getAllEmbeddableFactories={this.getEmbeddableFactories}
dateFormat={dateFormat}
commonlyUsedRanges={commonlyUsedRanges}
overlays={overlays}
notifications={core.notifications}
application={core.application}
inspector={inspector}
SavedObjectFinder={getSavedObjectFinder(
core.uiSettings,
core.http,
savedObjectsManagement,
savedObjectsTaggingOss?.getTaggingApi()
)}
containerContext={containerContext}
theme={theme}
/>
);
const commonContract: CommonEmbeddableStartContract = {
getEmbeddableFactory: this
.getEmbeddableFactory as unknown as CommonEmbeddableStartContract['getEmbeddableFactory'],
@ -240,7 +184,7 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
getMigrateFunction(commonContract)
);
return {
const embeddableStart: EmbeddableStart = {
getEmbeddableFactory: this.getEmbeddableFactory,
getEmbeddableFactories: this.getEmbeddableFactories,
getAttributeService: (type: string, options) =>
@ -254,7 +198,6 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
storage
)
: this.stateTransferService,
EmbeddablePanel: getEmbeddablePanelHoc(),
telemetry: getTelemetryFunction(commonContract),
extract: getExtractFunction(commonContract),
inject: getInjectFunction(commonContract),
@ -263,6 +206,9 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
return migrateToLatest(getAllMigrationsFn(), state) as EmbeddableStateWithType;
},
};
setKibanaServices(core, embeddableStart, deps);
return embeddableStart;
}
public stop() {

View file

@ -39,7 +39,6 @@ import {
import { coreMock } from '@kbn/core/public/mocks';
import { testPlugin } from './test_plugin';
import { of } from './helpers';
import { createEmbeddablePanelMock } from '../mocks';
import { EmbeddableContainerSettings } from '../lib/containers/i_container';
async function createHelloWorldContainerAndEmbeddable(
@ -64,20 +63,10 @@ async function createHelloWorldContainerAndEmbeddable(
const start = doStart();
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
containerInput,
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
},
settings
);
@ -97,7 +86,6 @@ async function createHelloWorldContainerAndEmbeddable(
start,
coreSetup,
coreStart,
testPanel,
container,
uiActions,
embeddable,
@ -240,7 +228,7 @@ test('Container.addNewEmbeddable', async () => {
});
test('Container.removeEmbeddable removes and cleans up', async () => {
const { start, testPanel } = await createHelloWorldContainerAndEmbeddable();
const { start } = await createHelloWorldContainerAndEmbeddable();
const container = new HelloWorldContainer(
{
@ -254,7 +242,6 @@ test('Container.removeEmbeddable removes and cleans up', async () => {
},
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);
const embeddable = await container.addNewEmbeddable<
@ -397,14 +384,13 @@ test('Container view mode change propagates to children', async () => {
});
test(`Container updates its state when a child's input is updated`, async () => {
const { container, embeddable, start, coreStart, uiActions } =
await createHelloWorldContainerAndEmbeddable(
{ id: 'hello', panels: {}, viewMode: ViewMode.VIEW },
{
id: '123',
firstName: 'Susy',
}
);
const { container, embeddable, start } = await createHelloWorldContainerAndEmbeddable(
{ id: 'hello', panels: {}, viewMode: ViewMode.VIEW },
{
id: '123',
firstName: 'Susy',
}
);
expect(isErrorEmbeddable(embeddable)).toBe(false);
@ -420,17 +406,8 @@ test(`Container updates its state when a child's input is updated`, async () =>
// Make sure a brand new container built off the output of container also creates an embeddable
// with "Dr.", not the default the embeddable was first added with. Makes sure changed input
// is preserved with the container.
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const containerClone = new HelloWorldContainer(container.getInput(), {
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
});
const cloneSubscription = Rx.merge(
containerClone.getOutput$(),
@ -668,14 +645,6 @@ test('Container changes made directly after adding a new embeddable are propagat
const start = doStart();
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
{
id: 'hello',
@ -684,7 +653,6 @@ test('Container changes made directly after adding a new embeddable are propagat
},
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);
@ -787,19 +755,8 @@ test('untilEmbeddableLoaded() throws an error if there is no such child panel in
});
test('untilEmbeddableLoaded() throws an error if there is no such child panel in the container - 2', async () => {
const { doStart, coreStart, uiActions } = testPlugin(
coreMock.createSetup(),
coreMock.createStart()
);
const { doStart } = testPlugin(coreMock.createSetup(), coreMock.createStart());
const start = doStart();
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
{
id: 'hello',
@ -807,7 +764,6 @@ test('untilEmbeddableLoaded() throws an error if there is no such child panel in
},
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);
@ -817,21 +773,10 @@ test('untilEmbeddableLoaded() throws an error if there is no such child panel in
});
test('untilEmbeddableLoaded() resolves if child is loaded in the container', async () => {
const { setup, doStart, coreStart, uiActions } = testPlugin(
coreMock.createSetup(),
coreMock.createStart()
);
const { setup, doStart } = testPlugin(coreMock.createSetup(), coreMock.createStart());
const factory = new HelloWorldEmbeddableFactoryDefinition();
setup.registerEmbeddableFactory(factory.type, factory);
const start = doStart();
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
{
id: 'hello',
@ -844,7 +789,6 @@ test('untilEmbeddableLoaded() resolves if child is loaded in the container', asy
},
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);
@ -854,10 +798,7 @@ test('untilEmbeddableLoaded() resolves if child is loaded in the container', asy
});
test('untilEmbeddableLoaded resolves with undefined if child is subsequently removed', async () => {
const { doStart, setup, coreStart, uiActions } = testPlugin(
coreMock.createSetup(),
coreMock.createStart()
);
const { doStart, setup, uiActions } = testPlugin(coreMock.createSetup(), coreMock.createStart());
const factory = new SlowContactCardEmbeddableFactory({
loadTickCount: 3,
execAction: uiActions.executeTriggerActions,
@ -865,14 +806,6 @@ test('untilEmbeddableLoaded resolves with undefined if child is subsequently rem
setup.registerEmbeddableFactory(factory.type, factory);
const start = doStart();
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
{
id: 'hello',
@ -885,7 +818,6 @@ test('untilEmbeddableLoaded resolves with undefined if child is subsequently rem
},
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);
@ -897,24 +829,13 @@ test('untilEmbeddableLoaded resolves with undefined if child is subsequently rem
});
test('adding a panel then subsequently removing it before its loaded removes the panel', (done) => {
const { doStart, coreStart, uiActions, setup } = testPlugin(
coreMock.createSetup(),
coreMock.createStart()
);
const { doStart, uiActions, setup } = testPlugin(coreMock.createSetup(), coreMock.createStart());
const factory = new SlowContactCardEmbeddableFactory({
loadTickCount: 1,
execAction: uiActions.executeTriggerActions,
});
setup.registerEmbeddableFactory(factory.type, factory);
const start = doStart();
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
{
id: 'hello',
@ -927,7 +848,6 @@ test('adding a panel then subsequently removing it before its loaded removes the
},
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);

View file

@ -11,7 +11,6 @@ import * as React from 'react';
import { EmbeddableOutput, isErrorEmbeddable, ViewMode } from '../lib';
import { coreMock } from '@kbn/core/public/mocks';
import { testPlugin } from './test_plugin';
import { CustomizePanelEditor } from '../lib/panel/panel_header/panel_actions/customize_panel/customize_panel_editor';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import {
EmbeddableTimeRangeInput,
@ -20,6 +19,7 @@ import {
TimeRangeEmbeddableFactory,
TIME_RANGE_EMBEDDABLE,
} from '../lib/test_samples';
import { CustomizePanelEditor } from '../embeddable_panel/panel_actions/customize_panel_action/customize_panel_editor';
let container: TimeRangeContainer;
let embeddable: TimeRangeEmbeddable;

View file

@ -21,12 +21,8 @@ import { FilterableContainer } from '../lib/test_samples/embeddables/filterable_
import { isErrorEmbeddable } from '../lib';
import { HelloWorldContainer } from '../lib/test_samples/embeddables/hello_world_container';
import { coreMock } from '@kbn/core/public/mocks';
import { createEmbeddablePanelMock } from '../mocks';
const { setup, doStart, coreStart, uiActions } = testPlugin(
coreMock.createSetup(),
coreMock.createStart()
);
const { setup, doStart, uiActions } = testPlugin(coreMock.createSetup(), coreMock.createStart());
setup.registerEmbeddableFactory(FILTERABLE_EMBEDDABLE, new FilterableEmbeddableFactory());
const factory = new SlowContactCardEmbeddableFactory({
@ -69,19 +65,10 @@ test('Explicit embeddable input mapped to undefined will default to inherited',
});
test('Explicit embeddable input mapped to undefined with no inherited value will get passed to embeddable', async () => {
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
{ id: 'hello', panels: {} },
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);
@ -111,14 +98,6 @@ test('Explicit embeddable input mapped to undefined with no inherited value will
// but before the embeddable factory returns the embeddable, that the `inheritedChildInput` and
// embeddable input comparisons won't cause explicit input to be set when it shouldn't.
test('Explicit input tests in async situations', (done: () => void) => {
const testPanel = createEmbeddablePanelMock({
getActions: uiActions.getTriggerCompatibleActions,
getEmbeddableFactory: start.getEmbeddableFactory,
getAllEmbeddableFactories: start.getEmbeddableFactories,
overlays: coreStart.overlays,
notifications: coreStart.notifications,
application: coreStart.application,
});
const container = new HelloWorldContainer(
{
id: 'hello',
@ -131,7 +110,6 @@ test('Explicit input tests in async situations', (done: () => void) => {
},
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
}
);

View file

@ -65,6 +65,7 @@ export const testPlugin = (
uiActions: uiActionsPluginMock.createStartContract(),
savedObjectsManagement:
savedObjectsManagementMock as unknown as SavedObjectsManagementPluginStart,
usageCollection: { reportUiCounter: jest.fn() },
});
return start;
},

View file

@ -1,14 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": [
".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*"
],
"include": ["*.ts", ".storybook/**/*", "common/**/*", "public/**/*", "server/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/inspector-plugin",
@ -25,17 +20,15 @@
"@kbn/test-jest-helpers",
"@kbn/std",
"@kbn/expressions-plugin",
"@kbn/usage-collection-plugin",
"@kbn/analytics",
"@kbn/data-plugin",
"@kbn/core-overlays-browser-mocks",
"@kbn/core-theme-browser-mocks",
"@kbn/saved-objects-management-plugin",
"@kbn/saved-objects-tagging-oss-plugin",
"@kbn/saved-objects-finder-plugin",
"@kbn/ui-actions-browser",
"@kbn/analytics",
"@kbn/usage-collection-plugin",
"@kbn/ui-theme"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -41,8 +41,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
});
it('Can add a child backed off a saved object', async () => {
await testSubjects.click('embeddablePanelToggleMenuIcon');
await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL');
await testSubjects.click('addPanelToListContainer');
await testSubjects.waitForDeleted('savedObjectFinderLoadingIndicator');
await toggleFilterPopover();
await clickFilter('Todo');

View file

@ -15,6 +15,7 @@ import {
EmbeddableFactory,
EmbeddableFactoryNotFoundError,
isErrorEmbeddable,
EmbeddablePanel,
} from '@kbn/embeddable-plugin/public';
import type { EmbeddableContainerContext } from '@kbn/embeddable-plugin/public';
import { StartDeps } from '../../plugin';
@ -50,10 +51,7 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => {
};
return (
<plugins.embeddable.EmbeddablePanel
embeddable={embeddable}
containerContext={embeddableContainerContext}
/>
<EmbeddablePanel embeddable={embeddable} containerContext={embeddableContainerContext} />
);
};

View file

@ -6,7 +6,7 @@
*/
import React, { FC, useEffect } from 'react';
import type { CoreStart, ThemeServiceStart } from '@kbn/core/public';
import type { CoreStart } from '@kbn/core/public';
import type { Action, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import { EuiLoadingChart } from '@elastic/eui';
@ -93,9 +93,8 @@ interface PluginsStartDependencies {
}
export function getEmbeddableComponent(core: CoreStart, plugins: PluginsStartDependencies) {
const { embeddable: embeddableStart, uiActions, inspector } = plugins;
const { embeddable: embeddableStart, uiActions } = plugins;
const factory = embeddableStart.getEmbeddableFactory('lens')!;
const theme = core.theme;
return (props: EmbeddableComponentProps) => {
const input = { ...props };
const hasActions =
@ -106,10 +105,8 @@ export function getEmbeddableComponent(core: CoreStart, plugins: PluginsStartDep
<EmbeddablePanelWrapper
factory={factory}
uiActions={uiActions}
inspector={inspector}
actionPredicate={() => hasActions}
input={input}
theme={theme}
extraActions={input.extraActions}
showInspector={input.showInspector}
withDefaultActions={input.withDefaultActions}
@ -137,10 +134,8 @@ function EmbeddableRootWrapper({
interface EmbeddablePanelWrapperProps {
factory: EmbeddableFactory<EmbeddableInput, EmbeddableOutput>;
uiActions: PluginsStartDependencies['uiActions'];
inspector: PluginsStartDependencies['inspector'];
actionPredicate: (id: string) => boolean;
input: EmbeddableComponentProps;
theme: ThemeServiceStart;
extraActions?: Action[];
showInspector?: boolean;
withDefaultActions?: boolean;
@ -150,9 +145,7 @@ const EmbeddablePanelWrapper: FC<EmbeddablePanelWrapperProps> = ({
factory,
uiActions,
actionPredicate,
inspector,
input,
theme,
extraActions,
showInspector = true,
withDefaultActions,
@ -179,12 +172,11 @@ const EmbeddablePanelWrapper: FC<EmbeddablePanelWrapperProps> = ({
return [...(extraActions ?? []), ...actions];
}}
inspector={showInspector ? inspector : undefined}
hideInspector={!showInspector}
actionPredicate={actionPredicate}
showNotifications={false}
showShadow={false}
showBadges={false}
showNotifications={false}
theme={theme}
/>
);
};

View file

@ -19,6 +19,8 @@ import { getLayerList } from './map_config';
import { useIsFieldInIndexPattern } from '../../../containers/fields';
import { buildTimeRangeFilter } from '../../../../detections/components/alerts_table/helpers';
import { setStubKibanaServices } from '@kbn/embeddable-plugin/public/mocks';
jest.mock('./create_embeddable');
jest.mock('./map_config');
jest.mock('../../../../common/containers/sourcerer');
@ -29,9 +31,6 @@ jest.mock('./index_patterns_missing_prompt', () => ({
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
embeddable: {
EmbeddablePanel: jest.fn(() => <div data-test-subj="EmbeddablePanel" />),
},
docLinks: {
ELASTIC_WEBSITE_URL: 'ELASTIC_WEBSITE_URL',
links: {
@ -51,6 +50,10 @@ jest.mock('../../../../common/lib/kibana', () => ({
remove: jest.fn(),
}),
}));
jest.mock('@kbn/embeddable-plugin/public', () => ({
...jest.requireActual('@kbn/embeddable-plugin/public'),
EmbeddablePanel: jest.fn().mockReturnValue(<div data-test-subj="EmbeddablePanel" />),
}));
const mockUseSourcererDataView = useSourcererDataView as jest.Mock;
const mockCreateEmbeddable = createEmbeddable as jest.Mock;
@ -107,6 +110,9 @@ describe('EmbeddedMapComponent', () => {
mockUseSourcererDataView.mockReturnValue({ selectedPatterns: ['filebeat-*', 'auditbeat-*'] });
mockCreateEmbeddable.mockResolvedValue(embeddableValue);
mockUseIsFieldInIndexPattern.mockReturnValue(() => true);
// stub Kibana services for the embeddable plugin to ensure embeddable panel renders.
setStubKibanaServices();
});
afterEach(() => {
@ -138,7 +144,7 @@ describe('EmbeddedMapComponent', () => {
});
});
test('renders services.embeddable.EmbeddablePanel', async () => {
test('renders EmbeddablePanel from embeddable plugin', async () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<EmbeddedMapComponent {...testProps} />

View file

@ -12,8 +12,11 @@ import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { createHtmlPortalNode, InPortal } from 'react-reverse-portal';
import styled, { css } from 'styled-components';
import type { Filter, Query } from '@kbn/es-query';
import type { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import {
EmbeddablePanel,
isErrorEmbeddable,
type ErrorEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { MapEmbeddable } from '@kbn/maps-plugin/public/embeddable';
import { isEqual } from 'lodash/fp';
import { buildTimeRangeFilter } from '../../../../detections/components/alerts_table/helpers';
@ -279,14 +282,14 @@ export const EmbeddedMapComponent = ({
{isIndexError ? (
<IndexPatternsMissingPrompt data-test-subj="missing-prompt" />
) : embeddable != null ? (
<services.embeddable.EmbeddablePanel embeddable={embeddable} />
<EmbeddablePanel embeddable={embeddable} />
) : (
<Loader data-test-subj="loading-panel" overlay size="xl" />
)}
</EmbeddableMap>
</Embeddable>
);
}, [embeddable, isIndexError, portalNode, services, storageValue]);
}, [embeddable, isIndexError, portalNode, storageValue]);
return isError ? null : (
<StyledEuiAccordion

View file

@ -2520,7 +2520,6 @@
"domDragDrop.dropTargets.swap": "Permuter",
"domDragDrop.keyboardInstructions": "Appuyez sur la barre d'espace ou sur Entrée pour commencer à faire glisser. Lors du glissement, utilisez les touches fléchées gauche/droite pour vous déplacer entre les cibles de dépôt. Appuyez à nouveau sur la barre d'espace ou sur Entrée pour terminer.",
"domDragDrop.keyboardInstructionsReorder": "Appuyez sur la barre d'espace ou sur Entrée pour commencer à faire glisser. Lors du glissement, utilisez les touches fléchées haut/bas pour réorganiser les éléments dans le groupe et les touches gauche/droite pour choisir les cibles de dépôt à l'extérieur du groupe. Appuyez à nouveau sur la barre d'espace ou sur Entrée pour terminer.",
"embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} a été ajouté",
"embeddableApi.attributeService.saveToLibraryError": "Une erreur s'est produite lors de l'enregistrement. Erreur : {errorMessage}",
"embeddableApi.errors.embeddableFactoryNotFound": "Impossible de charger {type}. Veuillez effectuer une mise à niveau vers la distribution par défaut d'Elasticsearch et de Kibana avec la licence appropriée.",
"embeddableApi.panel.editPanel.displayName": "Modifier {value}",
@ -2528,10 +2527,6 @@
"embeddableApi.panel.enhancedDashboardPanelAriaLabel": "Panneau du tableau de bord : {title}",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex": "Options pour le panneau {index}",
"embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel": "Options de panneau pour {title}",
"embeddableApi.addPanel.createNewDefaultOption": "Créer",
"embeddableApi.addPanel.displayName": "Ajouter un panneau",
"embeddableApi.addPanel.noMatchingObjectsMessage": "Aucun objet correspondant trouvé.",
"embeddableApi.addPanel.Title": "Ajouter depuis la bibliothèque",
"embeddableApi.cellValueTrigger.description": "Les actions apparaissent dans les options de valeur de cellule dans la visualisation",
"embeddableApi.cellValueTrigger.title": "Valeur de cellule",
"embeddableApi.contextMenuTrigger.description": "Une nouvelle action sera ajoutée au menu contextuel du panneau",

View file

@ -2520,7 +2520,6 @@
"domDragDrop.dropTargets.swap": "入れ替える",
"domDragDrop.keyboardInstructions": "スペースまたはEnterを押してドラッグを開始します。ドラッグするときには、左右の矢印キーを使用して、ドロップ対象間を移動します。もう一度スペースまたはEnterを押すと終了します。",
"domDragDrop.keyboardInstructionsReorder": "スペースまたはEnterを押してドラッグを開始します。ドラッグするときには、上下矢印キーを使用すると、グループの項目を並べ替えます。左右矢印キーを使用すると、グループの外側でドロップ対象を選択します。もう一度スペースまたはEnterを押すと終了します。",
"embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName}が追加されました",
"embeddableApi.attributeService.saveToLibraryError": "保存中にエラーが発生しました。エラー:{errorMessage}",
"embeddableApi.errors.embeddableFactoryNotFound": "{type}を読み込めません。Elasticsearch と Kibanaのデフォルトのディストリビューションを適切なライセンスでアップグレードしてください。",
"embeddableApi.panel.editPanel.displayName": "{value}の編集",
@ -2528,10 +2527,6 @@
"embeddableApi.panel.enhancedDashboardPanelAriaLabel": "ダッシュボードパネル:{title}",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex": "パネル{index}のオプション",
"embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel": "{title}のパネルオプション",
"embeddableApi.addPanel.createNewDefaultOption": "新規作成",
"embeddableApi.addPanel.displayName": "パネルの追加",
"embeddableApi.addPanel.noMatchingObjectsMessage": "一致するオブジェクトが見つかりませんでした。",
"embeddableApi.addPanel.Title": "ライブラリから追加",
"embeddableApi.cellValueTrigger.description": "アクションはビジュアライゼーションのセル値オプションに表示されます",
"embeddableApi.cellValueTrigger.title": "セル値",
"embeddableApi.contextMenuTrigger.description": "新しいアクションがパネルのコンテキストメニューに追加されます",

View file

@ -2519,7 +2519,6 @@
"domDragDrop.dropTargets.swap": "交换",
"domDragDrop.keyboardInstructions": "按空格键或 enter 键开始拖动。拖动时,请左右箭头键在拖动目标之间移动。再次按空格键或 enter 键结束操作。",
"domDragDrop.keyboardInstructionsReorder": "按空格键或 enter 键开始拖动。拖动时,请使用上下箭头键重新排列组中的项目,使用左右箭头键在组之外选择拖动目标。再次按空格键或 enter 键结束操作。",
"embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加",
"embeddableApi.attributeService.saveToLibraryError": "保存时出错。错误:{errorMessage}",
"embeddableApi.errors.embeddableFactoryNotFound": "{type} 无法加载。请升级到具有适当许可的默认 Elasticsearch 和 Kibana 分发。",
"embeddableApi.panel.editPanel.displayName": "编辑 {value}",
@ -2527,10 +2526,6 @@
"embeddableApi.panel.enhancedDashboardPanelAriaLabel": "仪表板面板:{title}",
"embeddableApi.panel.optionsMenu.panelOptionsButtonAriaLabelWithIndex": "面板 {index} 的选项",
"embeddableApi.panel.optionsMenu.panelOptionsButtonEnhancedAriaLabel": "{title} 的面板选项",
"embeddableApi.addPanel.createNewDefaultOption": "新建",
"embeddableApi.addPanel.displayName": "添加面板",
"embeddableApi.addPanel.noMatchingObjectsMessage": "未找到任何匹配对象。",
"embeddableApi.addPanel.Title": "从库中添加",
"embeddableApi.cellValueTrigger.description": "操作在可视化上的单元格值选项中显示",
"embeddableApi.cellValueTrigger.title": "单元格值",
"embeddableApi.contextMenuTrigger.description": "会将一个新操作添加到该面板的上下文菜单",