[Drilldowns] URL drilldown MVP (#75450)

This commit is contained in:
Anton Dosov 2020-09-04 13:52:41 +02:00 committed by GitHub
parent 70b4b89270
commit e7d80e7e70
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1836 additions and 174 deletions

View file

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

View file

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

View file

@ -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;
}
};
}

View file

@ -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 }));
}

View file

@ -80,6 +80,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLY
viewMode={'create'}
dynamicActionManager={embeddable.enhancements.dynamicActions}
supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())}
placeContext={{ embeddable }}
/>
),
{

View file

@ -64,6 +64,7 @@ export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOU
viewMode={'manage'}
dynamicActionManager={embeddable.enhancements.dynamicActions}
supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())}
placeContext={{ embeddable }}
/>
),
{

View file

@ -3,5 +3,6 @@
"version": "kibana",
"server": false,
"ui": true,
"requiredPlugins": ["embeddable", "uiActionsEnhanced"]
"requiredPlugins": ["embeddable", "kibanaReact", "uiActions", "uiActionsEnhanced"],
"requiredBundles": ["kibanaUtils"]
}

View file

@ -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';

View file

@ -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 wont 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`.

View file

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

View file

@ -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';

View file

@ -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();
});
});
});

View file

@ -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),
});
};
}

View file

@ -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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = (

View file

@ -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'>;

View file

@ -0,0 +1 @@
This directory contains reusable building blocks for creating custom URL drilldowns

View file

@ -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';

View file

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

View file

@ -0,0 +1,5 @@
.uaeUrlDrilldownCollectConfig__urlTemplateFormRow {
.euiFormRow__label {
align-self: flex-end;
}
}

View file

@ -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)}
</>
);
};

View file

@ -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 />);

View file

@ -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"`);
});

View file

@ -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>
);
}

View file

@ -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';

View file

@ -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 wont change depending on a place where url drilldown is used.
*/
export interface UrlDrilldownGlobalScope {
kibanaUrl: string;
}

View file

@ -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(),
});
}

View file

@ -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",
]
`);
});

View file

@ -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];
}

View file

@ -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"`
);
});
});

View file

@ -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));
}

View file

@ -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',
'&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;',
'&#106&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;',
'&#106 &#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;',
'&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058',
'&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A;',
'jav&#x09;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);
});
});

View file

@ -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),
};
}
}

View file

@ -32,3 +32,4 @@ export {
} from './dynamic_actions';
export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns';
export * from './drilldowns/url_drilldown';

View file

@ -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')],
});

View file

@ -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');

View file

@ -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,
},
}
);
}
}

View file

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

View file

@ -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);
}