Skip to content

Commit

Permalink
[Dashboard][Lens] Add "convert to lens" action to dashboard (#146363)
Browse files Browse the repository at this point in the history
## Summary

Closes #147032
Completes part of: #144605

Added `convert to lens` action for panel in dashboards.

If legacy visualization can be converted, the notification 'dot' will
shown on context menu.
<img width="828" alt="Снимок экрана 2022-12-02 в 10 50 58"
src="https://user-images.githubusercontent.com/16915480/205253599-3f3f102e-8fdc-497c-9e81-a9e1a146687c.png">

New action looks like this:
<img width="781" alt="Снимок экрана 2022-12-02 в 10 52 42"
src="https://user-images.githubusercontent.com/16915480/205253909-79d65fd8-81d8-4cce-a61a-234d3996cf84.png">

After clicking by that action user will be navigate to lens page and see
the following, where user can replace legacy visualization to lens on
dashboard:
<img width="1347" alt="Снимок экрана 2022-12-02 в 10 53 23"
src="https://user-images.githubusercontent.com/16915480/205254013-6e26b54d-6b92-4da5-be64-01b2876ea847.png">

On save user also can replace panel on dashboard:
<img width="506" alt="Снимок экрана 2022-12-02 в 10 55 22"
src="https://user-images.githubusercontent.com/16915480/205254409-163ebf51-c075-4c9a-a070-cebc7001636d.png">

Co-authored-by: Kibana Machine <[email protected]>
Co-authored-by: Stratoula Kalafateli <[email protected]>
  • Loading branch information
3 people authored Dec 13, 2022
1 parent 0aa6e1c commit a068b2e
Show file tree
Hide file tree
Showing 42 changed files with 694 additions and 52 deletions.
1 change: 1 addition & 0 deletions .buildkite/ftr_configs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ enabled:
- x-pack/test/functional/apps/lens/group3/config.ts
- x-pack/test/functional/apps/lens/open_in_lens/tsvb/config.ts
- x-pack/test/functional/apps/lens/open_in_lens/agg_based/config.ts
- x-pack/test/functional/apps/lens/open_in_lens/dashboard/config.ts
- x-pack/test/functional/apps/license_management/config.ts
- x-pack/test/functional/apps/logstash/config.ts
- x-pack/test/functional/apps/management/config.ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,14 @@ export const buildDashboardContainer = async ({
gridData: originalPanelState.gridData,
type: incomingEmbeddable.type,
explicitInput: {
...(incomingEmbeddable.type === originalPanelState.type && {
...originalPanelState.explicitInput,
}),
// even when we change embeddable type we should keep hidePanelTitles state
// this is temporary, and only required because the key is stored in explicitInput
// when it should be stored outside of it instead.
...(incomingEmbeddable.type === originalPanelState.type
? {
...originalPanelState.explicitInput,
}
: { hidePanelTitles: originalPanelState.explicitInput.hidePanelTitles }),
...incomingEmbeddable.input,
id: incomingEmbeddable.embeddableId,
},
Expand Down
11 changes: 11 additions & 0 deletions src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@
&:focus {
background-color: $euiFocusBackgroundColor;
}

}

.embPanel__optionsMenuPopover-notification::after {
position: absolute;
top: 0;
right: 0;
content: '';
transform: translate(50%, -50%);
color: $euiColorAccent;
font-size: $euiSizeL;
}

.embPanel .embPanel__optionsMenuButton {
Expand Down
7 changes: 6 additions & 1 deletion src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,13 +455,18 @@ export class EmbeddablePanel extends React.Component<Props, State> {
sortedActions = sortedActions.filter(({ id }) => this.props.actionPredicate!(id));
}

return await buildContextMenuForActions({
const panels = await buildContextMenuForActions({
actions: sortedActions.map((action) => ({
action,
context: { embeddable: this.props.embeddable },
trigger: contextMenuTrigger,
})),
closeMenu: this.closeMyContextMenuPanel,
});

return {
panels,
actions: sortedActions,
};
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export interface PanelHeaderProps {
index?: number;
isViewMode: boolean;
hidePanelTitle: boolean;
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor[]>;
getActionContextMenuPanel: () => Promise<{
panels: EuiContextMenuPanelDescriptor[];
actions: Action[];
}>;
closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,26 @@ import {
EuiContextMenuPanelDescriptor,
EuiPopover,
} from '@elastic/eui';
import { Action } from '@kbn/ui-actions-plugin/public';

export interface PanelOptionsMenuProps {
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor[]>;
getActionContextMenuPanel: () => Promise<{
panels: EuiContextMenuPanelDescriptor[];
actions: Action[];
}>;
isViewMode: boolean;
closeContextMenu: boolean;
title?: string;
index?: number;
}

interface State {
actionContextMenuPanel?: EuiContextMenuPanelDescriptor[];
actionContextMenuPanel?: {
panels: EuiContextMenuPanelDescriptor[];
actions: Action[];
};
isPopoverOpen: boolean;
showNotification: boolean;
}

export class PanelOptionsMenu extends React.Component<PanelOptionsMenuProps, State> {
Expand All @@ -47,15 +55,29 @@ export class PanelOptionsMenu extends React.Component<PanelOptionsMenuProps, Sta
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 });
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 });
}
}

Expand Down Expand Up @@ -95,7 +117,10 @@ export class PanelOptionsMenu extends React.Component<PanelOptionsMenuProps, Sta

return (
<EuiPopover
className="embPanel__optionsMenuPopover"
className={
'embPanel__optionsMenuPopover' +
(this.state.showNotification ? ' embPanel__optionsMenuPopover-notification' : '')
}
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
Expand All @@ -109,7 +134,7 @@ export class PanelOptionsMenu extends React.Component<PanelOptionsMenuProps, Sta
>
<EuiContextMenu
initialPanelId="mainMenu"
panels={this.state.actionContextMenuPanel || []}
panels={this.state.actionContextMenuPanel?.panels || []}
/>
</EuiPopover>
);
Expand Down
12 changes: 12 additions & 0 deletions src/plugins/ui_actions/public/actions/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export interface Action<Context extends object = object>
*
*/
disabled?: boolean;

/**
* Determines if notification should be shown in menu for that action
*
*/
showNotification?: boolean;
}

/**
Expand Down Expand Up @@ -151,6 +157,12 @@ export interface ActionDefinition<Context extends object = object>
*
*/
disabled?: boolean;

/**
* Determines if notification should be shown in menu for that action
*
*/
showNotification?: boolean;
}

export type ActionContext<A> = A extends ActionDefinition<infer Context> ? Context : never;
2 changes: 2 additions & 0 deletions src/plugins/ui_actions/public/actions/action_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class ActionInternal<A extends ActionDefinition = ActionDefinition>
public readonly MenuItem?: UiComponent<ActionMenuItemProps<Context<A>>>;
public readonly ReactMenuItem?: React.FC<ActionMenuItemProps<Context<A>>>;
public readonly grouping?: PresentableGrouping<Context<A>>;
public readonly showNotification?: boolean;

constructor(public readonly definition: A) {
this.id = this.definition.id;
Expand All @@ -33,6 +34,7 @@ export class ActionInternal<A extends ActionDefinition = ActionDefinition>
this.MenuItem = this.definition.MenuItem;
this.ReactMenuItem = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
this.grouping = this.definition.grouping;
this.showNotification = this.definition.showNotification;
}

public execute(context: Context<A>) {
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/visualizations/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"presentationUtil",
"dataViews",
"dataViewEditor",
"unifiedSearch"
"unifiedSearch",
"usageCollection"
],
"optionalPlugins": ["home", "share", "spaces", "savedObjectsTaggingOss"],
"requiredBundles": ["kibanaUtils", "savedSearch", "kibanaReact", "charts"],
Expand Down
129 changes: 129 additions & 0 deletions src/plugins/visualizations/public/actions/edit_in_lens_action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* 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 { take } from 'rxjs/operators';
import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui';
import { METRIC_TYPE } from '@kbn/analytics';
import { reactToUiComponent } from '@kbn/kibana-react-plugin/public';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { TimefilterContract } from '@kbn/data-plugin/public';
import { i18n } from '@kbn/i18n';
import { IEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
import { Action } from '@kbn/ui-actions-plugin/public';
import { VisualizeEmbeddable } from '../embeddable';
import { DASHBOARD_VISUALIZATION_PANEL_TRIGGER } from '../triggers';
import { getUiActions, getApplication, getEmbeddable, getUsageCollection } from '../services';

export const ACTION_EDIT_IN_LENS = 'ACTION_EDIT_IN_LENS';

export interface EditInLensContext {
embeddable: IEmbeddable;
}

const displayName = i18n.translate('visualizations.actions.editInLens.displayName', {
defaultMessage: 'Convert to Lens',
});

const ReactMenuItem: React.FC = () => {
return (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>{displayName}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color={'accent'}>
{i18n.translate('visualizations.tonNavMenu.tryItBadgeText', {
defaultMessage: 'Try it',
})}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
);
};

const UiMenuItem = reactToUiComponent(ReactMenuItem);

const isVisualizeEmbeddable = (embeddable: IEmbeddable): embeddable is VisualizeEmbeddable => {
return 'getVis' in embeddable;
};

export class EditInLensAction implements Action<EditInLensContext> {
public id = ACTION_EDIT_IN_LENS;
public readonly type = ACTION_EDIT_IN_LENS;
public order = 49;
public showNotification = true;
public currentAppId: string | undefined;

constructor(private readonly timefilter: TimefilterContract) {}

async execute(context: ActionExecutionContext<EditInLensContext>): Promise<void> {
const application = getApplication();
if (application?.currentAppId$) {
application.currentAppId$
.pipe(take(1))
.subscribe((appId: string | undefined) => (this.currentAppId = appId));
application.currentAppId$.subscribe(() => {
getEmbeddable().getStateTransfer().isTransferInProgress = false;
});
}
const { embeddable } = context;
if (isVisualizeEmbeddable(embeddable)) {
const vis = embeddable.getVis();
const navigateToLensConfig = await vis.type.navigateToLens?.(vis, this.timefilter);
const parentSearchSource = vis.data.searchSource?.getParent();
const searchFilters = parentSearchSource?.getField('filter');
const searchQuery = parentSearchSource?.getField('query');
const title = vis.title || embeddable.getOutput().title;
const updatedWithMeta = {
...navigateToLensConfig,
title,
visTypeTitle: vis.type.title,
embeddableId: embeddable.id,
originatingApp: this.currentAppId,
searchFilters,
searchQuery,
isEmbeddable: true,
};
if (navigateToLensConfig) {
if (this.currentAppId) {
getUsageCollection().reportUiCounter(
this.currentAppId,
METRIC_TYPE.CLICK,
ACTION_EDIT_IN_LENS
);
}
getEmbeddable().getStateTransfer().isTransferInProgress = true;
getUiActions().getTrigger(DASHBOARD_VISUALIZATION_PANEL_TRIGGER).exec(updatedWithMeta);
}
}
}

getDisplayName(context: ActionExecutionContext<EditInLensContext>): string {
return displayName;
}

MenuItem = UiMenuItem;

getIconType(context: ActionExecutionContext<EditInLensContext>): string | undefined {
return 'merge';
}

async isCompatible(context: ActionExecutionContext<EditInLensContext>) {
const { embeddable } = context;
if (!isVisualizeEmbeddable(embeddable)) {
return false;
}
const vis = embeddable.getVis();
if (!vis) {
return false;
}
const canNavigateToLens =
embeddable.getExpressionVariables?.()?.canNavigateToLens ??
(await vis.type.navigateToLens?.(vis, this.timefilter));
return Boolean(canNavigateToLens && embeddable.getInput().viewMode === ViewMode.EDIT);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export class VisualizeEmbeddable
VisualizeByValueInput,
VisualizeByReferenceInput
>;
private expressionVariables: Record<string, unknown> | undefined;
private readonly expressionVariablesSubject = new ReplaySubject<
Record<string, unknown> | undefined
>(1);
Expand Down Expand Up @@ -584,12 +585,12 @@ export class VisualizeEmbeddable
private async updateHandler() {
const context = this.getExecutionContext();

const expressionVariables = await this.vis.type.getExpressionVariables?.(
this.expressionVariables = await this.vis.type.getExpressionVariables?.(
this.vis,
this.timefilter
);

this.expressionVariablesSubject.next(expressionVariables);
this.expressionVariablesSubject.next(this.expressionVariables);

const expressionParams: IExpressionLoaderParams = {
searchContext: {
Expand All @@ -600,7 +601,7 @@ export class VisualizeEmbeddable
},
variables: {
embeddableTitle: this.getTitle(),
...expressionVariables,
...this.expressionVariables,
},
searchSessionId: this.input.searchSessionId,
syncColors: this.input.syncColors,
Expand Down Expand Up @@ -651,6 +652,10 @@ export class VisualizeEmbeddable
return this.expressionVariablesSubject.asObservable();
}

public getExpressionVariables() {
return this.expressionVariables;
}

inputIsRefType = (input: VisualizeInput): input is VisualizeByReferenceInput => {
if (!this.attributeService) {
throw new Error('AttributeService must be defined for getInputAsRefType');
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/visualizations/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ export type { IEditorController, EditorRenderProps } from './visualize_app/types
export {
VISUALIZE_EDITOR_TRIGGER,
AGG_BASED_VISUALIZATION_TRIGGER,
DASHBOARD_VISUALIZATION_PANEL_TRIGGER,
ACTION_CONVERT_TO_LENS,
ACTION_CONVERT_AGG_BASED_TO_LENS,
ACTION_CONVERT_DASHBOARD_PANEL_TO_LENS,
} from './triggers';

export const convertToLensModule = import('./convert_to_lens');
Expand Down
Loading

0 comments on commit a068b2e

Please sign in to comment.