diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index e5a55322a2f10..adf791e8d2f48 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -16,6 +16,7 @@ "visualizations", "dashboard", "uiActions", + "uiActionsEnhanced", "embeddable", "share", "presentationUtil", diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 876cb63b0333d..e3c879d864a46 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -44,6 +44,7 @@ import { VISUALIZE_EDITOR_TRIGGER } from '@kbn/visualizations-plugin/public'; import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public'; import type { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { AdvancedUiActionsSetup } from '@kbn/ui-actions-enhanced-plugin/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; import type { IndexPatternDatasource as IndexPatternDatasourceType, @@ -93,6 +94,7 @@ import type { SaveModalContainerProps } from './app_plugin/save_modal_container' import { setupExpressions } from './expressions'; import { getSearchProvider } from './search_provider'; +import { OpenInDiscoverDrilldown } from './trigger_actions/open_in_discover_drilldown'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -106,6 +108,7 @@ export interface LensPluginSetupDependencies { globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; discover?: DiscoverSetup; + uiActionsEnhanced: AdvancedUiActionsSetup; } export interface LensPluginStartDependencies { @@ -224,6 +227,7 @@ export class LensPlugin { private heatmapVisualization: HeatmapVisualizationType | undefined; private gaugeVisualization: GaugeVisualizationType | undefined; private topNavMenuEntries: LensTopNavMenuEntryGenerator[] = []; + private hasDiscoverAccess: boolean = false; private stopReportManager?: () => void; @@ -240,6 +244,8 @@ export class LensPlugin { eventAnnotation, globalSearch, usageCollection, + uiActionsEnhanced, + discover, }: LensPluginSetupDependencies ) { const startServices = createStartServicesGetter(core.getStartServices); @@ -285,6 +291,15 @@ export class LensPlugin { visualizations.registerAlias(getLensAliasConfig()); + if (discover) { + uiActionsEnhanced.registerDrilldown( + new OpenInDiscoverDrilldown({ + discover, + hasDiscoverAccess: () => this.hasDiscoverAccess, + }) + ); + } + setupExpressions( expressions, () => startServices().plugins.fieldFormats.deserialize, @@ -427,6 +442,7 @@ export class LensPlugin { } start(core: CoreStart, startDependencies: LensPluginStartDependencies): LensPublicStart { + this.hasDiscoverAccess = core.application.capabilities.discover.show as boolean; // unregisters the Visualize action and registers the lens one if (startDependencies.uiActions.hasAction(ACTION_VISUALIZE_FIELD)) { startDependencies.uiActions.unregisterAction(ACTION_VISUALIZE_FIELD); @@ -443,10 +459,7 @@ export class LensPlugin { startDependencies.uiActions.addTriggerAction( CONTEXT_MENU_TRIGGER, - createOpenInDiscoverAction( - startDependencies.discover!, - core.application.capabilities.discover.show as boolean - ) + createOpenInDiscoverAction(startDependencies.discover!, this.hasDiscoverAccess) ); return { diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts index 084bd65b70d31..eebdf04337f69 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts @@ -83,6 +83,7 @@ describe('open in discover action', () => { const embeddable = { getViewUnderlyingDataArgs: jest.fn(() => viewUnderlyingDataArgs), + type: 'lens', }; const discoverUrl = 'https://discover-redirect-url'; diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts index bd666f52bf0bc..54a24aac269b5 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts @@ -5,17 +5,23 @@ * 2.0. */ -import type { IEmbeddable } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { createAction } from '@kbn/ui-actions-plugin/public'; import type { DiscoverStart } from '@kbn/discover-plugin/public'; -import type { Embeddable } from '../embeddable'; -import { DOC_TYPE } from '../../common'; +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { execute, isCompatible } from './open_in_discover_helpers'; const ACTION_OPEN_IN_DISCOVER = 'ACTION_OPEN_IN_DISCOVER'; -export const createOpenInDiscoverAction = (discover: DiscoverStart, hasDiscoverAccess: boolean) => - createAction<{ embeddable: IEmbeddable }>({ +interface Context { + embeddable: IEmbeddable; +} + +export const createOpenInDiscoverAction = ( + discover: Pick, + hasDiscoverAccess: boolean +) => + createAction({ type: ACTION_OPEN_IN_DISCOVER, id: ACTION_OPEN_IN_DISCOVER, order: 19, // right after Inspect which is 20 @@ -24,18 +30,10 @@ export const createOpenInDiscoverAction = (discover: DiscoverStart, hasDiscoverA i18n.translate('xpack.lens.app.exploreDataInDiscover', { defaultMessage: 'Explore data in Discover', }), - isCompatible: async (context: { embeddable: IEmbeddable }) => { - if (!hasDiscoverAccess) return false; - return ( - context.embeddable.type === DOC_TYPE && - (await (context.embeddable as Embeddable).canViewUnderlyingData()) - ); + isCompatible: async (context: Context) => { + return isCompatible({ hasDiscoverAccess, discover, embeddable: context.embeddable }); }, - execute: async (context: { embeddable: Embeddable }) => { - const args = context.embeddable.getViewUnderlyingDataArgs()!; - const discoverUrl = discover.locator?.getRedirectUrl({ - ...args, - }); - window.open(discoverUrl, '_blank'); + execute: async (context: Context) => { + return execute({ ...context, discover, hasDiscoverAccess }); }, }); diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx new file mode 100644 index 0000000000000..bd1fc948eb937 --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx @@ -0,0 +1,65 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FormEvent } from 'react'; +import { IEmbeddable, EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { DiscoverSetup } from '@kbn/discover-plugin/public'; +import { execute, isCompatible } from './open_in_discover_helpers'; +import { mount } from 'enzyme'; +import { Filter } from '@kbn/es-query'; +import { + ActionFactoryContext, + CollectConfigProps, + OpenInDiscoverDrilldown, +} from './open_in_discover_drilldown'; + +jest.mock('./open_in_discover_helpers', () => ({ + isCompatible: jest.fn(() => true), + execute: jest.fn(), +})); + +describe('open in discover drilldown', () => { + let drilldown: OpenInDiscoverDrilldown; + beforeEach(() => { + drilldown = new OpenInDiscoverDrilldown({ + discover: {} as DiscoverSetup, + hasDiscoverAccess: () => true, + }); + }); + it('provides UI to edit config', () => { + const Component = (drilldown as unknown as { ReactCollectConfig: React.FC }) + .ReactCollectConfig; + const setConfig = jest.fn(); + const instance = mount( + + ); + instance.find('EuiSwitch').prop('onChange')!({} as unknown as FormEvent<{}>); + expect(setConfig).toHaveBeenCalledWith({ openInNewTab: true }); + }); + it('calls through to isCompatible helper', () => { + const filters: Filter[] = [{ meta: { disabled: false } }]; + drilldown.isCompatible( + { openInNewTab: true }, + { embeddable: { type: 'lens' } as IEmbeddable, filters } + ); + expect(isCompatible).toHaveBeenCalledWith(expect.objectContaining({ filters })); + }); + it('calls through to execute helper', () => { + const filters: Filter[] = [{ meta: { disabled: false } }]; + drilldown.execute( + { openInNewTab: true }, + { embeddable: { type: 'lens' } as IEmbeddable, filters } + ); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ filters, openInSameTab: false }) + ); + }); +}); diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.tsx b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.tsx new file mode 100644 index 0000000000000..d957b9cafd4be --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.tsx @@ -0,0 +1,139 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { IEmbeddable, EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { + Query, + Filter, + TimeRange, + extractTimeRange, + APPLY_FILTER_TRIGGER, +} from '@kbn/data-plugin/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public'; +import { reactToUiComponent } from '@kbn/kibana-react-plugin/public'; +import { + UiActionsEnhancedDrilldownDefinition as Drilldown, + UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, +} from '@kbn/ui-actions-enhanced-plugin/public'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { DiscoverSetup } from '@kbn/discover-plugin/public'; +import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { execute, isCompatible, isLensEmbeddable } from './open_in_discover_helpers'; + +interface EmbeddableQueryInput extends EmbeddableInput { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; +} + +/** @internal */ +export type EmbeddableWithQueryInput = IEmbeddable; + +interface UrlDrilldownDeps { + discover: Pick; + hasDiscoverAccess: () => boolean; +} + +export type ActionContext = ApplyGlobalFilterActionContext; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type Config = { + openInNewTab: boolean; +}; + +export type OpenInDiscoverTrigger = typeof APPLY_FILTER_TRIGGER; + +export interface ActionFactoryContext extends BaseActionFactoryContext { + embeddable?: EmbeddableWithQueryInput; +} +export type CollectConfigProps = CollectConfigPropsBase; + +const OPEN_IN_DISCOVER_DRILLDOWN = 'OPEN_IN_DISCOVER_DRILLDOWN'; + +export class OpenInDiscoverDrilldown + implements Drilldown +{ + public readonly id = OPEN_IN_DISCOVER_DRILLDOWN; + + constructor(private readonly deps: UrlDrilldownDeps) {} + + public readonly order = 8; + + public readonly getDisplayName = () => + i18n.translate('xpack.lens.app.exploreDataInDiscoverDrilldown', { + defaultMessage: 'Open in Discover', + }); + + public readonly euiIcon = 'discoverApp'; + + supportedTriggers(): OpenInDiscoverTrigger[] { + return [APPLY_FILTER_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC = ({ + config, + onConfig, + context, + }) => { + return ( + + onConfig({ ...config, openInNewTab: !config.openInNewTab })} + data-test-subj="openInDiscoverDrilldownOpenInNewTab" + /> + + ); + }; + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + openInNewTab: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + return true; + }; + + public readonly isCompatible = async (config: Config, context: ActionContext) => { + return isCompatible({ + discover: this.deps.discover, + hasDiscoverAccess: this.deps.hasDiscoverAccess(), + ...context, + embeddable: context.embeddable as IEmbeddable, + ...config, + }); + }; + + public readonly isConfigurable = (context: ActionFactoryContext) => { + return this.deps.hasDiscoverAccess() && isLensEmbeddable(context.embeddable as IEmbeddable); + }; + + public readonly execute = async (config: Config, context: ActionContext) => { + const { restOfFilters: filters, timeRange: timeRange } = extractTimeRange( + context.filters, + context.timeFieldName + ); + execute({ + discover: this.deps.discover, + hasDiscoverAccess: this.deps.hasDiscoverAccess(), + ...context, + embeddable: context.embeddable as IEmbeddable, + openInSameTab: !config.openInNewTab, + filters, + timeRange, + }); + }; +} diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_helpers.ts b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_helpers.ts new file mode 100644 index 0000000000000..87f0931f1a3db --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_helpers.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DiscoverSetup } from '@kbn/discover-plugin/public'; +import { Filter } from '@kbn/es-query'; +import { TimeRange } from '@kbn/data-plugin/public'; +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { Embeddable } from '../embeddable'; +import { DOC_TYPE } from '../../common'; + +interface Context { + embeddable: IEmbeddable; + filters?: Filter[]; + timeRange?: TimeRange; + openInSameTab?: boolean; + hasDiscoverAccess: boolean; + discover: Pick; +} + +export function isLensEmbeddable(embeddable: IEmbeddable): embeddable is Embeddable { + return embeddable.type === DOC_TYPE; +} + +export async function isCompatible({ hasDiscoverAccess, embeddable }: Context) { + if (!hasDiscoverAccess) return false; + return isLensEmbeddable(embeddable) && (await embeddable.canViewUnderlyingData()); +} + +export function execute({ embeddable, discover, timeRange, filters, openInSameTab }: Context) { + if (!isLensEmbeddable(embeddable)) { + // shouldn't be executed because of the isCompatible check + throw new Error('Can only be executed in the context of Lens visualization'); + } + const args = embeddable.getViewUnderlyingDataArgs(); + if (!args) { + // shouldn't be executed because of the isCompatible check + throw new Error('Underlying data is not ready'); + } + const discoverUrl = discover.locator?.getRedirectUrl({ + ...args, + timeRange: timeRange || args.timeRange, + filters: [...(filters || []), ...args.filters], + }); + window.open(discoverUrl, !openInSameTab ? '_blank' : '_self'); +} diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index e00581833f621..20def97df7aed 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../../../src/core/tsconfig.json" }, { "path": "../task_manager/tsconfig.json" }, { "path": "../global_search/tsconfig.json" }, + { "path": "../ui_actions_enhanced/tsconfig.json" }, { "path": "../saved_objects_tagging/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../../../src/plugins/data_views/tsconfig.json" }, diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index 4d64f02d2c14b..4928b368a96b4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -95,6 +95,12 @@ export interface DrilldownDefinition< */ isConfigValid: ActionFactoryDefinition['isConfigValid']; + /** + * Compatibility check during drilldown creation. + * Could be used to filter out a drilldown if it's not compatible with the current context. + */ + isConfigurable?(context: FactoryContext): boolean; + /** * Name of EUI icon to display when showing this drilldown to user. */ diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx index db9951f235dfc..f52ac6e161577 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { ActionFactoryPicker as ActionFactoryPickerUi } from '../../../../components/action_factory_picker'; import { useDrilldownManager } from '../context'; import { ActionFactoryView } from '../action_factory_view'; @@ -14,14 +15,19 @@ export const ActionFactoryPicker: React.FC = ({}) => { const drilldowns = useDrilldownManager(); const factory = drilldowns.useActionFactory(); const context = React.useMemo(() => drilldowns.getActionFactoryContext(), [drilldowns]); + const compatibleFactories = drilldowns.useCompatibleActionFactories(context); if (!!factory) { return ; } + if (!compatibleFactories) { + return ; + } + return ( { drilldowns.setActionFactory(actionFactory); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts index 15997355a2ae2..231057a50ee1f 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts @@ -6,9 +6,10 @@ */ import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import type { SerializableRecord } from '@kbn/utility-types'; +import { useMemo } from 'react'; import { PublicDrilldownManagerProps, DrilldownManagerDependencies, @@ -255,6 +256,24 @@ export class DrilldownManagerState { return context; } + public getCompatibleActionFactories( + context: BaseActionFactoryContext + ): Observable { + const compatibleActionFactories$ = new BehaviorSubject(undefined); + Promise.allSettled( + this.deps.actionFactories.map((factory) => factory.isCompatible(context)) + ).then((factoryCompatibility) => { + compatibleActionFactories$.next( + this.deps.actionFactories.filter((_factory, i) => { + const result = factoryCompatibility[i]; + // treat failed isCompatible checks as non-compatible + return result.status === 'fulfilled' && result.value; + }) + ); + }); + return compatibleActionFactories$.asObservable(); + } + /** * Get state object of the drilldown which is currently being created. */ @@ -478,4 +497,9 @@ export class DrilldownManagerState { public readonly useActionFactory = () => useObservable(this.actionFactory$, this.actionFactory$.getValue()); public readonly useEvents = () => useObservable(this.events$, this.events$.getValue()); + public readonly useCompatibleActionFactories = (context: BaseActionFactoryContext) => + useObservable( + useMemo(() => this.getCompatibleActionFactories(context), [context]), + undefined + ); } diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts index 63f90d5a55a1f..fb2dc3ea5bd03 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts @@ -116,6 +116,7 @@ export class UiActionsServiceEnhancements licenseFeatureName, supportedTriggers, isCompatible, + isConfigurable, telemetry, extract, inject, @@ -135,7 +136,7 @@ export class UiActionsServiceEnhancements extract, inject, getIconType: () => euiIcon, - isCompatible: async () => true, + isCompatible: async (context) => !isConfigurable || isConfigurable(context), create: (serializedAction) => ({ id: '', type: factoryId,