[Lens] Make open in discover drilldown work (#131237)

* make open in discover drilldown work

* cleanup and tests

* fix test

* fix icon

* fix type

* fix open in new tab

* fix open in new tab

* fix test

* make it possible to filter out drilldowns from list based on context

* review comments

* remove isConfigurable from the actionfactory

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Joe Reuter 2022-05-09 16:07:29 +02:00 committed by GitHub
parent 58f480b909
commit a8017dffd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 328 additions and 24 deletions

View file

@ -16,6 +16,7 @@
"visualizations",
"dashboard",
"uiActions",
"uiActionsEnhanced",
"embeddable",
"share",
"presentationUtil",

View file

@ -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 {

View file

@ -83,6 +83,7 @@ describe('open in discover action', () => {
const embeddable = {
getViewUnderlyingDataArgs: jest.fn(() => viewUnderlyingDataArgs),
type: 'lens',
};
const discoverUrl = 'https://discover-redirect-url';

View file

@ -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<DiscoverStart, 'locator'>,
hasDiscoverAccess: boolean
) =>
createAction<Context>({
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 });
},
});

View file

@ -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<CollectConfigProps> })
.ReactCollectConfig;
const setConfig = jest.fn();
const instance = mount(
<Component
config={{ openInNewTab: false }}
onConfig={setConfig}
context={{} as ActionFactoryContext}
/>
);
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<EmbeddableInput>, 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<EmbeddableInput>, filters }
);
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ filters, openInSameTab: false })
);
});
});

View file

@ -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<EmbeddableQueryInput>;
interface UrlDrilldownDeps {
discover: Pick<DiscoverSetup, 'locator'>;
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<Config, ActionFactoryContext>;
const OPEN_IN_DISCOVER_DRILLDOWN = 'OPEN_IN_DISCOVER_DRILLDOWN';
export class OpenInDiscoverDrilldown
implements Drilldown<Config, ActionContext, ActionFactoryContext>
{
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<CollectConfigProps> = ({
config,
onConfig,
context,
}) => {
return (
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
id="openInNewTab"
name="openInNewTab"
label={i18n.translate('xpack.lens.app.exploreDataInDiscoverDrilldown.newTabConfig', {
defaultMessage: 'Open in new tab',
})}
checked={config.openInNewTab}
onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })}
data-test-subj="openInDiscoverDrilldownOpenInNewTab"
/>
</EuiFormRow>
);
};
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,
});
};
}

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; 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<DiscoverSetup, 'locator'>;
}
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');
}

View file

@ -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" },

View file

@ -95,6 +95,12 @@ export interface DrilldownDefinition<
*/
isConfigValid: ActionFactoryDefinition<Config, ExecutionContext, FactoryContext>['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.
*/

View file

@ -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 <ActionFactoryView factory={factory} context={context} />;
}
if (!compatibleFactories) {
return <EuiLoadingSpinner size="m" />;
}
return (
<ActionFactoryPickerUi
actionFactories={drilldowns.deps.actionFactories}
actionFactories={compatibleFactories}
context={context}
onSelect={(actionFactory) => {
drilldowns.setActionFactory(actionFactory);

View file

@ -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<ActionFactory[] | undefined> {
const compatibleActionFactories$ = new BehaviorSubject<undefined | ActionFactory[]>(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
);
}

View file

@ -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,