mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Drilldowns] URL drilldown MVP (#75450)
This commit is contained in:
parent
70b4b89270
commit
e7d80e7e70
43 changed files with 1836 additions and 174 deletions
|
@ -27,6 +27,6 @@ export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = {
|
|||
defaultMessage: 'Range selection',
|
||||
}),
|
||||
description: i18n.translate('uiActions.triggers.selectRangeDescription', {
|
||||
defaultMessage: 'Select a group of values',
|
||||
defaultMessage: 'A range of values on the visualization',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -27,6 +27,6 @@ export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = {
|
|||
defaultMessage: 'Single click',
|
||||
}),
|
||||
description: i18n.translate('uiActions.triggers.valueClickDescription', {
|
||||
defaultMessage: 'A single point clicked on a visualization',
|
||||
defaultMessage: 'A single point on the visualization',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -1,142 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiCallOut, EuiFieldText, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui';
|
||||
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
|
||||
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public';
|
||||
import { ChartActionContext } from '../../../../../src/plugins/embeddable/public';
|
||||
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public';
|
||||
import {
|
||||
SELECT_RANGE_TRIGGER,
|
||||
VALUE_CLICK_TRIGGER,
|
||||
} from '../../../../../src/plugins/ui_actions/public';
|
||||
import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public';
|
||||
|
||||
function isValidUrl(url: string) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ActionContext = ChartActionContext;
|
||||
|
||||
export interface Config {
|
||||
url: string;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
|
||||
type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER;
|
||||
|
||||
export type CollectConfigProps = CollectConfigPropsBase<Config, { triggers: UrlTrigger[] }>;
|
||||
|
||||
const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN';
|
||||
|
||||
export class DashboardToUrlDrilldown implements Drilldown<Config, UrlTrigger> {
|
||||
public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN;
|
||||
|
||||
public readonly order = 8;
|
||||
|
||||
readonly minimalLicense = 'gold'; // example of minimal license support
|
||||
readonly licenseFeatureName = 'Sample URL Drilldown';
|
||||
|
||||
public readonly getDisplayName = () => 'Go to URL (example)';
|
||||
|
||||
public readonly euiIcon = 'link';
|
||||
|
||||
supportedTriggers(): UrlTrigger[] {
|
||||
return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER];
|
||||
}
|
||||
|
||||
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({
|
||||
config,
|
||||
onConfig,
|
||||
context,
|
||||
}) => (
|
||||
<>
|
||||
<EuiCallOut title="Example warning!" color="warning" iconType="help">
|
||||
<p>
|
||||
This is an example drilldown. It is meant as a starting point for developers, so they can
|
||||
grab this code and get started. It does not provide a complete working functionality but
|
||||
serves as a getting started example.
|
||||
</p>
|
||||
<p>
|
||||
Implementation of the actual <em>Go to URL</em> drilldown is tracked in{' '}
|
||||
<a href="https://github.com/elastic/kibana/issues/55324">#55324</a>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiFormRow label="Enter target URL" fullWidth>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
name="url"
|
||||
placeholder="Enter URL"
|
||||
value={config.url}
|
||||
onChange={(event) => onConfig({ ...config, url: event.target.value })}
|
||||
onBlur={() => {
|
||||
if (!config.url) return;
|
||||
if (/https?:\/\//.test(config.url)) return;
|
||||
onConfig({ ...config, url: 'https://' + config.url });
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="openInNewTab"
|
||||
label="Open in new tab?"
|
||||
checked={config.openInNewTab}
|
||||
onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiCallOut>
|
||||
{/* just demo how can access selected triggers*/}
|
||||
<p>Will be attached to triggers: {JSON.stringify(context.triggers)}</p>
|
||||
</EuiCallOut>
|
||||
</>
|
||||
);
|
||||
|
||||
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
|
||||
|
||||
public readonly createConfig = () => ({
|
||||
url: '',
|
||||
openInNewTab: false,
|
||||
});
|
||||
|
||||
public readonly isConfigValid = (config: Config): config is Config => {
|
||||
if (!config.url) return false;
|
||||
return isValidUrl(config.url);
|
||||
};
|
||||
|
||||
/**
|
||||
* `getHref` is need to support mouse middle-click and Cmd + Click behavior
|
||||
* to open a link in new tab.
|
||||
*/
|
||||
public readonly getHref = async (config: Config, context: ActionContext) => {
|
||||
return config.url;
|
||||
};
|
||||
|
||||
public readonly execute = async (
|
||||
config: Config,
|
||||
context: ActionExecutionContext<ActionContext>
|
||||
) => {
|
||||
// Just for showcasing:
|
||||
// we can get trigger a which caused this drilldown execution
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(context.trigger?.id);
|
||||
|
||||
const url = await this.getHref(config, context);
|
||||
|
||||
if (config.openInNewTab) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -11,7 +11,6 @@ import {
|
|||
AdvancedUiActionsStart,
|
||||
} from '../../../../x-pack/plugins/ui_actions_enhanced/public';
|
||||
import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown';
|
||||
import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown';
|
||||
import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown';
|
||||
import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public';
|
||||
import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public';
|
||||
|
@ -39,7 +38,6 @@ export class UiActionsEnhancedExamplesPlugin
|
|||
|
||||
uiActions.registerDrilldown(new DashboardHelloWorldDrilldown());
|
||||
uiActions.registerDrilldown(new DashboardHelloWorldOnlyRangeSelectDrilldown());
|
||||
uiActions.registerDrilldown(new DashboardToUrlDrilldown());
|
||||
uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start }));
|
||||
}
|
||||
|
||||
|
|
|
@ -80,6 +80,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLY
|
|||
viewMode={'create'}
|
||||
dynamicActionManager={embeddable.enhancements.dynamicActions}
|
||||
supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())}
|
||||
placeContext={{ embeddable }}
|
||||
/>
|
||||
),
|
||||
{
|
||||
|
|
|
@ -64,6 +64,7 @@ export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOU
|
|||
viewMode={'manage'}
|
||||
dynamicActionManager={embeddable.enhancements.dynamicActions}
|
||||
supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())}
|
||||
placeContext={{ embeddable }}
|
||||
/>
|
||||
),
|
||||
{
|
||||
|
|
|
@ -3,5 +3,6 @@
|
|||
"version": "kibana",
|
||||
"server": false,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["embeddable", "uiActionsEnhanced"]
|
||||
"requiredPlugins": ["embeddable", "kibanaReact", "uiActions", "uiActionsEnhanced"],
|
||||
"requiredBundles": ["kibanaUtils"]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export * from './url_drilldown';
|
|
@ -0,0 +1,24 @@
|
|||
# Basic url drilldown implementation
|
||||
|
||||
Url drilldown allows navigating to external URL or to internal kibana URL.
|
||||
By using variables in url template result url can be dynamic and depend on user's interaction.
|
||||
|
||||
URL drilldown has 3 sources for variables:
|
||||
|
||||
- Global static variables like, for example, `kibanaUrl`. Such variables won’t change depending on a place where url drilldown is used.
|
||||
- Context variables are dynamic and different depending on where drilldown is created and used.
|
||||
- Event variables depend on a trigger context. These variables are dynamically extracted from the action context when drilldown is executed.
|
||||
|
||||
Difference between `event` and `context` variables, is that real `context` variables are available during drilldown creation (e.g. embeddable panel),
|
||||
but `event` variables mapped from trigger context. Since there is no trigger context during drilldown creation, we have to provide some _mock_ variables for validating and previewing the URL.
|
||||
|
||||
In current implementation url drilldown has to be used inside the embeddable and with `ValueClickTrigger` or `RangeSelectTrigger`.
|
||||
|
||||
- `context` variables extracted from `embeddable`
|
||||
- `event` variables extracted from `trigger` context
|
||||
|
||||
In future this basic url drilldown implementation would allow injecting more variables into `context` (e.g. `dashboard` app specific variables) and would allow providing support for new trigger types from outside.
|
||||
This extensibility improvements are tracked here: https://github.com/elastic/kibana/issues/55324
|
||||
|
||||
In case a solution app has a use case for url drilldown that has to be different from current basic implementation and
|
||||
just extending variables list is not enough, then recommendation is to create own custom url drilldown and reuse building blocks from `ui_actions_enhanced`.
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const txtUrlDrilldownDisplayName = i18n.translate(
|
||||
'xpack.embeddableEnhanced.drilldowns.urlDrilldownDisplayName',
|
||||
{
|
||||
defaultMessage: 'Go to URL',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { UrlDrilldown } from './url_drilldown';
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { UrlDrilldown, ActionContext, Config } from './url_drilldown';
|
||||
import { coreMock } from '../../../../../../src/core/public/mocks';
|
||||
import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables';
|
||||
|
||||
const mockDataPoints = [
|
||||
{
|
||||
table: {
|
||||
columns: [
|
||||
{
|
||||
name: 'test',
|
||||
id: '1-1',
|
||||
meta: {
|
||||
type: 'histogram',
|
||||
indexPatternId: 'logstash-*',
|
||||
aggConfigParams: {
|
||||
field: 'bytes',
|
||||
interval: 30,
|
||||
otherBucket: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
'1-1': '2048',
|
||||
},
|
||||
],
|
||||
},
|
||||
column: 0,
|
||||
row: 0,
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const mockEmbeddable = ({
|
||||
getInput: () => ({
|
||||
filters: [],
|
||||
timeRange: { from: 'now-15m', to: 'now' },
|
||||
query: { query: 'test', language: 'kuery' },
|
||||
}),
|
||||
getOutput: () => ({}),
|
||||
} as unknown) as IEmbeddable;
|
||||
|
||||
const mockNavigateToUrl = jest.fn(() => Promise.resolve());
|
||||
|
||||
describe('UrlDrilldown', () => {
|
||||
const urlDrilldown = new UrlDrilldown({
|
||||
getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }),
|
||||
getOpenModal: () => Promise.resolve(coreMock.createStart().overlays.openModal),
|
||||
getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs',
|
||||
navigateToUrl: mockNavigateToUrl,
|
||||
});
|
||||
|
||||
test('license', () => {
|
||||
expect(urlDrilldown.minimalLicense).toBe('gold');
|
||||
});
|
||||
|
||||
describe('isCompatible', () => {
|
||||
test('throws if no embeddable', async () => {
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
};
|
||||
|
||||
const context: ActionContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(urlDrilldown.isCompatible(config, context)).rejects.toThrowError();
|
||||
});
|
||||
|
||||
test('compatible if url is valid', async () => {
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
};
|
||||
|
||||
const context: ActionContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
embeddable: mockEmbeddable,
|
||||
};
|
||||
|
||||
await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('not compatible if url is invalid', async () => {
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
};
|
||||
|
||||
const context: ActionContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
embeddable: mockEmbeddable,
|
||||
};
|
||||
|
||||
await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHref & execute', () => {
|
||||
beforeEach(() => {
|
||||
mockNavigateToUrl.mockReset();
|
||||
});
|
||||
|
||||
test('valid url', async () => {
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
};
|
||||
|
||||
const context: ActionContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
embeddable: mockEmbeddable,
|
||||
};
|
||||
|
||||
const url = await urlDrilldown.getHref(config, context);
|
||||
expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`);
|
||||
|
||||
await urlDrilldown.execute(config, context);
|
||||
expect(mockNavigateToUrl).toBeCalledWith(url);
|
||||
});
|
||||
|
||||
test('invalid url', async () => {
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.invalid}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
};
|
||||
|
||||
const context: ActionContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
embeddable: mockEmbeddable,
|
||||
};
|
||||
|
||||
await expect(urlDrilldown.getHref(config, context)).rejects.toThrowError();
|
||||
await expect(urlDrilldown.execute(config, context)).rejects.toThrowError();
|
||||
expect(mockNavigateToUrl).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { OverlayStart } from 'kibana/public';
|
||||
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { ChartActionContext, IEmbeddable } from '../../../../../../src/plugins/embeddable/public';
|
||||
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public';
|
||||
import {
|
||||
SELECT_RANGE_TRIGGER,
|
||||
VALUE_CLICK_TRIGGER,
|
||||
} from '../../../../../../src/plugins/ui_actions/public';
|
||||
import {
|
||||
UiActionsEnhancedDrilldownDefinition as Drilldown,
|
||||
UrlDrilldownGlobalScope,
|
||||
UrlDrilldownConfig,
|
||||
UrlDrilldownCollectConfig,
|
||||
urlDrilldownValidateUrlTemplate,
|
||||
urlDrilldownBuildScope,
|
||||
urlDrilldownCompileUrl,
|
||||
UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext,
|
||||
} from '../../../../ui_actions_enhanced/public';
|
||||
import { getContextScope, getEventScope, getMockEventScope } from './url_drilldown_scope';
|
||||
import { txtUrlDrilldownDisplayName } from './i18n';
|
||||
|
||||
interface UrlDrilldownDeps {
|
||||
getGlobalScope: () => UrlDrilldownGlobalScope;
|
||||
navigateToUrl: (url: string) => Promise<void>;
|
||||
getOpenModal: () => Promise<OverlayStart['openModal']>;
|
||||
getSyntaxHelpDocsLink: () => string;
|
||||
}
|
||||
|
||||
export type ActionContext = ChartActionContext;
|
||||
export type Config = UrlDrilldownConfig;
|
||||
export type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER;
|
||||
export interface ActionFactoryContext extends BaseActionFactoryContext<UrlTrigger> {
|
||||
embeddable?: IEmbeddable;
|
||||
}
|
||||
export type CollectConfigProps = CollectConfigPropsBase<Config, ActionFactoryContext>;
|
||||
|
||||
const URL_DRILLDOWN = 'URL_DRILLDOWN';
|
||||
|
||||
export class UrlDrilldown implements Drilldown<Config, UrlTrigger, ActionFactoryContext> {
|
||||
public readonly id = URL_DRILLDOWN;
|
||||
|
||||
constructor(private deps: UrlDrilldownDeps) {}
|
||||
|
||||
public readonly order = 8;
|
||||
|
||||
readonly minimalLicense = 'gold';
|
||||
readonly licenseFeatureName = 'URL drilldown';
|
||||
|
||||
public readonly getDisplayName = () => txtUrlDrilldownDisplayName;
|
||||
|
||||
public readonly euiIcon = 'link';
|
||||
|
||||
supportedTriggers(): UrlTrigger[] {
|
||||
return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER];
|
||||
}
|
||||
|
||||
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({
|
||||
config,
|
||||
onConfig,
|
||||
context,
|
||||
}) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const scope = React.useMemo(() => this.buildEditorScope(context), [context]);
|
||||
return (
|
||||
<UrlDrilldownCollectConfig
|
||||
config={config}
|
||||
onConfig={onConfig}
|
||||
scope={scope}
|
||||
syntaxHelpDocsLink={this.deps.getSyntaxHelpDocsLink()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
|
||||
|
||||
public readonly createConfig = () => ({
|
||||
url: { template: '' },
|
||||
openInNewTab: false,
|
||||
});
|
||||
|
||||
public readonly isConfigValid = (
|
||||
config: Config,
|
||||
context: ActionFactoryContext
|
||||
): config is Config => {
|
||||
const { isValid } = urlDrilldownValidateUrlTemplate(config.url, this.buildEditorScope(context));
|
||||
return isValid;
|
||||
};
|
||||
|
||||
public readonly isCompatible = async (config: Config, context: ActionContext) => {
|
||||
const { isValid, error } = urlDrilldownValidateUrlTemplate(
|
||||
config.url,
|
||||
await this.buildRuntimeScope(context)
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`UrlDrilldown [${config.url.template}] is not valid. Error [${error}]. Skipping execution.`
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(isValid);
|
||||
};
|
||||
|
||||
public readonly getHref = async (config: Config, context: ActionContext) =>
|
||||
urlDrilldownCompileUrl(config.url.template, await this.buildRuntimeScope(context));
|
||||
|
||||
public readonly execute = async (config: Config, context: ActionContext) => {
|
||||
const url = await urlDrilldownCompileUrl(
|
||||
config.url.template,
|
||||
await this.buildRuntimeScope(context, { allowPrompts: true })
|
||||
);
|
||||
if (config.openInNewTab) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} else {
|
||||
await this.deps.navigateToUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
private buildEditorScope = (context: ActionFactoryContext) => {
|
||||
return urlDrilldownBuildScope({
|
||||
globalScope: this.deps.getGlobalScope(),
|
||||
contextScope: getContextScope(context),
|
||||
eventScope: getMockEventScope(context.triggers),
|
||||
});
|
||||
};
|
||||
|
||||
private buildRuntimeScope = async (
|
||||
context: ActionContext,
|
||||
opts: { allowPrompts: boolean } = { allowPrompts: false }
|
||||
) => {
|
||||
return urlDrilldownBuildScope({
|
||||
globalScope: this.deps.getGlobalScope(),
|
||||
contextScope: getContextScope(context),
|
||||
eventScope: await getEventScope(context, this.deps, opts),
|
||||
});
|
||||
};
|
||||
}
|
|
@ -0,0 +1,319 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file contains all the logic for mapping from trigger's context and action factory context to variables for URL drilldown scope,
|
||||
* Please refer to ./README.md for explanation of different scope sources
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiRadioGroup,
|
||||
} from '@elastic/eui';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public';
|
||||
import {
|
||||
IEmbeddable,
|
||||
isRangeSelectTriggerContext,
|
||||
isValueClickTriggerContext,
|
||||
RangeSelectContext,
|
||||
ValueClickContext,
|
||||
} from '../../../../../../src/plugins/embeddable/public';
|
||||
import type { ActionContext, ActionFactoryContext, UrlTrigger } from './url_drilldown';
|
||||
import { SELECT_RANGE_TRIGGER } from '../../../../../../src/plugins/ui_actions/public';
|
||||
import { OverlayStart } from '../../../../../../src/core/public';
|
||||
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
type ContextScopeInput = ActionContext | ActionFactoryContext;
|
||||
|
||||
/**
|
||||
* Part of context scope extracted from an embeddable
|
||||
* Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}`
|
||||
*/
|
||||
interface EmbeddableUrlDrilldownContextScope {
|
||||
id: string;
|
||||
title?: string;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
timeRange?: TimeRange;
|
||||
savedObjectId?: string;
|
||||
/**
|
||||
* In case panel supports only 1 index patterns
|
||||
*/
|
||||
indexPatternId?: string;
|
||||
/**
|
||||
* In case panel supports more then 1 index patterns
|
||||
*/
|
||||
indexPatternIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Url drilldown context scope
|
||||
* `{{context.$}}`
|
||||
*/
|
||||
interface UrlDrilldownContextScope {
|
||||
panel?: EmbeddableUrlDrilldownContextScope;
|
||||
}
|
||||
|
||||
export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilldownContextScope {
|
||||
function hasEmbeddable(val: unknown): val is { embeddable: IEmbeddable } {
|
||||
if (val && typeof val === 'object' && 'embeddable' in val) return true;
|
||||
return false;
|
||||
}
|
||||
if (!hasEmbeddable(contextScopeInput))
|
||||
throw new Error(
|
||||
"UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context"
|
||||
);
|
||||
|
||||
const embeddable = contextScopeInput.embeddable;
|
||||
const input = embeddable.getInput();
|
||||
const output = embeddable.getOutput();
|
||||
function hasSavedObjectId(obj: Record<string, any>): obj is { savedObjectId: string } {
|
||||
return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string';
|
||||
}
|
||||
function getIndexPatternIds(): string[] {
|
||||
function hasIndexPatterns(
|
||||
_output: Record<string, any>
|
||||
): _output is { indexPatterns: Array<{ id?: string }> } {
|
||||
return (
|
||||
'indexPatterns' in _output &&
|
||||
Array.isArray(_output.indexPatterns) &&
|
||||
_output.indexPatterns.length > 0
|
||||
);
|
||||
}
|
||||
return hasIndexPatterns(output)
|
||||
? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[])
|
||||
: [];
|
||||
}
|
||||
const indexPatternsIds = getIndexPatternIds();
|
||||
return {
|
||||
panel: cleanEmptyKeys({
|
||||
id: input.id,
|
||||
title: output.title ?? input.title,
|
||||
savedObjectId:
|
||||
output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined),
|
||||
query: input.query,
|
||||
timeRange: input.timeRange,
|
||||
filters: input.filters,
|
||||
indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined,
|
||||
indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* URL drilldown event scope,
|
||||
* available as: {{event.key}}, {{event.from}}
|
||||
*/
|
||||
type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope;
|
||||
type EventScopeInput = ActionContext;
|
||||
interface ValueClickTriggerEventScope {
|
||||
key?: string;
|
||||
value?: string | number | boolean;
|
||||
negate: boolean;
|
||||
}
|
||||
interface RangeSelectTriggerEventScope {
|
||||
key: string;
|
||||
from?: string | number;
|
||||
to?: string | number;
|
||||
}
|
||||
|
||||
export async function getEventScope(
|
||||
eventScopeInput: EventScopeInput,
|
||||
deps: { getOpenModal: () => Promise<OverlayStart['openModal']> },
|
||||
opts: { allowPrompts: boolean } = { allowPrompts: false }
|
||||
): Promise<UrlDrilldownEventScope> {
|
||||
if (isRangeSelectTriggerContext(eventScopeInput)) {
|
||||
return getEventScopeFromRangeSelectTriggerContext(eventScopeInput);
|
||||
} else if (isValueClickTriggerContext(eventScopeInput)) {
|
||||
return getEventScopeFromValueClickTriggerContext(eventScopeInput, deps, opts);
|
||||
} else {
|
||||
throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger");
|
||||
}
|
||||
}
|
||||
|
||||
async function getEventScopeFromRangeSelectTriggerContext(
|
||||
eventScopeInput: RangeSelectContext
|
||||
): Promise<RangeSelectTriggerEventScope> {
|
||||
const { table, column: columnIndex, range } = eventScopeInput.data;
|
||||
const column = table.columns[columnIndex];
|
||||
return cleanEmptyKeys({
|
||||
key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string,
|
||||
from: toPrimitiveOrUndefined(range[0]) as string | number | undefined,
|
||||
to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function getEventScopeFromValueClickTriggerContext(
|
||||
eventScopeInput: ValueClickContext,
|
||||
deps: { getOpenModal: () => Promise<OverlayStart['openModal']> },
|
||||
opts: { allowPrompts: boolean } = { allowPrompts: false }
|
||||
): Promise<ValueClickTriggerEventScope> {
|
||||
const negate = eventScopeInput.data.negate ?? false;
|
||||
const point = await getSingleValue(eventScopeInput.data.data, deps, opts);
|
||||
const { key, value } = getKeyValueFromPoint(point);
|
||||
return cleanEmptyKeys({
|
||||
key,
|
||||
value,
|
||||
negate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks
|
||||
* Difference between `event` and `context` variables, is that real `context` variables are available during drilldown creation (e.g. embeddable panel)
|
||||
* `event` variables are mapped from trigger context. Since there is no trigger context during drilldown creation, we have to provide some _mock_ variables for validating and previewing the URL
|
||||
*/
|
||||
export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventScope {
|
||||
if (trigger === SELECT_RANGE_TRIGGER) {
|
||||
return {
|
||||
key: 'event.key',
|
||||
from: new Date(Date.now() - 15 * 60 * 1000).toISOString(), // 15 minutes ago
|
||||
to: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
key: 'event.key',
|
||||
value: 'event.value',
|
||||
negate: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getKeyValueFromPoint(
|
||||
point: ValueClickContext['data']['data'][0]
|
||||
): Pick<ValueClickTriggerEventScope, 'key' | 'value'> {
|
||||
const { table, column: columnIndex, value } = point;
|
||||
const column = table.columns[columnIndex];
|
||||
return {
|
||||
key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined,
|
||||
value: toPrimitiveOrUndefined(value),
|
||||
};
|
||||
}
|
||||
|
||||
function toPrimitiveOrUndefined(v: unknown): string | number | boolean | undefined {
|
||||
if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v;
|
||||
if (typeof v === 'object' && v instanceof Date) return v.toISOString();
|
||||
if (typeof v === 'undefined' || v === null) return undefined;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function cleanEmptyKeys<T extends Record<string, any>>(obj: T): T {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (obj[key] === undefined) {
|
||||
delete obj[key];
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* VALUE_CLICK_TRIGGER could have multiple data points
|
||||
* Prompt user which data point to use in a drilldown
|
||||
*/
|
||||
async function getSingleValue(
|
||||
data: ValueClickContext['data']['data'],
|
||||
deps: { getOpenModal: () => Promise<OverlayStart['openModal']> },
|
||||
opts: { allowPrompts: boolean } = { allowPrompts: false }
|
||||
): Promise<ValueClickContext['data']['data'][0]> {
|
||||
data = uniqBy(data.filter(Boolean), (point) => {
|
||||
const { key, value } = getKeyValueFromPoint(point);
|
||||
return `${key}:${value}`;
|
||||
});
|
||||
if (data.length === 0)
|
||||
throw new Error(`[trigger = "VALUE_CLICK_TRIGGER"][getSingleValue] no value to pick from`);
|
||||
if (data.length === 1) return Promise.resolve(data[0]);
|
||||
if (!opts.allowPrompts) return Promise.resolve(data[0]);
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const openModal = await deps.getOpenModal();
|
||||
const overlay = openModal(
|
||||
toMountPoint(
|
||||
<GetSingleValuePopup
|
||||
onCancel={() => overlay.close()}
|
||||
onSubmit={(point) => {
|
||||
if (point) {
|
||||
resolve(point);
|
||||
}
|
||||
overlay.close();
|
||||
}}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
);
|
||||
overlay.onClose.then(() => reject());
|
||||
});
|
||||
}
|
||||
|
||||
function GetSingleValuePopup({
|
||||
data,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
data: ValueClickContext['data']['data'];
|
||||
onCancel: () => void;
|
||||
onSubmit: (value: ValueClickContext['data']['data'][0]) => void;
|
||||
}) {
|
||||
const values = data
|
||||
.map((point) => {
|
||||
const { key, value } = getKeyValueFromPoint(point);
|
||||
return {
|
||||
point,
|
||||
id: key ?? '',
|
||||
label: `${key}:${value}`,
|
||||
};
|
||||
})
|
||||
.filter((value) => Boolean(value.id));
|
||||
|
||||
const [selectedValueId, setSelectedValueId] = React.useState(values[0].id);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.popupHeader"
|
||||
defaultMessage="Select a value to drill down into"
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
<EuiRadioGroup
|
||||
options={values}
|
||||
idSelected={selectedValueId}
|
||||
onChange={(id) => setSelectedValueId(id)}
|
||||
name="drilldownValues"
|
||||
/>
|
||||
</EuiModalBody>
|
||||
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={onCancel}>
|
||||
<FormattedMessage
|
||||
id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
<EuiButton
|
||||
onClick={() => onSubmit(values.find((v) => v.id === selectedValueId)?.point!)}
|
||||
data-test-subj="applySingleValuePopoverButton"
|
||||
fill
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.applyButtonLabel"
|
||||
defaultMessage="Apply"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
|
@ -28,8 +28,11 @@ import {
|
|||
UiActionsEnhancedDynamicActionManager as DynamicActionManager,
|
||||
AdvancedUiActionsSetup,
|
||||
AdvancedUiActionsStart,
|
||||
urlDrilldownGlobalScopeProvider,
|
||||
} from '../../ui_actions_enhanced/public';
|
||||
import { PanelNotificationsAction, ACTION_PANEL_NOTIFICATIONS } from './actions';
|
||||
import { UrlDrilldown } from './drilldowns';
|
||||
import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public';
|
||||
|
||||
declare module '../../../../src/plugins/ui_actions/public' {
|
||||
export interface ActionContextMapping {
|
||||
|
@ -61,11 +64,21 @@ export class EmbeddableEnhancedPlugin
|
|||
|
||||
public setup(core: CoreSetup<StartDependencies>, plugins: SetupDependencies): SetupContract {
|
||||
this.setCustomEmbeddableFactoryProvider(plugins);
|
||||
|
||||
const startServices = createStartServicesGetter(core.getStartServices);
|
||||
const panelNotificationAction = new PanelNotificationsAction();
|
||||
plugins.uiActionsEnhanced.registerAction(panelNotificationAction);
|
||||
plugins.uiActionsEnhanced.attachAction(PANEL_NOTIFICATION_TRIGGER, panelNotificationAction.id);
|
||||
|
||||
plugins.uiActionsEnhanced.registerDrilldown(
|
||||
new UrlDrilldown({
|
||||
getGlobalScope: urlDrilldownGlobalScopeProvider({ core }),
|
||||
navigateToUrl: (url: string) =>
|
||||
core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)),
|
||||
getOpenModal: () => core.getStartServices().then(([{ overlays }]) => overlays.openModal),
|
||||
getSyntaxHelpDocsLink: () => startServices().core.docLinks.links.dashboard.drilldowns, // TODO: replace with docs https://github.com/elastic/kibana/issues/69414
|
||||
})
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,12 @@ import {
|
|||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { txtChangeButton, txtTriggerPickerHelpText, txtTriggerPickerLabel } from './i18n';
|
||||
import {
|
||||
txtChangeButton,
|
||||
txtTriggerPickerHelpText,
|
||||
txtTriggerPickerLabel,
|
||||
txtTriggerPickerHelpTooltip,
|
||||
} from './i18n';
|
||||
import './action_wizard.scss';
|
||||
import { ActionFactory, BaseActionFactoryContext } from '../../dynamic_actions';
|
||||
import { Trigger, TriggerId } from '../../../../../../src/plugins/ui_actions/public';
|
||||
|
@ -157,14 +162,17 @@ const TriggerPicker: React.FC<TriggerPickerProps> = ({
|
|||
const selectedTrigger = selectedTriggers ? selectedTriggers[0] : undefined;
|
||||
return (
|
||||
<EuiFormFieldset
|
||||
data-test-subj={`triggerPicker`}
|
||||
legend={{
|
||||
children: (
|
||||
<EuiText size="s">
|
||||
<h5>
|
||||
<span>{txtTriggerPickerLabel}</span>{' '}
|
||||
<EuiLink href={triggerPickerDocsLink} target={'blank'} external>
|
||||
{txtTriggerPickerHelpText}
|
||||
</EuiLink>
|
||||
<EuiToolTip content={txtTriggerPickerHelpTooltip}>
|
||||
<EuiLink href={triggerPickerDocsLink} target={'blank'} external>
|
||||
{txtTriggerPickerHelpText}
|
||||
</EuiLink>
|
||||
</EuiToolTip>
|
||||
</h5>
|
||||
</EuiText>
|
||||
),
|
||||
|
@ -271,7 +279,7 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
<EuiSpacer size="l" />
|
||||
<EuiSpacer size="m" />
|
||||
<div>
|
||||
<actionFactory.ReactCollectConfig
|
||||
config={config}
|
||||
|
|
|
@ -16,13 +16,20 @@ export const txtChangeButton = i18n.translate(
|
|||
export const txtTriggerPickerLabel = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerLabel',
|
||||
{
|
||||
defaultMessage: 'Pick a trigger:',
|
||||
defaultMessage: 'Show option on:',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtTriggerPickerHelpText = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.components.actionWizard.helpText',
|
||||
'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerHelpText',
|
||||
{
|
||||
defaultMessage: "What's this?",
|
||||
}
|
||||
);
|
||||
|
||||
export const txtTriggerPickerHelpTooltip = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerHelpTooltip',
|
||||
{
|
||||
defaultMessage: 'Determines when the drilldown appears in context menu',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -30,7 +30,7 @@ import {
|
|||
SerializedAction,
|
||||
SerializedEvent,
|
||||
} from '../../../dynamic_actions';
|
||||
import { ExtraActionFactoryContext } from '../types';
|
||||
import { ActionFactoryPlaceContext } from '../types';
|
||||
|
||||
interface ConnectedFlyoutManageDrilldownsProps<
|
||||
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
|
||||
|
@ -47,7 +47,7 @@ interface ConnectedFlyoutManageDrilldownsProps<
|
|||
/**
|
||||
* Extra action factory context passed into action factories CollectConfig, getIconType, getDisplayName and etc...
|
||||
*/
|
||||
extraContext?: ExtraActionFactoryContext<ActionFactoryContext>;
|
||||
placeContext?: ActionFactoryPlaceContext<ActionFactoryContext>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,8 +81,8 @@ export function createFlyoutManageDrilldowns({
|
|||
const isCreateOnly = props.viewMode === 'create';
|
||||
|
||||
const factoryContext: BaseActionFactoryContext = useMemo(
|
||||
() => ({ ...props.extraContext, triggers: props.supportedTriggers }),
|
||||
[props.extraContext, props.supportedTriggers]
|
||||
() => ({ ...props.placeContext, triggers: props.supportedTriggers }),
|
||||
[props.placeContext, props.supportedTriggers]
|
||||
);
|
||||
const actionFactories = useCompatibleActionFactoriesForCurrentContext(
|
||||
allActionFactories,
|
||||
|
@ -137,7 +137,7 @@ export function createFlyoutManageDrilldowns({
|
|||
function mapToDrilldownToDrilldownListItem(drilldown: SerializedEvent): DrilldownListItem {
|
||||
const actionFactory = allActionFactoriesById[drilldown.action.factoryId];
|
||||
const drilldownFactoryContext: BaseActionFactoryContext = {
|
||||
...props.extraContext,
|
||||
...props.placeContext,
|
||||
triggers: drilldown.triggers as TriggerId[],
|
||||
};
|
||||
return {
|
||||
|
@ -204,7 +204,7 @@ export function createFlyoutManageDrilldowns({
|
|||
setRoute(Routes.Manage);
|
||||
setCurrentEditId(null);
|
||||
}}
|
||||
extraActionFactoryContext={props.extraContext}
|
||||
actionFactoryPlaceContext={props.placeContext}
|
||||
initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()}
|
||||
supportedTriggers={props.supportedTriggers}
|
||||
getTrigger={getTrigger}
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { DrilldownHelloBar } from '../drilldown_hello_bar';
|
||||
import { ActionFactory, BaseActionFactoryContext } from '../../../dynamic_actions';
|
||||
import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public';
|
||||
import { ExtraActionFactoryContext } from '../types';
|
||||
import { ActionFactoryPlaceContext } from '../types';
|
||||
|
||||
export interface DrilldownWizardConfig<ActionConfig extends object = object> {
|
||||
name: string;
|
||||
|
@ -44,7 +44,7 @@ export interface FlyoutDrilldownWizardProps<
|
|||
showWelcomeMessage?: boolean;
|
||||
onWelcomeHideClick?: () => void;
|
||||
|
||||
extraActionFactoryContext?: ExtraActionFactoryContext<ActionFactoryContext>;
|
||||
actionFactoryPlaceContext?: ActionFactoryPlaceContext<ActionFactoryContext>;
|
||||
|
||||
docsLink?: string;
|
||||
|
||||
|
@ -143,7 +143,7 @@ export function FlyoutDrilldownWizard<CurrentActionConfig extends object = objec
|
|||
showWelcomeMessage = true,
|
||||
onWelcomeHideClick,
|
||||
drilldownActionFactories,
|
||||
extraActionFactoryContext,
|
||||
actionFactoryPlaceContext,
|
||||
docsLink,
|
||||
getTrigger,
|
||||
supportedTriggers,
|
||||
|
@ -152,16 +152,16 @@ export function FlyoutDrilldownWizard<CurrentActionConfig extends object = objec
|
|||
wizardConfig,
|
||||
{ setActionFactory, setActionConfig, setName, setSelectedTriggers },
|
||||
] = useWizardConfigState(
|
||||
{ ...extraActionFactoryContext, triggers: supportedTriggers },
|
||||
{ ...actionFactoryPlaceContext, triggers: supportedTriggers },
|
||||
initialDrilldownWizardConfig
|
||||
);
|
||||
|
||||
const actionFactoryContext: BaseActionFactoryContext = useMemo(
|
||||
() => ({
|
||||
...extraActionFactoryContext,
|
||||
...actionFactoryPlaceContext,
|
||||
triggers: wizardConfig.selectedTriggers ?? [],
|
||||
}),
|
||||
[extraActionFactoryContext, wizardConfig.selectedTriggers]
|
||||
[actionFactoryPlaceContext, wizardConfig.selectedTriggers]
|
||||
);
|
||||
|
||||
const isActionValid = (
|
||||
|
|
|
@ -10,6 +10,6 @@ import { BaseActionFactoryContext } from '../../dynamic_actions';
|
|||
* Interface used as piece of ActionFactoryContext that is passed in from drilldown wizard component to action factories
|
||||
* Omitted values are added inside the wizard and then full {@link BaseActionFactoryContext} passed into action factory methods
|
||||
*/
|
||||
export type ExtraActionFactoryContext<
|
||||
export type ActionFactoryPlaceContext<
|
||||
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
|
||||
> = Omit<ActionFactoryContext, 'triggers'>;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
This directory contains reusable building blocks for creating custom URL drilldowns
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config/url_drilldown_collect_config';
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const txtUrlTemplatePlaceholder = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText',
|
||||
{
|
||||
defaultMessage: 'Example: {exampleUrl}',
|
||||
values: {
|
||||
exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlPreviewHelpText = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText',
|
||||
{
|
||||
defaultMessage: 'Please note that \\{\\{event.*\\}\\} variables replaced by dummy values.',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtAddVariableButtonTitle = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle',
|
||||
{
|
||||
defaultMessage: 'Add variable',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateLabel = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel',
|
||||
{
|
||||
defaultMessage: 'Enter URL template:',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText',
|
||||
{
|
||||
defaultMessage: 'Syntax help',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateVariablesHelpLinkText = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText',
|
||||
{
|
||||
defaultMessage: 'Help',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateVariablesFilterPlaceholderText = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText',
|
||||
{
|
||||
defaultMessage: 'Filter variables',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplatePreviewLabel = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel',
|
||||
{
|
||||
defaultMessage: 'URL preview:',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplatePreviewLinkText = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText',
|
||||
{
|
||||
defaultMessage: 'Preview',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateOpenInNewTab = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel',
|
||||
{
|
||||
defaultMessage: 'Open in new tab',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,5 @@
|
|||
.uaeUrlDrilldownCollectConfig__urlTemplateFormRow {
|
||||
.euiFormRow__label {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { UrlDrilldownConfig, UrlDrilldownScope } from '../../../types';
|
||||
import { UrlDrilldownCollectConfig } from '../url_drilldown_collect_config';
|
||||
|
||||
export const Demo = () => {
|
||||
const [config, onConfig] = React.useState<UrlDrilldownConfig>({
|
||||
openInNewTab: false,
|
||||
url: { template: '' },
|
||||
});
|
||||
|
||||
const fakeScope: UrlDrilldownScope = {
|
||||
kibanaUrl: 'http://localhost:5601/',
|
||||
context: {
|
||||
filters: [
|
||||
{
|
||||
query: { match: { extension: { query: 'jpg', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
|
||||
},
|
||||
{
|
||||
query: { match: { '@tags': { query: 'info', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
|
||||
},
|
||||
{
|
||||
query: { match: { _type: { query: 'nginx', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
event: {
|
||||
key: 'fakeKey',
|
||||
value: 'fakeValue',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UrlDrilldownCollectConfig config={config} onConfig={onConfig} scope={fakeScope} />
|
||||
{JSON.stringify(config)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { Demo } from './test_samples/demo';
|
||||
|
||||
storiesOf('UrlDrilldownCollectConfig', module).add('default', () => <Demo />);
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Demo } from './test_samples/demo';
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
||||
import React from 'react';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test('configure valid URL template', () => {
|
||||
const screen = render(<Demo />);
|
||||
|
||||
const urlTemplate = 'https://elastic.co/?{{event.key}}={{event.value}}';
|
||||
fireEvent.change(screen.getByLabelText(/Enter URL template/i), {
|
||||
target: { value: urlTemplate },
|
||||
});
|
||||
|
||||
const preview = screen.getByLabelText(/URL preview/i) as HTMLTextAreaElement;
|
||||
expect(preview.value).toMatchInlineSnapshot(`"https://elastic.co/?fakeKey=fakeValue"`);
|
||||
expect(preview.disabled).toEqual(true);
|
||||
const previewLink = screen.getByText('Preview') as HTMLAnchorElement;
|
||||
expect(previewLink.href).toMatchInlineSnapshot(`"https://elastic.co/?fakeKey=fakeValue"`);
|
||||
expect(previewLink.target).toMatchInlineSnapshot(`"_blank"`);
|
||||
});
|
||||
|
||||
test('configure invalid URL template', () => {
|
||||
const screen = render(<Demo />);
|
||||
|
||||
const urlTemplate = 'https://elastic.co/?{{event.wrongKey}}={{event.wrongValue}}';
|
||||
fireEvent.change(screen.getByLabelText(/Enter URL template/i), {
|
||||
target: { value: urlTemplate },
|
||||
});
|
||||
|
||||
const previewTextArea = screen.getByLabelText(/URL preview/i) as HTMLTextAreaElement;
|
||||
expect(previewTextArea.disabled).toEqual(true);
|
||||
expect(previewTextArea.value).toEqual(urlTemplate);
|
||||
expect(screen.getByText(/invalid format/i)).toBeInTheDocument(); // check that error is shown
|
||||
|
||||
const previewLink = screen.getByText('Preview') as HTMLAnchorElement;
|
||||
expect(previewLink.href).toEqual(urlTemplate);
|
||||
expect(previewLink.target).toMatchInlineSnapshot(`"_blank"`);
|
||||
});
|
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
EuiCheckbox,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiPopover,
|
||||
EuiPopoverFooter,
|
||||
EuiPopoverTitle,
|
||||
EuiSelectable,
|
||||
EuiText,
|
||||
EuiTextArea,
|
||||
EuiSelectableOption,
|
||||
} from '@elastic/eui';
|
||||
import { UrlDrilldownConfig, UrlDrilldownScope } from '../../types';
|
||||
import { compile } from '../../url_template';
|
||||
import { validateUrlTemplate } from '../../url_validation';
|
||||
import { buildScopeSuggestions } from '../../url_drilldown_scope';
|
||||
import './index.scss';
|
||||
import {
|
||||
txtAddVariableButtonTitle,
|
||||
txtUrlPreviewHelpText,
|
||||
txtUrlTemplateSyntaxHelpLinkText,
|
||||
txtUrlTemplateVariablesHelpLinkText,
|
||||
txtUrlTemplateVariablesFilterPlaceholderText,
|
||||
txtUrlTemplateLabel,
|
||||
txtUrlTemplateOpenInNewTab,
|
||||
txtUrlTemplatePlaceholder,
|
||||
txtUrlTemplatePreviewLabel,
|
||||
txtUrlTemplatePreviewLinkText,
|
||||
} from './i18n';
|
||||
|
||||
export interface UrlDrilldownCollectConfig {
|
||||
config: UrlDrilldownConfig;
|
||||
onConfig: (newConfig: UrlDrilldownConfig) => void;
|
||||
scope: UrlDrilldownScope;
|
||||
syntaxHelpDocsLink?: string;
|
||||
}
|
||||
|
||||
export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfig> = ({
|
||||
config,
|
||||
onConfig,
|
||||
scope,
|
||||
syntaxHelpDocsLink,
|
||||
}) => {
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const urlTemplate = config.url.template ?? '';
|
||||
const compiledUrl = React.useMemo(() => {
|
||||
try {
|
||||
return compile(urlTemplate, scope);
|
||||
} catch {
|
||||
return urlTemplate;
|
||||
}
|
||||
}, [urlTemplate, scope]);
|
||||
const scopeVariables = React.useMemo(() => buildScopeSuggestions(scope), [scope]);
|
||||
|
||||
function updateUrlTemplate(newUrlTemplate: string) {
|
||||
if (config.url.template !== newUrlTemplate) {
|
||||
onConfig({
|
||||
...config,
|
||||
url: {
|
||||
...config.url,
|
||||
template: newUrlTemplate,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const { error, isValid } = React.useMemo(
|
||||
() => validateUrlTemplate({ template: urlTemplate }, scope),
|
||||
[urlTemplate, scope]
|
||||
);
|
||||
const isEmpty = !urlTemplate;
|
||||
const isInvalid = !isValid && !isEmpty;
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
isInvalid={isInvalid}
|
||||
error={error}
|
||||
className={'uaeUrlDrilldownCollectConfig__urlTemplateFormRow'}
|
||||
label={txtUrlTemplateLabel}
|
||||
helpText={
|
||||
syntaxHelpDocsLink && (
|
||||
<EuiLink external target={'_blank'} href={syntaxHelpDocsLink}>
|
||||
{txtUrlTemplateSyntaxHelpLinkText}
|
||||
</EuiLink>
|
||||
)
|
||||
}
|
||||
labelAppend={
|
||||
<AddVariableButton
|
||||
variables={scopeVariables}
|
||||
variablesHelpLink={syntaxHelpDocsLink}
|
||||
onSelect={(variable: string) => {
|
||||
if (textAreaRef.current) {
|
||||
updateUrlTemplate(
|
||||
urlTemplate.substr(0, textAreaRef.current!.selectionStart) +
|
||||
`{{${variable}}}` +
|
||||
urlTemplate.substr(textAreaRef.current!.selectionEnd)
|
||||
);
|
||||
} else {
|
||||
updateUrlTemplate(urlTemplate + `{{${variable}}}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiTextArea
|
||||
fullWidth
|
||||
isInvalid={isInvalid}
|
||||
name="url"
|
||||
data-test-subj="urlInput"
|
||||
value={urlTemplate}
|
||||
placeholder={txtUrlTemplatePlaceholder}
|
||||
onChange={(event) => updateUrlTemplate(event.target.value)}
|
||||
rows={3}
|
||||
inputRef={textAreaRef}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={txtUrlTemplatePreviewLabel}
|
||||
labelAppend={
|
||||
<EuiText size="xs">
|
||||
<EuiLink href={compiledUrl} target="_blank" external>
|
||||
{txtUrlTemplatePreviewLinkText}
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
}
|
||||
helpText={txtUrlPreviewHelpText}
|
||||
>
|
||||
<EuiTextArea
|
||||
fullWidth
|
||||
name="urlPreview"
|
||||
data-test-subj="urlPreview"
|
||||
value={compiledUrl}
|
||||
disabled={true}
|
||||
rows={3}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiCheckbox
|
||||
id="openInNewTab"
|
||||
name="openInNewTab"
|
||||
label={txtUrlTemplateOpenInNewTab}
|
||||
checked={config.openInNewTab}
|
||||
onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function AddVariableButton({
|
||||
variables,
|
||||
onSelect,
|
||||
variablesHelpLink,
|
||||
}: {
|
||||
variables: string[];
|
||||
onSelect: (variable: string) => void;
|
||||
variablesHelpLink?: string;
|
||||
}) {
|
||||
const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false);
|
||||
const closePopover = () => setIsVariablesPopoverOpen(false);
|
||||
|
||||
const options: EuiSelectableOption[] = variables.map((variable: string) => ({
|
||||
key: variable,
|
||||
label: variable,
|
||||
}));
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
ownFocus={true}
|
||||
button={
|
||||
<EuiText size="xs">
|
||||
<EuiLink onClick={() => setIsVariablesPopoverOpen(true)}>
|
||||
{txtAddVariableButtonTitle} <EuiIcon type="indexOpen" />
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
}
|
||||
isOpen={isVariablesPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
withTitle
|
||||
>
|
||||
<EuiSelectable
|
||||
singleSelection={true}
|
||||
searchable
|
||||
searchProps={{
|
||||
placeholder: txtUrlTemplateVariablesFilterPlaceholderText,
|
||||
compressed: true,
|
||||
}}
|
||||
options={options}
|
||||
onChange={(newOptions) => {
|
||||
const selected = newOptions.find((o) => o.checked === 'on');
|
||||
if (!selected) return;
|
||||
onSelect(selected.key!);
|
||||
closePopover();
|
||||
}}
|
||||
listProps={{
|
||||
showIcons: false,
|
||||
}}
|
||||
>
|
||||
{(list, search) => (
|
||||
<div style={{ width: 320 }}>
|
||||
<EuiPopoverTitle>{search}</EuiPopoverTitle>
|
||||
{list}
|
||||
{variablesHelpLink && (
|
||||
<EuiPopoverFooter className={'eui-textRight'}>
|
||||
<EuiLink external href={variablesHelpLink} target="_blank">
|
||||
{txtUrlTemplateVariablesHelpLinkText}
|
||||
</EuiLink>
|
||||
</EuiPopoverFooter>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</EuiSelectable>
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { UrlDrilldownConfig, UrlDrilldownGlobalScope, UrlDrilldownScope } from './types';
|
||||
export { UrlDrilldownCollectConfig } from './components';
|
||||
export {
|
||||
validateUrlTemplate as urlDrilldownValidateUrlTemplate,
|
||||
validateUrl as urlDrilldownValidateUrl,
|
||||
} from './url_validation';
|
||||
export { compile as urlDrilldownCompileUrl } from './url_template';
|
||||
export { globalScopeProvider as urlDrilldownGlobalScopeProvider } from './url_drilldown_global_scope';
|
||||
export {
|
||||
buildScope as urlDrilldownBuildScope,
|
||||
buildScopeSuggestions as urlDrilldownBuildScopeSuggestions,
|
||||
} from './url_drilldown_scope';
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export interface UrlDrilldownConfig {
|
||||
url: { format?: 'handlebars_v1'; template: string };
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL drilldown has 3 sources for variables: global, context and event variables
|
||||
*/
|
||||
export interface UrlDrilldownScope<
|
||||
ContextScope extends object = object,
|
||||
EventScope extends object = object
|
||||
> extends UrlDrilldownGlobalScope {
|
||||
/**
|
||||
* Dynamic variables that are differ depending on where drilldown is created and used,
|
||||
* For example: variables extracted from embeddable panel
|
||||
*/
|
||||
context?: ContextScope;
|
||||
|
||||
/**
|
||||
* Variables extracted from trigger context
|
||||
*/
|
||||
event?: EventScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global static variables like, for example, `kibanaUrl`
|
||||
* Such variables won’t change depending on a place where url drilldown is used.
|
||||
*/
|
||||
export interface UrlDrilldownGlobalScope {
|
||||
kibanaUrl: string;
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { CoreSetup } from 'kibana/public';
|
||||
import { UrlDrilldownGlobalScope } from './types';
|
||||
|
||||
interface UrlDrilldownGlobalScopeDeps {
|
||||
core: CoreSetup;
|
||||
}
|
||||
|
||||
export function globalScopeProvider({
|
||||
core,
|
||||
}: UrlDrilldownGlobalScopeDeps): () => UrlDrilldownGlobalScope {
|
||||
return () => ({
|
||||
kibanaUrl: window.location.origin + core.http.basePath.get(),
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { buildScope, buildScopeSuggestions } from './url_drilldown_scope';
|
||||
|
||||
test('buildScopeSuggestions', () => {
|
||||
expect(
|
||||
buildScopeSuggestions(
|
||||
buildScope({
|
||||
globalScope: {
|
||||
kibanaUrl: 'http://localhost:5061/',
|
||||
},
|
||||
eventScope: {
|
||||
key: '__testKey__',
|
||||
value: '__testValue__',
|
||||
},
|
||||
contextScope: {
|
||||
filters: [
|
||||
{
|
||||
query: { match: { extension: { query: 'jpg', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
|
||||
},
|
||||
{
|
||||
query: { match: { '@tags': { query: 'info', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
|
||||
},
|
||||
{
|
||||
query: { match: { _type: { query: 'nginx', type: 'phrase' } } },
|
||||
meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
|
||||
},
|
||||
],
|
||||
query: {
|
||||
query: '',
|
||||
language: 'kquery',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"event.key",
|
||||
"event.value",
|
||||
"context.filters",
|
||||
"context.query.language",
|
||||
"context.query.query",
|
||||
"kibanaUrl",
|
||||
]
|
||||
`);
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import partition from 'lodash/partition';
|
||||
import { UrlDrilldownGlobalScope, UrlDrilldownScope } from './types';
|
||||
import { getFlattenedObject } from '../../../../../../src/core/public';
|
||||
|
||||
export function buildScope<
|
||||
ContextScope extends object = object,
|
||||
EventScope extends object = object
|
||||
>({
|
||||
globalScope,
|
||||
contextScope,
|
||||
eventScope,
|
||||
}: {
|
||||
globalScope: UrlDrilldownGlobalScope;
|
||||
contextScope?: ContextScope;
|
||||
eventScope?: EventScope;
|
||||
}): UrlDrilldownScope<ContextScope, EventScope> {
|
||||
return {
|
||||
...globalScope,
|
||||
context: contextScope,
|
||||
event: eventScope,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds list of variables for suggestion from scope
|
||||
* keys sorted alphabetically, except {{event.$}} variables are pulled to the top
|
||||
* @param scope
|
||||
*/
|
||||
export function buildScopeSuggestions(scope: UrlDrilldownGlobalScope): string[] {
|
||||
const allKeys = Object.keys(getFlattenedObject(scope)).sort();
|
||||
const [eventKeys, otherKeys] = partition(allKeys, (key) => key.startsWith('event'));
|
||||
return [...eventKeys, ...otherKeys];
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { compile } from './url_template';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
test('should compile url without variables', () => {
|
||||
const url = 'https://elastic.co';
|
||||
expect(compile(url, {})).toBe(url);
|
||||
});
|
||||
|
||||
test('should fail on unknown syntax', () => {
|
||||
const url = 'https://elastic.co/{{}';
|
||||
expect(() => compile(url, {})).toThrowError();
|
||||
});
|
||||
|
||||
test('should fail on not existing variable', () => {
|
||||
const url = 'https://elastic.co/{{fake}}';
|
||||
expect(() => compile(url, {})).toThrowError();
|
||||
});
|
||||
|
||||
test('should fail on not existing nested variable', () => {
|
||||
const url = 'https://elastic.co/{{fake.fake}}';
|
||||
expect(() => compile(url, { fake: {} })).toThrowError();
|
||||
});
|
||||
|
||||
test('should replace existing variable', () => {
|
||||
const url = 'https://elastic.co/{{foo}}';
|
||||
expect(compile(url, { foo: 'bar' })).toMatchInlineSnapshot(`"https://elastic.co/bar"`);
|
||||
});
|
||||
|
||||
test('should fail on unknown helper', () => {
|
||||
const url = 'https://elastic.co/{{fake foo}}';
|
||||
expect(() => compile(url, { foo: 'bar' })).toThrowError();
|
||||
});
|
||||
|
||||
describe('json helper', () => {
|
||||
test('should replace with json', () => {
|
||||
const url = 'https://elastic.co/{{json foo bar}}';
|
||||
expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"`
|
||||
);
|
||||
});
|
||||
test('should replace with json and skip encoding', () => {
|
||||
const url = 'https://elastic.co/{{{json foo bar}}}';
|
||||
expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"`
|
||||
);
|
||||
});
|
||||
test('should throw on unknown key', () => {
|
||||
const url = 'https://elastic.co/{{{json fake}}}';
|
||||
expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rison helper', () => {
|
||||
test('should replace with rison', () => {
|
||||
const url = 'https://elastic.co/{{rison foo bar}}';
|
||||
expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/!((foo:bar),(bar:foo))"`
|
||||
);
|
||||
});
|
||||
test('should replace with rison and skip encoding', () => {
|
||||
const url = 'https://elastic.co/{{{rison foo bar}}}';
|
||||
expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/!((foo:bar),(bar:foo))"`
|
||||
);
|
||||
});
|
||||
test('should throw on unknown key', () => {
|
||||
const url = 'https://elastic.co/{{{rison fake}}}';
|
||||
expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('date helper', () => {
|
||||
let spy: jest.SpyInstance;
|
||||
const date = new Date('2020-08-18T14:45:00.000Z');
|
||||
beforeAll(() => {
|
||||
spy = jest.spyOn(global.Date, 'now').mockImplementation(() => date.valueOf());
|
||||
moment.tz.setDefault('UTC');
|
||||
});
|
||||
afterAll(() => {
|
||||
spy.mockRestore();
|
||||
moment.tz.setDefault('Browser');
|
||||
});
|
||||
|
||||
test('uses datemath', () => {
|
||||
const url = 'https://elastic.co/{{date time}}';
|
||||
expect(compile(url, { time: 'now' })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/2020-08-18T14:45:00.000Z"`
|
||||
);
|
||||
});
|
||||
|
||||
test('can use format', () => {
|
||||
const url = 'https://elastic.co/{{date time "dddd, MMMM Do YYYY, h:mm:ss a"}}';
|
||||
expect(compile(url, { time: 'now' })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/Tuesday,%20August%2018th%202020,%202:45:00%20pm"`
|
||||
);
|
||||
});
|
||||
|
||||
test('throws if missing variable', () => {
|
||||
const url = 'https://elastic.co/{{date time}}';
|
||||
expect(() => compile(url, {})).toThrowError();
|
||||
});
|
||||
|
||||
test("doesn't throw if non valid date", () => {
|
||||
const url = 'https://elastic.co/{{date time}}';
|
||||
expect(compile(url, { time: 'fake' })).toMatchInlineSnapshot(`"https://elastic.co/fake"`);
|
||||
});
|
||||
|
||||
test("doesn't throw on boolean or number", () => {
|
||||
const url = 'https://elastic.co/{{date time}}';
|
||||
expect(compile(url, { time: false })).toMatchInlineSnapshot(`"https://elastic.co/false"`);
|
||||
expect(compile(url, { time: 24 })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/1970-01-01T00:00:00.024Z"`
|
||||
);
|
||||
});
|
||||
|
||||
test('works with ISO string', () => {
|
||||
const url = 'https://elastic.co/{{date time}}';
|
||||
expect(compile(url, { time: date.toISOString() })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/2020-08-18T14:45:00.000Z"`
|
||||
);
|
||||
});
|
||||
|
||||
test('works with ts', () => {
|
||||
const url = 'https://elastic.co/{{date time}}';
|
||||
expect(compile(url, { time: date.valueOf() })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/2020-08-18T14:45:00.000Z"`
|
||||
);
|
||||
});
|
||||
test('works with ts string', () => {
|
||||
const url = 'https://elastic.co/{{date time}}';
|
||||
expect(compile(url, { time: String(date.valueOf()) })).toMatchInlineSnapshot(
|
||||
`"https://elastic.co/2020-08-18T14:45:00.000Z"`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { create as createHandlebars, HelperDelegate, HelperOptions } from 'handlebars';
|
||||
import { encode, RisonValue } from 'rison-node';
|
||||
import dateMath from '@elastic/datemath';
|
||||
import moment, { Moment } from 'moment';
|
||||
|
||||
const handlebars = createHandlebars();
|
||||
|
||||
function createSerializationHelper(
|
||||
fnName: string,
|
||||
serializeFn: (value: unknown) => string
|
||||
): HelperDelegate {
|
||||
return (...args) => {
|
||||
const { hash } = args.slice(-1)[0] as HelperOptions;
|
||||
const hasHash = Object.keys(hash).length > 0;
|
||||
const hasValues = args.length > 1;
|
||||
if (hasHash && hasValues) {
|
||||
throw new Error(`[${fnName}]: both value list and hash are not supported`);
|
||||
}
|
||||
if (hasHash) {
|
||||
if (Object.values(hash).some((v) => typeof v === 'undefined'))
|
||||
throw new Error(`[${fnName}]: unknown variable`);
|
||||
return serializeFn(hash);
|
||||
} else {
|
||||
const values = args.slice(0, -1) as unknown[];
|
||||
if (values.some((value) => typeof value === 'undefined'))
|
||||
throw new Error(`[${fnName}]: unknown variable`);
|
||||
if (values.length === 0) throw new Error(`[${fnName}]: unknown variable`);
|
||||
if (values.length === 1) return serializeFn(values[0]);
|
||||
return serializeFn(values);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
handlebars.registerHelper('json', createSerializationHelper('json', JSON.stringify));
|
||||
handlebars.registerHelper(
|
||||
'rison',
|
||||
createSerializationHelper('rison', (v) => encode(v as RisonValue))
|
||||
);
|
||||
|
||||
handlebars.registerHelper('date', (...args) => {
|
||||
const values = args.slice(0, -1) as [string | Date, string | undefined];
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [date, format] = values;
|
||||
if (typeof date === 'undefined') throw new Error(`[date]: unknown variable`);
|
||||
let momentDate: Moment | undefined;
|
||||
if (typeof date === 'string') {
|
||||
momentDate = dateMath.parse(date);
|
||||
if (!momentDate || !momentDate.isValid()) {
|
||||
const ts = Number(date);
|
||||
if (!Number.isNaN(ts)) {
|
||||
momentDate = moment(ts);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
momentDate = moment(date);
|
||||
}
|
||||
|
||||
if (!momentDate || !momentDate.isValid()) {
|
||||
// do not throw error here, because it could be that in preview `__testValue__` is not parsable,
|
||||
// but in runtime it is
|
||||
return date;
|
||||
}
|
||||
return format ? momentDate.format(format) : momentDate.toISOString();
|
||||
});
|
||||
|
||||
export function compile(url: string, context: object): string {
|
||||
const template = handlebars.compile(url, { strict: true, noEscape: true });
|
||||
return encodeURI(template(context));
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { validateUrl, validateUrlTemplate } from './url_validation';
|
||||
|
||||
describe('validateUrl', () => {
|
||||
describe('unsafe urls', () => {
|
||||
const unsafeUrls = [
|
||||
// eslint-disable-next-line no-script-url
|
||||
'javascript:evil()',
|
||||
// eslint-disable-next-line no-script-url
|
||||
'JavaScript:abc',
|
||||
'evilNewProtocol:abc',
|
||||
' \n Java\n Script:abc',
|
||||
'javascript:',
|
||||
'javascript:',
|
||||
'j avascript:',
|
||||
'javascript:',
|
||||
'javascript:',
|
||||
'jav	ascript:alert();',
|
||||
// 'jav\u0000ascript:alert();', CI fails on this one
|
||||
'data:;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
||||
'data:,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
||||
'data:iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
||||
'data:text/javascript;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
||||
'data:application/x-msdownload;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
|
||||
];
|
||||
|
||||
for (const url of unsafeUrls) {
|
||||
test(`unsafe ${url}`, () => {
|
||||
expect(validateUrl(url).isValid).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('invalid urls', () => {
|
||||
const invalidUrls = ['elastic.co', 'www.elastic.co', 'test', '', ' ', 'https://'];
|
||||
for (const url of invalidUrls) {
|
||||
test(`invalid ${url}`, () => {
|
||||
expect(validateUrl(url).isValid).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('valid urls', () => {
|
||||
const validUrls = [
|
||||
'https://elastic.co',
|
||||
'https://www.elastic.co',
|
||||
'http://elastic',
|
||||
'mailto:someone',
|
||||
];
|
||||
for (const url of validUrls) {
|
||||
test(`valid ${url}`, () => {
|
||||
expect(validateUrl(url).isValid).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUrlTemplate', () => {
|
||||
test('domain in variable is allowed', () => {
|
||||
expect(
|
||||
validateUrlTemplate(
|
||||
{ template: '{{kibanaUrl}}/test' },
|
||||
{ kibanaUrl: 'http://localhost:5601/app' }
|
||||
).isValid
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('unsafe domain in variable is not allowed', () => {
|
||||
expect(
|
||||
// eslint-disable-next-line no-script-url
|
||||
validateUrlTemplate({ template: '{{kibanaUrl}}/test' }, { kibanaUrl: 'javascript:evil()' })
|
||||
.isValid
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('if missing variable then invalid', () => {
|
||||
expect(
|
||||
validateUrlTemplate({ template: '{{url}}/test' }, { kibanaUrl: 'http://localhost:5601/app' })
|
||||
.isValid
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { UrlDrilldownConfig, UrlDrilldownScope } from './types';
|
||||
import { compile } from './url_template';
|
||||
|
||||
const generalFormatError = i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage',
|
||||
{
|
||||
defaultMessage: 'Invalid format. Example: {exampleUrl}',
|
||||
values: {
|
||||
exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const formatError = (message: string) =>
|
||||
i18n.translate(
|
||||
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage',
|
||||
{
|
||||
defaultMessage: 'Invalid format: {message}',
|
||||
values: {
|
||||
message,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const SAFE_URL_PATTERN = /^(?:(?:https?|mailto):|[^&:/?#]*(?:[/?#]|$))/gi;
|
||||
export function validateUrl(url: string): { isValid: boolean; error?: string } {
|
||||
if (!url)
|
||||
return {
|
||||
isValid: false,
|
||||
error: generalFormatError,
|
||||
};
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
if (!url.match(SAFE_URL_PATTERN)) throw new Error();
|
||||
return { isValid: true };
|
||||
} catch (e) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: generalFormatError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUrlTemplate(
|
||||
urlTemplate: UrlDrilldownConfig['url'],
|
||||
scope: UrlDrilldownScope
|
||||
): { isValid: boolean; error?: string } {
|
||||
if (!urlTemplate.template)
|
||||
return {
|
||||
isValid: false,
|
||||
error: generalFormatError,
|
||||
};
|
||||
|
||||
try {
|
||||
const compiledUrl = compile(urlTemplate.template, scope);
|
||||
return validateUrl(compiledUrl);
|
||||
} catch (e) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: formatError(e.message),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -32,3 +32,4 @@ export {
|
|||
} from './dynamic_actions';
|
||||
|
||||
export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns';
|
||||
export * from './drilldowns/url_drilldown';
|
||||
|
|
|
@ -9,8 +9,5 @@ import { join } from 'path';
|
|||
// eslint-disable-next-line
|
||||
require('@kbn/storybook').runStorybookCli({
|
||||
name: 'ui_actions_enhanced',
|
||||
storyGlobs: [
|
||||
join(__dirname, '..', 'public', 'components', '**', '*.story.tsx'),
|
||||
join(__dirname, '..', 'public', 'drilldowns', 'components', '**', '*.story.tsx'),
|
||||
],
|
||||
storyGlobs: [join(__dirname, '..', 'public', '**', '*.story.tsx')],
|
||||
});
|
||||
|
|
|
@ -22,7 +22,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
const testSubjects = getService('testSubjects');
|
||||
const filterBar = getService('filterBar');
|
||||
|
||||
describe('Dashboard Drilldowns', function () {
|
||||
describe('Dashboard to dashboard drilldown', function () {
|
||||
before(async () => {
|
||||
log.debug('Dashboard Drilldowns:initTests');
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
const DRILLDOWN_TO_DISCOVER_URL = 'Go to discover';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const dashboardPanelActions = getService('dashboardPanelActions');
|
||||
const dashboardDrilldownPanelActions = getService('dashboardDrilldownPanelActions');
|
||||
const dashboardDrilldownsManage = getService('dashboardDrilldownsManage');
|
||||
const PageObjects = getPageObjects(['dashboard', 'common', 'header', 'timePicker', 'discover']);
|
||||
const log = getService('log');
|
||||
const browser = getService('browser');
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
describe('Dashboard to URL drilldown', function () {
|
||||
before(async () => {
|
||||
log.debug('Dashboard to URL:initTests');
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
await PageObjects.dashboard.preserveCrossAppState();
|
||||
});
|
||||
|
||||
it('should create dashboard to URL drilldown and use it to navigate to discover', async () => {
|
||||
await PageObjects.dashboard.gotoDashboardEditMode(
|
||||
dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME
|
||||
);
|
||||
|
||||
// create drilldown
|
||||
await dashboardPanelActions.openContextMenu();
|
||||
await dashboardDrilldownPanelActions.expectExistsCreateDrilldownAction();
|
||||
await dashboardDrilldownPanelActions.clickCreateDrilldown();
|
||||
await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen();
|
||||
|
||||
const urlTemplate = `{{kibanaUrl}}/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:'{{event.from}}',to:'{{event.to}}'))&_a=(columns:!(_source),filters:{{rison context.panel.filters}},index:'{{context.panel.indexPatternId}}',interval:auto,query:(language:{{context.panel.query.language}},query:'{{context.panel.query.query}}'),sort:!())`;
|
||||
|
||||
await dashboardDrilldownsManage.fillInDashboardToURLDrilldownWizard({
|
||||
drilldownName: DRILLDOWN_TO_DISCOVER_URL,
|
||||
destinationURLTemplate: urlTemplate,
|
||||
trigger: 'SELECT_RANGE_TRIGGER',
|
||||
});
|
||||
await dashboardDrilldownsManage.saveChanges();
|
||||
await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutClose();
|
||||
|
||||
// check that drilldown notification badge is shown
|
||||
expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(2);
|
||||
|
||||
// save dashboard, navigate to view mode
|
||||
await PageObjects.dashboard.saveDashboard(
|
||||
dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME,
|
||||
{
|
||||
saveAsNew: false,
|
||||
waitDialogIsClosed: true,
|
||||
}
|
||||
);
|
||||
|
||||
const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
|
||||
|
||||
await brushAreaChart();
|
||||
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
|
||||
await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_DISCOVER_URL);
|
||||
|
||||
await PageObjects.discover.waitForDiscoverAppOnScreen();
|
||||
|
||||
// check that new time range duration was applied
|
||||
const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
|
||||
expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours);
|
||||
});
|
||||
});
|
||||
|
||||
// utils which shouldn't be a part of test flow, but also too specific to be moved to pageobject or service
|
||||
async function brushAreaChart() {
|
||||
const areaChart = await testSubjects.find('visualizationLoader');
|
||||
expect(await areaChart.getAttribute('data-title')).to.be('Visualization漢字 AreaChart');
|
||||
await browser.dragAndDrop(
|
||||
{
|
||||
location: areaChart,
|
||||
offset: {
|
||||
x: -100,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
location: areaChart,
|
||||
offset: {
|
||||
x: 100,
|
||||
y: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -22,7 +22,8 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
|
|||
await esArchiver.unload('dashboard/drilldowns');
|
||||
});
|
||||
|
||||
loadTestFile(require.resolve('./dashboard_drilldowns'));
|
||||
loadTestFile(require.resolve('./dashboard_to_dashboard_drilldown'));
|
||||
loadTestFile(require.resolve('./dashboard_to_url_drilldown'));
|
||||
loadTestFile(require.resolve('./explore_data_panel_action'));
|
||||
|
||||
// Disabled for now as it requires xpack.discoverEnhanced.actions.exploreDataInChart.enabled
|
||||
|
|
|
@ -12,6 +12,8 @@ const DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM =
|
|||
'actionFactoryItem-DASHBOARD_TO_DASHBOARD_DRILLDOWN';
|
||||
const DASHBOARD_TO_DASHBOARD_ACTION_WIZARD =
|
||||
'selectedActionFactory-DASHBOARD_TO_DASHBOARD_DRILLDOWN';
|
||||
const DASHBOARD_TO_URL_ACTION_LIST_ITEM = 'actionFactoryItem-URL_DRILLDOWN';
|
||||
const DASHBOARD_TO_URL_ACTION_WIZARD = 'selectedActionFactory-URL_DRILLDOWN';
|
||||
const DESTINATION_DASHBOARD_SELECT = 'dashboardDrilldownSelectDashboard';
|
||||
const DRILLDOWN_WIZARD_SUBMIT = 'drilldownWizardSubmit';
|
||||
|
||||
|
@ -68,10 +70,32 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon
|
|||
await this.selectDestinationDashboard(destinationDashboardTitle);
|
||||
}
|
||||
|
||||
async fillInDashboardToURLDrilldownWizard({
|
||||
drilldownName,
|
||||
destinationURLTemplate,
|
||||
trigger,
|
||||
}: {
|
||||
drilldownName: string;
|
||||
destinationURLTemplate: string;
|
||||
trigger: 'VALUE_CLICK_TRIGGER' | 'SELECT_RANGE_TRIGGER';
|
||||
}) {
|
||||
await this.fillInDrilldownName(drilldownName);
|
||||
await this.selectDashboardToURLActionIfNeeded();
|
||||
await this.selectTriggerIfNeeded(trigger);
|
||||
await this.fillInURLTemplate(destinationURLTemplate);
|
||||
}
|
||||
|
||||
async fillInDrilldownName(name: string) {
|
||||
await testSubjects.setValue('drilldownNameInput', name);
|
||||
}
|
||||
|
||||
async selectDashboardToURLActionIfNeeded() {
|
||||
if (await testSubjects.exists(DASHBOARD_TO_URL_ACTION_LIST_ITEM)) {
|
||||
await testSubjects.click(DASHBOARD_TO_URL_ACTION_LIST_ITEM);
|
||||
}
|
||||
await testSubjects.existOrFail(DASHBOARD_TO_URL_ACTION_WIZARD);
|
||||
}
|
||||
|
||||
async selectDashboardToDashboardActionIfNeeded() {
|
||||
if (await testSubjects.exists(DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM)) {
|
||||
await testSubjects.click(DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM);
|
||||
|
@ -83,6 +107,18 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon
|
|||
await comboBox.set(DESTINATION_DASHBOARD_SELECT, title);
|
||||
}
|
||||
|
||||
async selectTriggerIfNeeded(trigger: 'VALUE_CLICK_TRIGGER' | 'SELECT_RANGE_TRIGGER') {
|
||||
if (await testSubjects.exists(`triggerPicker`)) {
|
||||
const container = await testSubjects.find(`triggerPicker-${trigger}`);
|
||||
const radio = await container.findByCssSelector('input[type=radio]');
|
||||
await radio.click();
|
||||
}
|
||||
}
|
||||
|
||||
async fillInURLTemplate(destinationURLTemplate: string) {
|
||||
await testSubjects.setValue('urlInput', destinationURLTemplate);
|
||||
}
|
||||
|
||||
async saveChanges() {
|
||||
await testSubjects.click(DRILLDOWN_WIZARD_SUBMIT);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue