Skip to content

Commit

Permalink
[ES|QL] Creates charts from the dashboard (#171973)
Browse files Browse the repository at this point in the history
## Summary

Closes #165928

Enables the creation of ES|QL charts from the dashboard.


![esql](https://github.com/elastic/kibana/assets/17003240/86dd5594-d130-4fb7-b495-29ddbaee5e5b)

The implementation is using UIActions which I think is the correct way
to register a new panel action to a dashboard. Lens is responsible to
register the ESQL panel action and owns the code.

### How it works
- A new ES|QL panel has been added to the dashboard toolbar registered
by a ui action
- A new panel is been created with a default esql query `from
<default_dataview_index_pattern> | limit 10`
- This results to a datatable and opens the flyout
- If a user clicks cancel  then the embeddable is being removed

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
stratoula and kibanamachine authored Dec 20, 2023
1 parent cdb1047 commit 0503b82
Show file tree
Hide file tree
Showing 29 changed files with 663 additions and 34 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 { getAddPanelActionMenuItems } from './add_panel_action_menu_items';

describe('getAddPanelActionMenuItems', () => {
it('returns the items correctly', async () => {
const registeredActions = [
{
id: 'ACTION_CREATE_ESQL_CHART',
type: 'ACTION_CREATE_ESQL_CHART',
getDisplayName: () => 'Action name',
getIconType: () => 'pencil',
getDisplayNameTooltip: () => 'Action tooltip',
isCompatible: () => Promise.resolve(true),
execute: jest.fn(),
},
];
const items = getAddPanelActionMenuItems(registeredActions, jest.fn(), jest.fn(), jest.fn());
expect(items).toStrictEqual([
{
'data-test-subj': 'create-action-Action name',
icon: 'pencil',
name: 'Action name',
onClick: expect.any(Function),
toolTipContent: 'Action tooltip',
},
]);
});

it('returns empty array if no actions have been registered', async () => {
const items = getAddPanelActionMenuItems([], jest.fn(), jest.fn(), jest.fn());
expect(items).toStrictEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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 type { ActionExecutionContext, Action } from '@kbn/ui-actions-plugin/public';
import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { addPanelMenuTrigger } from '../../triggers';

const onAddPanelActionClick =
(action: Action, context: ActionExecutionContext<object>, closePopover: () => void) =>
(event: React.MouseEvent) => {
closePopover();
if (event.currentTarget instanceof HTMLAnchorElement) {
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 &&
(!event.currentTarget.target || event.currentTarget.target === '_self') &&
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
) {
event.preventDefault();
action.execute(context);
}
} else action.execute(context);
};

export const getAddPanelActionMenuItems = (
actions: Array<Action<object>> | undefined,
createNewEmbeddable: (embeddableFactory: EmbeddableFactory) => void,
deleteEmbeddable: (embeddableId: string) => void,
closePopover: () => void
) => {
return (
actions?.map((item) => {
const context = {
createNewEmbeddable,
deleteEmbeddable,
trigger: addPanelMenuTrigger,
};
const actionName = item.getDisplayName(context);
return {
name: actionName,
icon: item.getIconType(context),
onClick: onAddPanelActionClick(item, context, closePopover),
'data-test-subj': `create-action-${actionName}`,
toolTipContent: item?.getDisplayNameTooltip?.(context),
};
}) ?? []
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -80,21 +80,37 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
[stateTransferService, dashboard, search.session, trackUiMetric]
);

/**
* embeddableFactory: Required, you can get the factory from embeddableStart.getEmbeddableFactory(<embeddable type, i.e. lens>)
* initialInput: Optional, use it in case you want to pass your own input to the factory
* dismissNotification: Optional, if not passed a toast will appear in the dashboard
*/
const createNewEmbeddable = useCallback(
async (embeddableFactory: EmbeddableFactory) => {
async (
embeddableFactory: EmbeddableFactory,
initialInput?: Partial<EmbeddableInput>,
dismissNotification?: boolean
) => {
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, embeddableFactory.type);
}

let explicitInput: Partial<EmbeddableInput>;
let attributes: unknown;
try {
const explicitInputReturn = await embeddableFactory.getExplicitInput(undefined, dashboard);
if (isExplicitInputWithAttributes(explicitInputReturn)) {
explicitInput = explicitInputReturn.newInput;
attributes = explicitInputReturn.attributes;
if (initialInput) {
explicitInput = initialInput;
} else {
explicitInput = explicitInputReturn;
const explicitInputReturn = await embeddableFactory.getExplicitInput(
undefined,
dashboard
);
if (isExplicitInputWithAttributes(explicitInputReturn)) {
explicitInput = explicitInputReturn.newInput;
attributes = explicitInputReturn.attributes;
} else {
explicitInput = explicitInputReturn;
}
}
} catch (e) {
// error likely means user canceled embeddable creation
Expand All @@ -110,19 +126,31 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
if (newEmbeddable) {
dashboard.setScrollToPanelId(newEmbeddable.id);
dashboard.setHighlightPanelId(newEmbeddable.id);
toasts.addSuccess({
title: dashboardReplacePanelActionStrings.getSuccessMessage(newEmbeddable.getTitle()),
'data-test-subj': 'addEmbeddableToDashboardSuccess',
});

if (!dismissNotification) {
toasts.addSuccess({
title: dashboardReplacePanelActionStrings.getSuccessMessage(newEmbeddable.getTitle()),
'data-test-subj': 'addEmbeddableToDashboardSuccess',
});
}
}
return newEmbeddable;
},
[trackUiMetric, dashboard, toasts]
);

const deleteEmbeddable = useCallback(
(embeddableId: string) => {
dashboard.removeEmbeddable(embeddableId);
},
[dashboard]
);

const extraButtons = [
<EditorMenu
createNewVisType={createNewVisType}
createNewEmbeddable={createNewEmbeddable}
deleteEmbeddable={deleteEmbeddable}
isDisabled={isDisabled}
/>,
<AddFromLibraryButton
Expand Down
48 changes: 46 additions & 2 deletions src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
EuiBadge,
EuiContextMenu,
Expand All @@ -18,17 +18,22 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ToolbarPopover } from '@kbn/shared-ux-button-toolbar';
import type { Action } from '@kbn/ui-actions-plugin/public';
import { type BaseVisType, VisGroups, type VisTypeAlias } from '@kbn/visualizations-plugin/public';
import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { pluginServices } from '../../services/plugin_services';
import { DASHBOARD_APP_ID } from '../../dashboard_constants';
import { ADD_PANEL_TRIGGER } from '../../triggers';
import { getAddPanelActionMenuItems } from './add_panel_action_menu_items';

interface Props {
isDisabled?: boolean;
/** Handler for creating new visualization of a specified type */
createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void;
/** Handler for creating a new embeddable of a specified type */
createNewEmbeddable: (embeddableFactory: EmbeddableFactory) => void;
/** Handler for deleting an embeddable */
deleteEmbeddable: (embeddableId: string) => void;
}

interface FactoryGroup {
Expand All @@ -44,14 +49,21 @@ interface UnwrappedEmbeddableFactory {
isEditable: boolean;
}

export const EditorMenu = ({ createNewVisType, createNewEmbeddable, isDisabled }: Props) => {
export const EditorMenu = ({
createNewVisType,
createNewEmbeddable,
deleteEmbeddable,
isDisabled,
}: Props) => {
const isMounted = useRef(false);
const {
embeddable,
visualizations: {
getAliases: getVisTypeAliases,
getByGroup: getVisTypesByGroup,
showNewVisModal,
},
uiActions,
} = pluginServices.getServices();

const { euiTheme } = useEuiTheme();
Expand All @@ -64,6 +76,10 @@ export const EditorMenu = ({ createNewVisType, createNewEmbeddable, isDisabled }
UnwrappedEmbeddableFactory[]
>([]);

const [addPanelActions, setAddPanelActions] = useState<Array<Action<object>> | undefined>(
undefined
);

useEffect(() => {
Promise.all(
embeddableFactories.map<Promise<UnwrappedEmbeddableFactory>>(async (factory) => ({
Expand Down Expand Up @@ -121,6 +137,28 @@ export const EditorMenu = ({ createNewVisType, createNewEmbeddable, isDisabled }

let panelCount = 1 + aggBasedPanelID;

useEffect(() => {
isMounted.current = true;

return () => {
isMounted.current = false;
};
}, []);

// Retrieve ADD_PANEL_TRIGGER actions
useEffect(() => {
async function loadPanelActions() {
const registeredActions = await uiActions?.getTriggerCompatibleActions?.(
ADD_PANEL_TRIGGER,
{}
);
if (isMounted.current) {
setAddPanelActions(registeredActions);
}
}
loadPanelActions();
}, [uiActions]);

factories.forEach(({ factory }) => {
const { grouping } = factory;

Expand Down Expand Up @@ -236,6 +274,12 @@ export const EditorMenu = ({ createNewVisType, createNewEmbeddable, isDisabled }
})),

...promotedVisTypes.map(getVisTypeMenuItem),
...getAddPanelActionMenuItems(
addPanelActions,
createNewEmbeddable,
deleteEmbeddable,
closePopover
),
];
if (aggsBasedVisTypes.length > 0) {
initialPanelItems.push({
Expand Down
15 changes: 14 additions & 1 deletion src/plugins/dashboard/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
import { DashboardMountContextProps } from './dashboard_app/types';
import type { FindDashboardsService } from './services/dashboard_content_management/types';
import { CONTENT_ID, LATEST_VERSION } from '../common/content_management';
import { addPanelMenuTrigger } from './triggers';

export interface DashboardFeatureFlagConfig {
allowByValueEmbeddables: boolean;
Expand Down Expand Up @@ -149,11 +150,23 @@ export class DashboardPlugin

public setup(
core: CoreSetup<DashboardStartDependencies, DashboardStart>,
{ share, embeddable, home, urlForwarding, data, contentManagement }: DashboardSetupDependencies
{
share,
embeddable,
home,
urlForwarding,
data,
contentManagement,
uiActions,
}: DashboardSetupDependencies
): DashboardSetup {
this.dashboardFeatureFlagConfig =
this.initializerContext.config.get<DashboardFeatureFlagConfig>();

// this trigger enables external consumers to register actions for
// adding items to the add panel menu
uiActions.registerTrigger(addPanelMenuTrigger);

if (share) {
this.locator = share.url.locators.create(
new DashboardAppLocatorDefinition({
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/dashboard/public/services/plugin_services.stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { savedObjectsManagementServiceFactory } from './saved_objects_management
import { contentManagementServiceFactory } from './content_management/content_management_service.stub';
import { serverlessServiceFactory } from './serverless/serverless_service.stub';
import { noDataPageServiceFactory } from './no_data_page/no_data_page_service.stub';
import { uiActionsServiceFactory } from './ui_actions/ui_actions_service.stub';

export const providers: PluginServiceProviders<DashboardServices> = {
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory),
Expand Down Expand Up @@ -74,6 +75,7 @@ export const providers: PluginServiceProviders<DashboardServices> = {
contentManagement: new PluginServiceProvider(contentManagementServiceFactory),
serverless: new PluginServiceProvider(serverlessServiceFactory),
noDataPage: new PluginServiceProvider(noDataPageServiceFactory),
uiActions: new PluginServiceProvider(uiActionsServiceFactory),
};

export const registry = new PluginServiceRegistry<DashboardServices>(providers);
2 changes: 2 additions & 0 deletions src/plugins/dashboard/public/services/plugin_services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { dashboardContentManagementServiceFactory } from './dashboard_content_ma
import { contentManagementServiceFactory } from './content_management/content_management_service';
import { serverlessServiceFactory } from './serverless/serverless_service';
import { noDataPageServiceFactory } from './no_data_page/no_data_page_service';
import { uiActionsServiceFactory } from './ui_actions/ui_actions_service';

const providers: PluginServiceProviders<DashboardServices, DashboardPluginServiceParams> = {
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory, [
Expand Down Expand Up @@ -88,6 +89,7 @@ const providers: PluginServiceProviders<DashboardServices, DashboardPluginServic
contentManagement: new PluginServiceProvider(contentManagementServiceFactory),
serverless: new PluginServiceProvider(serverlessServiceFactory),
noDataPage: new PluginServiceProvider(noDataPageServiceFactory),
uiActions: new PluginServiceProvider(uiActionsServiceFactory),
};

export const pluginServices = new PluginServices<DashboardServices>();
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/dashboard/public/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { DashboardUsageCollectionService } from './usage_collection/types';
import { DashboardVisualizationsService } from './visualizations/types';
import { DashboardServerlessService } from './serverless/types';
import { NoDataPageService } from './no_data_page/types';
import { DashboardUiActionsService } from './ui_actions/types';

export type DashboardPluginServiceParams = KibanaPluginServiceParams<DashboardStartDependencies> & {
initContext: PluginInitializerContext; // need a custom type so that initContext is a required parameter for initializerContext
Expand Down Expand Up @@ -74,4 +75,5 @@ export interface DashboardServices {
contentManagement: ContentManagementPublicStart;
serverless: DashboardServerlessService; // TODO: make this optional in follow up
noDataPage: NoDataPageService;
uiActions: DashboardUiActionsService;
}
13 changes: 13 additions & 0 deletions src/plugins/dashboard/public/services/ui_actions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* 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 type { UiActionsStart } from '@kbn/ui-actions-plugin/public';

export interface DashboardUiActionsService {
getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions'];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { DashboardUiActionsService } from './types';

export type UIActionsServiceFactory = PluginServiceFactory<DashboardUiActionsService>;

export const uiActionsServiceFactory: UIActionsServiceFactory = () => {
const pluginMock = uiActionsPluginMock.createStartContract();
return { getTriggerCompatibleActions: pluginMock.getTriggerCompatibleActions };
};
Loading

0 comments on commit 0503b82

Please sign in to comment.