[Lens] Enable actions on Lens Embeddable (#102038)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2021-06-25 14:59:36 +02:00 committed by GitHub
parent 922d7cc73d
commit dfc70bdfd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 333 additions and 100 deletions

View file

@ -36,6 +36,7 @@
| [isSavedObjectEmbeddableInput(input)](./kibana-plugin-plugins-embeddable-public.issavedobjectembeddableinput.md) | |
| [openAddPanelFlyout(options)](./kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md) | |
| [plugin(initializerContext)](./kibana-plugin-plugins-embeddable-public.plugin.md) | |
| [useEmbeddableFactory({ input, factory, onInputUpdated, })](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md) | |
## Interfaces

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) &gt; [useEmbeddableFactory](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md)
## useEmbeddableFactory() function
<b>Signature:</b>
```typescript
export declare function useEmbeddableFactory<I extends EmbeddableInput>({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory<I>): readonly [ErrorEmbeddable | IEmbeddable<I, import("./i_embeddable").EmbeddableOutput> | undefined, boolean, string | undefined];
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| { input, factory, onInputUpdated, } | <code>EmbeddableRendererWithFactory&lt;I&gt;</code> | |
<b>Returns:</b>
`readonly [ErrorEmbeddable | IEmbeddable<I, import("./i_embeddable").EmbeddableOutput> | undefined, boolean, string | undefined]`

View file

@ -69,6 +69,7 @@ export {
EmbeddablePackageState,
EmbeddableRenderer,
EmbeddableRendererProps,
useEmbeddableFactory,
} from './lib';
export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service';

View file

@ -9,14 +9,39 @@
import React from 'react';
import { waitFor } from '@testing-library/dom';
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import {
HelloWorldEmbeddable,
HelloWorldEmbeddableFactoryDefinition,
HELLO_WORLD_EMBEDDABLE,
} from '../../tests/fixtures';
import { EmbeddableRenderer } from './embeddable_renderer';
import { EmbeddableRenderer, useEmbeddableFactory } from './embeddable_renderer';
import { embeddablePluginMock } from '../../mocks';
describe('useEmbeddableFactory', () => {
it('should update upstream value changes', async () => {
const { setup, doStart } = embeddablePluginMock.createInstance();
const getFactory = setup.registerEmbeddableFactory(
HELLO_WORLD_EMBEDDABLE,
new HelloWorldEmbeddableFactoryDefinition()
);
doStart();
const { result, waitForNextUpdate } = renderHook(() =>
useEmbeddableFactory({ factory: getFactory(), input: { id: 'hello' } })
);
const [, loading] = result.current;
expect(loading).toBe(true);
await waitForNextUpdate();
const [embeddable] = result.current;
expect(embeddable).toBeDefined();
});
});
describe('<EmbeddableRenderer/>', () => {
test('Render embeddable', () => {
const embeddable = new HelloWorldEmbeddable({ id: 'hello' });

View file

@ -28,12 +28,6 @@ interface EmbeddableRendererPropsWithEmbeddable<I extends EmbeddableInput> {
embeddable: IEmbeddable<I>;
}
function isWithEmbeddable<I extends EmbeddableInput>(
props: EmbeddableRendererProps<I>
): props is EmbeddableRendererPropsWithEmbeddable<I> {
return 'embeddable' in props;
}
interface EmbeddableRendererWithFactory<I extends EmbeddableInput> {
input: I;
onInputUpdated?: (newInput: I) => void;
@ -46,6 +40,72 @@ function isWithFactory<I extends EmbeddableInput>(
return 'factory' in props;
}
export function useEmbeddableFactory<I extends EmbeddableInput>({
input,
factory,
onInputUpdated,
}: EmbeddableRendererWithFactory<I>) {
const [embeddable, setEmbeddable] = useState<IEmbeddable<I> | ErrorEmbeddable | undefined>(
undefined
);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>();
const latestInput = React.useRef(input);
useEffect(() => {
latestInput.current = input;
}, [input]);
useEffect(() => {
let canceled = false;
// keeping track of embeddables created by this component to be able to destroy them
let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined;
setEmbeddable(undefined);
setLoading(true);
factory
.create(latestInput.current!)
.then((createdEmbeddable) => {
if (canceled) {
if (createdEmbeddable) {
createdEmbeddable.destroy();
}
} else {
createdEmbeddableRef = createdEmbeddable;
setEmbeddable(createdEmbeddable);
}
})
.catch((err) => {
if (canceled) return;
setError(err?.message);
})
.finally(() => {
if (canceled) return;
setLoading(false);
});
return () => {
canceled = true;
if (createdEmbeddableRef) {
createdEmbeddableRef.destroy();
}
};
}, [factory]);
useEffect(() => {
if (!embeddable) return;
if (isErrorEmbeddable(embeddable)) return;
if (!onInputUpdated) return;
const sub = embeddable.getInput$().subscribe((newInput) => {
onInputUpdated(newInput);
});
return () => {
sub.unsubscribe();
};
}, [embeddable, onInputUpdated]);
return [embeddable, loading, error] as const;
}
/**
* Helper react component to render an embeddable
* Can be used if you have an embeddable object or an embeddable factory
@ -82,72 +142,22 @@ function isWithFactory<I extends EmbeddableInput>(
export const EmbeddableRenderer = <I extends EmbeddableInput>(
props: EmbeddableRendererProps<I>
) => {
const { input, onInputUpdated } = props;
const [embeddable, setEmbeddable] = useState<IEmbeddable<I> | ErrorEmbeddable | undefined>(
isWithEmbeddable(props) ? props.embeddable : undefined
);
const [loading, setLoading] = useState<boolean>(!isWithEmbeddable(props));
const [error, setError] = useState<string | undefined>();
const latestInput = React.useRef(props.input);
useEffect(() => {
latestInput.current = input;
}, [input]);
const factoryFromProps = isWithFactory(props) ? props.factory : undefined;
const embeddableFromProps = isWithEmbeddable(props) ? props.embeddable : undefined;
useEffect(() => {
let canceled = false;
if (embeddableFromProps) {
setEmbeddable(embeddableFromProps);
return;
}
// keeping track of embeddables created by this component to be able to destroy them
let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined;
if (factoryFromProps) {
setEmbeddable(undefined);
setLoading(true);
factoryFromProps
.create(latestInput.current!)
.then((createdEmbeddable) => {
if (canceled) {
if (createdEmbeddable) {
createdEmbeddable.destroy();
}
} else {
createdEmbeddableRef = createdEmbeddable;
setEmbeddable(createdEmbeddable);
}
})
.catch((err) => {
if (canceled) return;
setError(err?.message);
})
.finally(() => {
if (canceled) return;
setLoading(false);
});
}
return () => {
canceled = true;
if (createdEmbeddableRef) {
createdEmbeddableRef.destroy();
}
};
}, [factoryFromProps, embeddableFromProps]);
useEffect(() => {
if (!embeddable) return;
if (isErrorEmbeddable(embeddable)) return;
if (!onInputUpdated) return;
const sub = embeddable.getInput$().subscribe((newInput) => {
onInputUpdated(newInput);
});
return () => {
sub.unsubscribe();
};
}, [embeddable, onInputUpdated]);
if (isWithFactory(props)) {
return <EmbeddableByFactory {...props} />;
}
return <EmbeddableRoot embeddable={props.embeddable} input={props.input} />;
};
//
const EmbeddableByFactory = <I extends EmbeddableInput>({
factory,
input,
onInputUpdated,
}: EmbeddableRendererWithFactory<I>) => {
const [embeddable, loading, error] = useEmbeddableFactory({
factory,
input,
onInputUpdated,
});
return <EmbeddableRoot embeddable={embeddable} loading={loading} error={error} input={input} />;
};

View file

@ -16,4 +16,8 @@ export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable';
export { withEmbeddableSubscription } from './with_subscription';
export { EmbeddableRoot } from './embeddable_root';
export * from '../../../common/lib/saved_object_embeddable';
export { EmbeddableRenderer, EmbeddableRendererProps } from './embeddable_renderer';
export {
EmbeddableRenderer,
EmbeddableRendererProps,
useEmbeddableFactory,
} from './embeddable_renderer';

View file

@ -542,3 +542,40 @@ test('Check when hide header option is true', async () => {
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}
/>
</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);
});

View file

@ -54,16 +54,20 @@ const removeById = (disabledActions: string[]) => ({ id }: { id: string }) =>
interface Props {
embeddable: IEmbeddable<EmbeddableInput, EmbeddableOutput>;
getActions: UiActionsService['getTriggerCompatibleActions'];
getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'];
getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories'];
overlays: CoreStart['overlays'];
notifications: CoreStart['notifications'];
application: CoreStart['application'];
inspector: InspectorStartContract;
SavedObjectFinder: React.ComponentType<any>;
getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'];
getAllEmbeddableFactories?: EmbeddableStart['getEmbeddableFactories'];
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;
}
interface State {
@ -80,7 +84,11 @@ interface State {
errorEmbeddable?: ErrorEmbeddable;
}
interface PanelUniversalActions {
interface InspectorPanelAction {
inspectPanel: InspectPanelAction;
}
interface BasePanelActions {
customizePanelTitle: CustomizePanelTitleAction;
addPanel: AddPanelAction;
inspectPanel: InspectPanelAction;
@ -88,6 +96,15 @@ interface PanelUniversalActions {
editPanel: EditPanelAction;
}
const emptyObject = {};
type EmptyObject = typeof emptyObject;
type PanelUniversalActions =
| BasePanelActions
| InspectorPanelAction
| (BasePanelActions & InspectorPanelAction)
| EmptyObject;
export class EmbeddablePanel extends React.Component<Props, State> {
private embeddableRoot: React.RefObject<HTMLDivElement>;
private parentSubscription?: Subscription;
@ -117,10 +134,15 @@ export class EmbeddablePanel extends React.Component<Props, State> {
}
private async refreshBadges() {
if (!this.mounted) {
return;
}
if (this.props.showBadges === false) {
return;
}
let badges = await this.props.getActions(PANEL_BADGE_TRIGGER, {
embeddable: this.props.embeddable,
});
if (!this.mounted) return;
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
@ -135,10 +157,15 @@ export class EmbeddablePanel extends React.Component<Props, State> {
}
private async refreshNotifications() {
if (!this.mounted) {
return;
}
if (this.props.showNotifications === false) {
return;
}
let notifications = await this.props.getActions(PANEL_NOTIFICATION_TRIGGER, {
embeddable: this.props.embeddable,
});
if (!this.mounted) return;
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
@ -229,13 +256,18 @@ export class EmbeddablePanel extends React.Component<Props, State> {
paddingSize="none"
role="figure"
aria-labelledby={headerId}
hasShadow={this.props.showShadow}
>
{!this.props.hideHeader && (
<PanelHeader
getActionContextMenuPanel={this.getActionContextMenuPanel}
hidePanelTitle={this.state.hidePanelTitle}
isViewMode={viewOnlyMode}
customizeTitle={this.state.universalActions.customizePanelTitle}
customizeTitle={
'customizePanelTitle' in this.state.universalActions
? this.state.universalActions.customizePanelTitle
: undefined
}
closeContextMenu={this.state.closeContextMenu}
title={title}
badges={this.state.badges}
@ -284,6 +316,23 @@ export class EmbeddablePanel extends React.Component<Props, State> {
};
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;
}
const createGetUserData = (overlays: OverlayStart) =>
async function getUserData(context: { embeddable: IEmbeddable }) {
return new Promise<{ title: string | undefined; hideTitle?: boolean }>((resolve) => {
@ -308,6 +357,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
// Universal actions are exposed on the context menu for every embeddable, they bypass the trigger
// registry.
return {
...actions,
customizePanelTitle: new CustomizePanelTitleAction(createGetUserData(this.props.overlays)),
addPanel: new AddPanelAction(
this.props.getEmbeddableFactory,
@ -317,7 +367,6 @@ export class EmbeddablePanel extends React.Component<Props, State> {
this.props.SavedObjectFinder,
this.props.reportUiCounter
),
inspectPanel: new InspectPanelAction(this.props.inspector),
removePanel: new RemovePanelAction(),
editPanel: new EditPanelAction(
this.props.getEmbeddableFactory,
@ -338,9 +387,13 @@ export class EmbeddablePanel extends React.Component<Props, State> {
regularActions = regularActions.filter(removeDisabledActions);
}
const sortedActions = [...regularActions, ...Object.values(this.state.universalActions)].sort(
sortByOrderField
);
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));
}
return await buildContextMenuForActions({
actions: sortedActions.map((action) => ({

View file

@ -36,7 +36,7 @@ export interface PanelHeaderProps {
embeddable: IEmbeddable;
headerId?: string;
showPlaceholderTitle?: boolean;
customizeTitle: CustomizePanelTitleAction;
customizeTitle?: CustomizePanelTitleAction;
}
function renderBadges(badges: Array<Action<EmbeddableContext>>, embeddable: IEmbeddable) {
@ -177,7 +177,7 @@ export function PanelHeader({
>
{title || placeholderTitle}
</span>
) : (
) : customizeTitle ? (
<EuiLink
color="text"
data-test-subj={'embeddablePanelTitleLink'}
@ -193,7 +193,7 @@ export function PanelHeader({
>
{title || placeholderTitle}
</EuiLink>
);
) : null;
}
return description ? (
<EuiToolTip

View file

@ -843,6 +843,11 @@ export interface SavedObjectEmbeddableInput extends EmbeddableInput {
// @public (undocumented)
export const SELECT_RANGE_TRIGGER = "SELECT_RANGE_TRIGGER";
// Warning: (ae-missing-release-tag) "useEmbeddableFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function useEmbeddableFactory<I extends EmbeddableInput>({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory<I>): readonly [ErrorEmbeddable | IEmbeddable<I, import("./i_embeddable").EmbeddableOutput> | undefined, boolean, string | undefined];
// Warning: (ae-missing-release-tag) "VALUE_CLICK_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)

View file

@ -8,6 +8,7 @@
"requiredPlugins": [
"lens",
"data",
"embeddable",
"developerExamples"
],
"optionalPlugins": [],

View file

@ -20,6 +20,7 @@ import {
} from '@elastic/eui';
import { IndexPattern } from 'src/plugins/data/public';
import { CoreStart } from 'kibana/public';
import { ViewMode } from '../../../../src/plugins/embeddable/public';
import {
TypedLensByValueInput,
PersistedIndexPatternLayer,
@ -193,6 +194,7 @@ export const App = (props: {
</EuiFlexGroup>
<LensComponent
id=""
withActions
style={{ height: 500 }}
timeRange={time}
attributes={getLensAttributes(props.defaultIndexPattern, color)}
@ -211,6 +213,7 @@ export const App = (props: {
onTableRowClick={(_data) => {
// call back event for on table row click event
}}
viewMode={ViewMode.VIEW}
/>
{isSaveModalVisible && (
<LensSaveModalComponent

View file

@ -8,6 +8,7 @@
"data",
"charts",
"expressions",
"inspector",
"navigation",
"urlForwarding",
"visualizations",

View file

@ -5,10 +5,18 @@
* 2.0.
*/
import React from 'react';
import React, { FC, useEffect } from 'react';
import { CoreStart } from 'kibana/public';
import { UiActionsStart } from 'src/plugins/ui_actions/public';
import type { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import {
EmbeddableRenderer,
EmbeddableInput,
EmbeddableOutput,
EmbeddablePanel,
EmbeddableRoot,
EmbeddableStart,
IEmbeddable,
useEmbeddableFactory,
} from '../../../../../../src/plugins/embeddable/public';
import type { LensByReferenceInput, LensByValueInput } from './embeddable';
import type { Document } from '../../persistence';
@ -43,11 +51,69 @@ export type TypedLensByValueInput = Omit<LensByValueInput, 'attributes'> & {
| LensAttributes<'lnsMetric', MetricState>;
};
export type EmbeddableComponentProps = TypedLensByValueInput | LensByReferenceInput;
export type EmbeddableComponentProps = (TypedLensByValueInput | LensByReferenceInput) & {
withActions?: boolean;
};
export function getEmbeddableComponent(embeddableStart: EmbeddableStart) {
interface PluginsStartDependencies {
uiActions: UiActionsStart;
embeddable: EmbeddableStart;
inspector: InspectorStartContract;
}
export function getEmbeddableComponent(core: CoreStart, plugins: PluginsStartDependencies) {
return (props: EmbeddableComponentProps) => {
const { embeddable: embeddableStart, uiActions, inspector } = plugins;
const factory = embeddableStart.getEmbeddableFactory('lens')!;
return <EmbeddableRenderer factory={factory} input={props} />;
const input = { ...props };
const [embeddable, loading, error] = useEmbeddableFactory({ factory, input });
const hasActions = props.withActions === true;
if (embeddable && hasActions) {
return (
<EmbeddablePanelWrapper
embeddable={embeddable as IEmbeddable<EmbeddableInput, EmbeddableOutput>}
uiActions={uiActions}
inspector={inspector}
actionPredicate={() => hasActions}
input={input}
/>
);
}
return <EmbeddableRoot embeddable={embeddable} loading={loading} error={error} input={input} />;
};
}
interface EmbeddablePanelWrapperProps {
embeddable: IEmbeddable<EmbeddableInput, EmbeddableOutput>;
uiActions: PluginsStartDependencies['uiActions'];
inspector: PluginsStartDependencies['inspector'];
actionPredicate: (id: string) => boolean;
input: EmbeddableComponentProps;
}
const EmbeddablePanelWrapper: FC<EmbeddablePanelWrapperProps> = ({
embeddable,
uiActions,
actionPredicate,
inspector,
input,
}) => {
useEffect(() => {
embeddable.updateInput(input);
}, [embeddable, input]);
return (
<EmbeddablePanel
hideHeader={false}
embeddable={embeddable as IEmbeddable<EmbeddableInput, EmbeddableOutput>}
getActions={uiActions.getTriggerCompatibleActions}
inspector={inspector}
actionPredicate={actionPredicate}
showShadow={false}
showBadges={false}
showNotifications={false}
/>
);
};

View file

@ -7,7 +7,7 @@
import { LensPlugin } from './plugin';
export {
export type {
EmbeddableComponentProps,
TypedLensByValueInput,
} from './editor_frame_service/embeddable/embeddable_component';

View file

@ -6,6 +6,7 @@
*/
import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public';
import type { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import { UsageCollectionSetup, UsageCollectionStart } from 'src/plugins/usage_collection/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public';
@ -81,6 +82,7 @@ export interface LensPluginStartDependencies {
savedObjectsTagging?: SavedObjectTaggingPluginStart;
presentationUtil: PresentationUtilPluginStart;
indexPatternFieldEditor: IndexPatternFieldEditorStart;
inspector: InspectorStartContract;
usageCollection?: UsageCollectionStart;
}
@ -256,7 +258,7 @@ export class LensPlugin {
);
return {
EmbeddableComponent: getEmbeddableComponent(startDependencies.embeddable),
EmbeddableComponent: getEmbeddableComponent(core, startDependencies),
SaveModalComponent: getSaveModalComponent(core, startDependencies, this.attributeService!),
navigateToPrefilledEditor: (input: LensEmbeddableInput, openInNewTab?: boolean) => {
// for openInNewTab, we set the time range in url via getEditPath below

View file

@ -78,7 +78,9 @@ export class CustomTimeRangeAction implements Action<TimeRangeActionContext> {
const isMarkdown =
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown';
return Boolean(embeddable && hasTimeRange(embeddable) && !isInputControl && !isMarkdown);
return Boolean(
embeddable && embeddable.parent && hasTimeRange(embeddable) && !isInputControl && !isMarkdown
);
}
public async execute({ embeddable }: TimeRangeActionContext) {