Url template editor (#88577)

* feat: 🎸 set up Storybook for URL template editor

* feat: 🎸 add basic syntax highlighting

* feat: 🎸 add autocompletion example

* feat: 🎸 add Handlebars language

* fix: 🐛 first register language

* feat: 🎸 add url and handlebars language parsing

* feat: 🎸 use simple Handlebars language

* refactor: 💡 move <VariablePopover> to a separate file

* feat: 🎸 add Monaco editor to URL drilldown

* feat: 🎸 remove editor line numbers

* feat: 🎸 allow user to provide Handlebars variables

* feat: 🎸 wire in URL drilldown variables into Monaco editor

* feat: 🎸 add metadata to event level variables

* feat: 🎸 allow to specify Handlebars variable kind

* feat: 🎸 add global variables to autocompletion

* refactor: 💡 restructure event and context variable code

* feat: 🎸 sort variables by scope group

* feat: 🎸 add meta information to context variables

* docs: ✏️ use correct variable labels

* feat: 🎸 fix component demo props

* feat: 🎸 improve highlighting of URL parts

* feat: 🎸 improve syntax highlighting colors

* feat: 🎸 improve highlighting colors

* feat: 🎸 add color to url query parameter key

* feat: 🎸 improve visual layout url editor

* feat: 🎸 highlight URL slashes with light color

* feat: 🎸 connect URL editor to state

* feat: 🎸 tweak URL parameter colors

* feat: 🎸 improve URL schema color

* feat: 🎸 insert variables on click in variable dropdown

* fix: 🐛 fix unit tests and translation

* test: 💍 fix drilldown tests after refactor

* feat: 🎸 add dark mode support to URL template editor

* test: 💍 fix URL drilldown test after adding dark mode support

* fix: 🐛 use text color which can be converted to dark mode

* test: 💍 fill in URL template in monaco editor

* fix: 🐛 fix translation key

* chore: 🤖 update license headers

* chore: 🤖 update license headers

* feat: 🎸 preview values of global variables

* feat: 🎸 preview values of context variables

* chore: 🤖 fix url editor Storybook config

* fix: 🐛 make translation key unique

* feat: 🎸 stop Esc key propagation in URL editor

* feat: 🎸 reduce editor height

* feat: 🎸 set example URL once URL drilldown is created

* feat: 🎸 add word wrapping to URL editor

* feat: 🎸 use EUI variable in SCSS

* feat: 🎸 add "Example: " prefix to default template

* feat: 🎸 do not insert extra brackets

* feat: 🎸 make URL param values same color as text

* perf: ️ make URL drilldown config component lazy loaded

* test: 💍 remove default URL drilldown template

* fix: 🐛 disable autocompletion popup while typing

* style: 💄 don't use "Example: " prefix in default URL
This commit is contained in:
Vadim Dalecky 2021-02-15 18:24:58 +01:00 committed by GitHub
parent 4d01440012
commit 8ed1c3ca3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1646 additions and 720 deletions

View file

@ -190,7 +190,7 @@ internal {kib} navigations with carrying over current filters.
| Current query string.
|
| context.panel.query.lang
| context.panel.query.language
| Current query language.
|
@ -200,8 +200,8 @@ context.panel.timeRange.to
Tip: Use in combination with <<helpers, date>> helper to format date.
|
| context.panel.timeRange.indexPatternId +
context.panel.timeRange.indexPatternIds
| context.panel.indexPatternId +
context.panel.indexPatternIds
|Index pattern ids used by a panel.
|

View file

@ -10,6 +10,7 @@ export const storybookAliases = {
apm: 'x-pack/plugins/apm/.storybook',
canvas: 'x-pack/plugins/canvas/storybook',
codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook',
url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook',
dashboard: 'src/plugins/dashboard/.storybook',
dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook',
data_enhanced: 'x-pack/plugins/data_enhanced/.storybook',

View file

@ -41,7 +41,7 @@ export function createTheme(
{ token: 'annotation', foreground: euiTheme.euiColorMediumShade },
{ token: 'type', foreground: euiTheme.euiColorVis0 },
{ token: 'delimiter', foreground: euiTheme.euiColorDarkestShade },
{ token: 'delimiter', foreground: euiTheme.euiTextSubduedColor },
{ token: 'delimiter.html', foreground: euiTheme.euiColorDarkShade },
{ token: 'delimiter.xml', foreground: euiTheme.euiColorPrimary },
@ -81,6 +81,9 @@ export function createTheme(
{ token: 'operator.sql', foreground: euiTheme.euiColorMediumShade },
{ token: 'operator.swift', foreground: euiTheme.euiColorMediumShade },
{ token: 'predefined.sql', foreground: euiTheme.euiColorMediumShade },
{ token: 'text', foreground: euiTheme.euiTitleColor },
{ token: 'label', foreground: euiTheme.euiColorVis9 },
],
colors: {
'editor.foreground': euiTheme.euiColorDarkestShade,

View file

@ -7,6 +7,7 @@
*/
export * from './code_editor';
export * from './url_template_editor';
export * from './exit_full_screen_button';
export * from './context';
export * from './overview_page';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
// eslint-disable-next-line import/no-commonjs
module.exports = require('@kbn/storybook').defaultConfig;

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const LANG = 'handlebars_url';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './url_template_editor';

View file

@ -0,0 +1,198 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/**
* This file is adapted from: https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts
* License: https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
*/
import { monaco } from '@kbn/monaco';
export const conf: monaco.languages.LanguageConfiguration = {
wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,
comments: {
blockComment: ['{{!--', '--}}'],
},
brackets: [
['<', '>'],
['{{', '}}'],
['{', '}'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: '<', close: '>' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
};
export const language: monaco.languages.IMonarchLanguage = {
// Set defaultToken to invalid to see what you do not tokenize yet.
defaultToken: 'invalid',
tokenPostfix: '',
brackets: [
{
token: 'constant.delimiter.double',
open: '{{',
close: '}}',
},
{
token: 'constant.delimiter.triple',
open: '{{{',
close: '}}}',
},
],
tokenizer: {
root: [
{ include: '@maybeHandlebars' },
{ include: '@whitespace' },
{ include: '@urlScheme' },
{ include: '@urlAuthority' },
{ include: '@urlSlash' },
{ include: '@urlParamKey' },
{ include: '@urlParamValue' },
{ include: '@text' },
],
maybeHandlebars: [
[
/\{\{/,
{
token: '@rematch',
switchTo: '@handlebars.root',
},
],
],
whitespace: [[/[ \t\r\n]+/, '']],
text: [
[
/[^<{\?\&\/]+/,
{
token: 'text',
next: '@popall',
},
],
],
rematchAsRoot: [
[
/.+/,
{
token: '@rematch',
switchTo: '@root',
},
],
],
urlScheme: [
[
/([a-zA-Z0-9\+\.\-]{1,10})(:)/,
[
{
token: 'text.keyword.scheme.url',
},
{
token: 'delimiter',
},
],
],
],
urlAuthority: [
[
/(\/\/)([a-zA-Z0-9\.\-_]+)/,
[
{
token: 'delimiter',
},
{
token: 'metatag.keyword.authority.url',
},
],
],
],
urlSlash: [
[
/\/+/,
{
token: 'delimiter',
},
],
],
urlParamKey: [
[
/([\?\&\#])([a-zA-Z0-9_\-]+)/,
[
{
token: 'delimiter.key.query.url',
},
{
token: 'label.label.key.query.url',
},
],
],
],
urlParamValue: [
[
/(\=)([^\?\&\{}]+)/,
[
{
token: 'text.separator.value.query.url',
},
{
token: 'text.value.query.url',
},
],
],
],
handlebars: [
[
/\{\{\{?/,
{
token: '@brackets',
bracket: '@open',
},
],
[
/\}\}\}?/,
{
token: '@brackets',
bracket: '@close',
switchTo: '@$S2.$S3',
},
],
{ include: 'handlebarsExpression' },
],
handlebarsExpression: [
[/"[^"]*"/, 'string.handlebars'],
[/[#/][^\s}]+/, 'keyword.helper.handlebars'],
[/else\b/, 'keyword.helper.handlebars'],
[/[\s]+/],
[/[^}]/, 'variable.parameter.handlebars'],
],
},
} as monaco.languages.IMonarchLanguage;

View file

@ -0,0 +1,5 @@
.urlTemplateEditor__container {
.monaco-editor .lines-content.monaco-editor-background {
margin: $euiSizeS;
}
}

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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import React from 'react';
import { UrlTemplateEditor } from './url_template_editor';
import { CodeEditor } from '../code_editor/code_editor';
storiesOf('UrlTemplateEditor', module)
.add('default', () => (
<UrlTemplateEditor
value={'http://elastic.co/foo/{{event.value}}?foo=bar&test={{json context.panel}}'}
onChange={action('onChange')}
Editor={CodeEditor}
/>
))
.add('with variables', () => (
<UrlTemplateEditor
value={'http://elastic.co/foo/{{event.value}}?foo=bar&test={{json context.panel}}'}
variables={[
{
label: 'event.value',
},
{
label: 'event.key',
title: 'Field key.',
documentation:
'Field key is Elasticsearch document key as described in Elasticsearch index pattern.',
},
{
label: 'kibanaUrl',
title: 'Kibana deployment URL.',
documentation: 'Kibana URL is the link to homepage of Kibana deployment.',
},
]}
onChange={action('onChange')}
Editor={CodeEditor}
/>
));

View file

@ -0,0 +1,163 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as React from 'react';
import { monaco } from '@kbn/monaco';
import { Props as CodeEditorProps } from '../code_editor/code_editor';
import { CodeEditor } from '../code_editor';
import { LANG } from './constants';
import { language, conf } from './language';
import './styles.scss';
monaco.languages.register({
id: LANG,
});
monaco.languages.setMonarchTokensProvider(LANG, language);
monaco.languages.setLanguageConfiguration(LANG, conf);
export interface UrlTemplateEditorVariable {
label: string;
title?: string;
documentation?: string;
kind?: monaco.languages.CompletionItemKind;
sortText?: string;
}
export interface UrlTemplateEditorProps {
value: string;
height?: CodeEditorProps['height'];
variables?: UrlTemplateEditorVariable[];
onChange: CodeEditorProps['onChange'];
onEditor?: (editor: monaco.editor.IStandaloneCodeEditor) => void;
Editor?: React.ComponentType<CodeEditorProps>;
}
export const UrlTemplateEditor: React.FC<UrlTemplateEditorProps> = ({
height = 105,
value,
variables,
onChange,
onEditor,
Editor = CodeEditor,
}) => {
const refEditor = React.useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const handleEditor = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor) => {
refEditor.current = editor;
if (onEditor) {
onEditor(editor);
}
}, []);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
const editor = refEditor.current;
if (!editor) return;
if (event.key === 'Escape') {
if (editor.hasWidgetFocus()) {
// Don't propagate Escape click if Monaco editor is focused, this allows
// user to close the autocomplete widget with Escape button without
// closing the EUI flyout.
event.stopPropagation();
editor.trigger('editor', 'hideSuggestWidget', []);
}
}
}, []);
React.useEffect(() => {
if (!variables) {
return;
}
const { dispose } = monaco.languages.registerCompletionItemProvider(LANG, {
triggerCharacters: ['{', '/', '?', '&', '='],
provideCompletionItems(model, position, context, token) {
const { lineNumber } = position;
const line = model.getLineContent(lineNumber);
const wordUntil = model.getWordUntilPosition(position);
const word = model.getWordAtPosition(position) || wordUntil;
const { startColumn, endColumn } = word;
const range = {
startLineNumber: lineNumber,
endLineNumber: lineNumber,
startColumn,
endColumn,
};
const leadingMustacheCount =
0 +
(line[range.startColumn - 2] === '{' ? 1 : 0) +
(line[range.startColumn - 3] === '{' ? 1 : 0);
const trailingMustacheCount =
0 +
(line[range.endColumn - 1] === '}' ? 1 : 0) +
(line[range.endColumn + 0] === '}' ? 1 : 0);
return {
suggestions: variables.map(
({
label,
title = '',
documentation = '',
kind = monaco.languages.CompletionItemKind.Variable,
sortText,
}) => ({
kind,
label,
insertText:
(leadingMustacheCount === 2 ? '' : leadingMustacheCount === 1 ? '{' : '{{') +
label +
(trailingMustacheCount === 2 ? '' : trailingMustacheCount === 1 ? '}' : '}}'),
detail: title,
documentation,
range,
sortText,
})
),
};
},
});
return () => {
dispose();
};
}, [variables]);
return (
<div className={'urlTemplateEditor__container'} onKeyDown={handleKeyDown}>
<Editor
languageId={LANG}
height={height}
value={value}
onChange={onChange}
editorDidMount={handleEditor}
options={{
fontSize: 14,
highlightActiveIndentGuide: false,
renderLineHighlight: 'none',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 2,
quickSuggestions: {
comments: false,
strings: false,
other: false,
},
suggestOnTriggerCharacters: true,
minimap: {
enabled: false,
},
wordWrap: 'on',
wrappingIndent: 'none',
}}
/>
</div>
);
};

View file

@ -6,6 +6,7 @@
*/
import { IExternalUrl } from 'src/core/public';
import { uiSettingsServiceMock } from 'src/core/public/mocks';
import { UrlDrilldown, ActionContext, Config } from './url_drilldown';
import { IEmbeddable, VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/embeddable/public';
import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common';
@ -74,6 +75,7 @@ const createDrilldown = (isExternalUrlValid: boolean = true) => {
getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs',
getVariablesHelpDocsLink: () => 'http://localhost:5601/docs',
navigateToUrl: mockNavigateToUrl,
uiSettings: uiSettingsServiceMock.createSetupContract(),
});
return drilldown;
};
@ -408,7 +410,7 @@ describe('UrlDrilldown', () => {
];
for (const expectedItem of expectedList) {
expect(list.includes(expectedItem)).toBe(true);
expect(!!list.find(({ label }) => label === expectedItem)).toBe(true);
}
});
@ -438,7 +440,7 @@ describe('UrlDrilldown', () => {
];
for (const expectedItem of expectedList) {
expect(list.includes(expectedItem)).toBe(true);
expect(!!list.find(({ label }) => label === expectedItem)).toBe(true);
}
});
});

View file

@ -6,9 +6,7 @@
*/
import React from 'react';
import { getFlattenedObject } from '@kbn/std';
import { IExternalUrl } from 'src/core/public';
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
import { IExternalUrl, IUiSettingsClient } from 'src/core/public';
import {
ChartActionContext,
CONTEXT_MENU_TRIGGER,
@ -20,6 +18,11 @@ import {
import { ROW_CLICK_TRIGGER } from '../../../../../../src/plugins/ui_actions/public';
import { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public';
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public';
import {
reactToUiComponent,
UrlTemplateEditorVariable,
KibanaContextProvider,
} from '../../../../../../src/plugins/kibana_react/public';
import {
UiActionsEnhancedDrilldownDefinition as Drilldown,
UrlDrilldownGlobalScope,
@ -29,8 +32,10 @@ import {
urlDrilldownCompileUrl,
UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext,
} from '../../../../ui_actions_enhanced/public';
import { getPanelVariables, getEventScope, getEventVariableList } from './url_drilldown_scope';
import { txtUrlDrilldownDisplayName } from './i18n';
import { getEventVariableList, getEventScopeValues } from './variables/event_variables';
import { getContextVariableList, getContextScopeValues } from './variables/context_variables';
import { getGlobalVariableList } from './variables/global_variables';
interface EmbeddableQueryInput extends EmbeddableInput {
query?: Query;
@ -47,6 +52,7 @@ interface UrlDrilldownDeps {
navigateToUrl: (url: string) => Promise<void>;
getSyntaxHelpDocsLink: () => string;
getVariablesHelpDocsLink: () => string;
uiSettings: IUiSettingsClient;
}
export type ActionContext = ChartActionContext<EmbeddableWithQueryInput>;
@ -90,21 +96,30 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
}) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const variables = React.useMemo(() => this.getVariableList(context), [context]);
return (
<UrlDrilldownCollectConfig
variables={variables}
config={config}
onConfig={onConfig}
syntaxHelpDocsLink={this.deps.getSyntaxHelpDocsLink()}
variablesHelpDocsLink={this.deps.getVariablesHelpDocsLink()}
/>
<KibanaContextProvider
services={{
uiSettings: this.deps.uiSettings,
}}
>
<UrlDrilldownCollectConfig
variables={variables}
config={config}
onConfig={onConfig}
syntaxHelpDocsLink={this.deps.getSyntaxHelpDocsLink()}
variablesHelpDocsLink={this.deps.getVariablesHelpDocsLink()}
/>
</KibanaContextProvider>
);
};
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
public readonly createConfig = () => ({
url: { template: '' },
url: {
template: 'https://example.com/?{{event.key}}={{event.value}}',
},
openInNewTab: true,
encodeUrl: true,
});
@ -167,21 +182,20 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
public readonly getRuntimeVariables = (context: ActionContext) => {
return {
event: getEventScopeValues(context),
context: getContextScopeValues(context),
...this.deps.getGlobalScope(),
context: {
panel: getPanelVariables(context),
},
event: getEventScope(context),
};
};
public readonly getVariableList = (context: ActionFactoryContext): string[] => {
public readonly getVariableList = (
context: ActionFactoryContext
): UrlTemplateEditorVariable[] => {
const globalScopeValues = this.deps.getGlobalScope();
const eventVariables = getEventVariableList(context);
const contextVariables = Object.keys(getFlattenedObject(getPanelVariables(context))).map(
(key) => 'context.panel.' + key
);
const globalVariables = Object.keys(getFlattenedObject(this.deps.getGlobalScope()));
const contextVariables = getContextVariableList(context);
const globalVariables = getGlobalVariableList(globalScopeValues);
return [...eventVariables.sort(), ...contextVariables.sort(), ...globalVariables.sort()];
return [...eventVariables, ...contextVariables, ...globalVariables];
};
}

View file

@ -1,294 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getEventScope,
ValueClickTriggerEventScope,
getEventVariableList,
getPanelVariables,
} from './url_drilldown_scope';
import {
RowClickContext,
ROW_CLICK_TRIGGER,
} from '../../../../../../src/plugins/ui_actions/public';
import { createPoint, rowClickData, TestEmbeddable } from './test/data';
describe('VALUE_CLICK_TRIGGER', () => {
describe('supports `points[]`', () => {
test('getEventScope()', () => {
const mockDataPoints = [
createPoint({ field: 'field0', value: 'value0' }),
createPoint({ field: 'field1', value: 'value1' }),
createPoint({ field: 'field2', value: 'value2' }),
];
const eventScope = getEventScope({
data: { data: mockDataPoints },
}) as ValueClickTriggerEventScope;
expect(eventScope.key).toBe('field0');
expect(eventScope.value).toBe('value0');
expect(eventScope.points).toHaveLength(mockDataPoints.length);
expect(eventScope.points).toMatchInlineSnapshot(`
Array [
Object {
"key": "field0",
"value": "value0",
},
Object {
"key": "field1",
"value": "value1",
},
Object {
"key": "field2",
"value": "value2",
},
]
`);
});
});
describe('handles undefined, null or missing values', () => {
test('undefined or missing values are removed from the result scope', () => {
const point = createPoint({ field: undefined } as any);
const eventScope = getEventScope({
data: { data: [point] },
}) as ValueClickTriggerEventScope;
expect('key' in eventScope).toBeFalsy();
expect('value' in eventScope).toBeFalsy();
});
test('null value stays in the result scope', () => {
const point = createPoint({ field: 'field', value: null });
const eventScope = getEventScope({
data: { data: [point] },
}) as ValueClickTriggerEventScope;
expect(eventScope.value).toBeNull();
});
});
});
describe('ROW_CLICK_TRIGGER', () => {
test('getEventVariableList() returns correct list of runtime variables', () => {
const vars = getEventVariableList({
triggers: [ROW_CLICK_TRIGGER],
});
expect(vars).toEqual(['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']);
});
test('getEventScope() returns correct variables for row click trigger', () => {
const context = ({
embeddable: {},
data: rowClickData as any,
} as unknown) as RowClickContext;
const res = getEventScope(context);
expect(res).toEqual({
rowIndex: 1,
values: ['IT', '2.25', 3, 0, 2],
keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'],
columnNames: [
'Top values of DestCountry',
'Top values of FlightTimeHour',
'Count of records',
'Average of DistanceMiles',
'Unique count of OriginAirportID',
],
});
});
});
describe('getPanelVariables()', () => {
test('returns only ID for empty embeddable', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
});
});
test('returns title as specified in input', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
title: 'title1',
},
{}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
title: 'title1',
});
});
test('returns output title if input and output titles are specified', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
title: 'title1',
},
{
title: 'title2',
}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
title: 'title2',
});
});
test('returns title from output if title in input is missing', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
title: 'title2',
}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
title: 'title2',
});
});
test('returns saved object ID from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
savedObjectId: '5678',
},
{
savedObjectId: '1234',
}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
savedObjectId: '1234',
});
});
test('returns saved object ID from input if it is not set on output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
savedObjectId: '5678',
},
{}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
savedObjectId: '5678',
});
});
test('returns query, timeRange and filters from input', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
query: {
language: 'C++',
query: 'std::cout << 123;',
},
timeRange: {
from: 'FROM',
to: 'TO',
},
filters: [
{
meta: {
alias: 'asdf',
disabled: false,
negate: false,
},
},
],
},
{}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
query: {
language: 'C++',
query: 'std::cout << 123;',
},
timeRange: {
from: 'FROM',
to: 'TO',
},
filters: [
{
meta: {
alias: 'asdf',
disabled: false,
negate: false,
},
},
],
});
});
test('returns a single index pattern from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }],
}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
});
});
test('returns multiple index patterns from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
indexPatterns: [
{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
{ id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' },
],
}
);
const vars = getPanelVariables({ embeddable });
expect(vars).toEqual({
id: 'test',
indexPatternIds: [
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy',
],
});
});
});

View file

@ -1,266 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* 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 type { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public';
import {
isRangeSelectTriggerContext,
isValueClickTriggerContext,
isRowClickTriggerContext,
isContextMenuTriggerContext,
RangeSelectContext,
SELECT_RANGE_TRIGGER,
ValueClickContext,
VALUE_CLICK_TRIGGER,
EmbeddableInput,
EmbeddableOutput,
} from '../../../../../../src/plugins/embeddable/public';
import type {
ActionContext,
ActionFactoryContext,
EmbeddableWithQueryInput,
} from './url_drilldown';
import {
RowClickContext,
ROW_CLICK_TRIGGER,
} from '../../../../../../src/plugins/ui_actions/public';
/**
* Part of context scope extracted from an embeddable
* Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}`
*/
interface EmbeddableUrlDrilldownContextScope extends EmbeddableInput {
/**
* ID of the embeddable panel.
*/
id: string;
/**
* Title of the embeddable panel.
*/
title?: string;
/**
* In case panel supports only 1 index pattern.
*/
indexPatternId?: string;
/**
* In case panel supports more then 1 index pattern.
*/
indexPatternIds?: string[];
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
savedObjectId?: string;
}
export function getPanelVariables(contextScopeInput: unknown): EmbeddableUrlDrilldownContextScope {
function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } {
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;
return getEmbeddableVariables(embeddable);
}
function hasSavedObjectId(obj: Record<string, any>): obj is { savedObjectId: string } {
return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string';
}
/**
* @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts,
* combine both implementations into a common approach.
*/
function getIndexPatternIds(output: EmbeddableOutput): 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[])
: [];
}
export function getEmbeddableVariables(
embeddable: EmbeddableWithQueryInput
): EmbeddableUrlDrilldownContextScope {
const input = embeddable.getInput();
const output = embeddable.getOutput();
const indexPatternsIds = getIndexPatternIds(output);
return deleteUndefinedKeys({
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.$}}
*/
export type UrlDrilldownEventScope =
| ValueClickTriggerEventScope
| RangeSelectTriggerEventScope
| RowClickTriggerEventScope
| ContextMenuTriggerEventScope;
export type EventScopeInput = ActionContext;
export interface ValueClickTriggerEventScope {
key?: string;
value: Primitive;
negate: boolean;
points: Array<{ key?: string; value: Primitive }>;
}
export interface RangeSelectTriggerEventScope {
key: string;
from?: string | number;
to?: string | number;
}
export interface RowClickTriggerEventScope {
rowIndex: number;
values: Primitive[];
keys: string[];
columnNames: string[];
}
export type ContextMenuTriggerEventScope = object;
export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope {
if (isRangeSelectTriggerContext(eventScopeInput)) {
return getEventScopeFromRangeSelectTriggerContext(eventScopeInput);
} else if (isValueClickTriggerContext(eventScopeInput)) {
return getEventScopeFromValueClickTriggerContext(eventScopeInput);
} else if (isRowClickTriggerContext(eventScopeInput)) {
return getEventScopeFromRowClickTriggerContext(eventScopeInput);
} else if (isContextMenuTriggerContext(eventScopeInput)) {
return {};
} else {
throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger");
}
}
function getEventScopeFromRangeSelectTriggerContext(
eventScopeInput: RangeSelectContext
): RangeSelectTriggerEventScope {
const { table, column: columnIndex, range } = eventScopeInput.data;
const column = table.columns[columnIndex];
return deleteUndefinedKeys({
key: toPrimitiveOrUndefined(column?.meta.field) as string,
from: toPrimitiveOrUndefined(range[0]) as string | number | undefined,
to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined,
});
}
function getEventScopeFromValueClickTriggerContext(
eventScopeInput: ValueClickContext
): ValueClickTriggerEventScope {
const negate = eventScopeInput.data.negate ?? false;
const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => {
const column = table.columns[columnIndex];
return {
value: toPrimitiveOrUndefined(value) as Primitive,
key: column?.meta?.field,
};
});
return deleteUndefinedKeys({
key: points[0]?.key,
value: points[0]?.value,
negate,
points,
});
}
function getEventScopeFromRowClickTriggerContext(ctx: RowClickContext): RowClickTriggerEventScope {
const { data } = ctx;
const embeddable = ctx.embeddable as EmbeddableWithQueryInput;
const { rowIndex } = data;
const columns = data.columns || data.table.columns.map(({ id }) => id);
const values: Primitive[] = [];
const keys: string[] = [];
const columnNames: string[] = [];
const row = data.table.rows[rowIndex];
for (const columnId of columns) {
const column = data.table.columns.find(({ id }) => id === columnId);
if (!column) {
// This should never happe, but in case it does we log data necessary for debugging.
// eslint-disable-next-line no-console
console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null);
throw new Error('Could not find a datatable column.');
}
values.push(row[columnId]);
keys.push(column.meta.field || '');
columnNames.push(column.name || column.meta.field || '');
}
const scope: RowClickTriggerEventScope = {
rowIndex,
values,
keys,
columnNames,
};
return scope;
}
export function getEventVariableList(context: ActionFactoryContext): string[] {
const [trigger] = context.triggers;
switch (trigger) {
case SELECT_RANGE_TRIGGER:
return ['event.key', 'event.from', 'event.to'];
case VALUE_CLICK_TRIGGER:
return ['event.key', 'event.value', 'event.negate', 'event.points'];
case ROW_CLICK_TRIGGER:
return ['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames'];
}
return [];
}
type Primitive = string | number | boolean | null;
function toPrimitiveOrUndefined(v: unknown): Primitive | undefined {
if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null)
return v;
if (typeof v === 'object' && v instanceof Date) return v.toISOString();
if (typeof v === 'undefined') return undefined;
return String(v);
}
function deleteUndefinedKeys<T extends Record<string, any>>(obj: T): T {
Object.keys(obj).forEach((key) => {
if (obj[key] === undefined) {
delete obj[key];
}
});
return obj;
}

View file

@ -0,0 +1,216 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getContextScopeValues } from './context_variables';
import { TestEmbeddable } from '../test/data';
describe('getContextScopeValues()', () => {
test('returns only ID for empty embeddable', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
},
});
});
test('returns title as specified in input', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
title: 'title1',
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
title: 'title1',
},
});
});
test('returns output title if input and output titles are specified', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
title: 'title1',
},
{
title: 'title2',
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
title: 'title2',
},
});
});
test('returns title from output if title in input is missing', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
title: 'title2',
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
title: 'title2',
},
});
});
test('returns saved object ID from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
savedObjectId: '5678',
},
{
savedObjectId: '1234',
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
savedObjectId: '1234',
},
});
});
test('returns saved object ID from input if it is not set on output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
savedObjectId: '5678',
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
savedObjectId: '5678',
},
});
});
test('returns query, timeRange and filters from input', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
query: {
language: 'C++',
query: 'std::cout << 123;',
},
timeRange: {
from: 'FROM',
to: 'TO',
},
filters: [
{
meta: {
alias: 'asdf',
disabled: false,
negate: false,
},
},
],
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
query: {
language: 'C++',
query: 'std::cout << 123;',
},
timeRange: {
from: 'FROM',
to: 'TO',
},
filters: [
{
meta: {
alias: 'asdf',
disabled: false,
negate: false,
},
},
],
},
});
});
test('returns a single index pattern from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }],
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
},
});
});
test('returns multiple index patterns from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
indexPatterns: [
{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
{ id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' },
],
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
indexPatternIds: [
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy',
],
},
});
});
});

View file

@ -0,0 +1,249 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { monaco } from '@kbn/monaco';
import { getFlattenedObject } from '@kbn/std';
import { txtValue } from './i18n';
import type { Filter, Query, TimeRange } from '../../../../../../../src/plugins/data/public';
import {
EmbeddableInput,
EmbeddableOutput,
} from '../../../../../../../src/plugins/embeddable/public';
import type { EmbeddableWithQueryInput } from '../url_drilldown';
import { deleteUndefinedKeys } from './util';
import type { ActionFactoryContext } from '../url_drilldown';
import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public';
/**
* Part of context scope extracted from an embeddable
* Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}`
*/
interface PanelValues extends EmbeddableInput {
/**
* ID of the embeddable panel.
*/
id: string;
/**
* Title of the embeddable panel.
*/
title?: string;
/**
* In case panel supports only 1 index pattern.
*/
indexPatternId?: string;
/**
* In case panel supports more then 1 index pattern.
*/
indexPatternIds?: string[];
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
savedObjectId?: string;
}
interface ContextValues {
panel: PanelValues;
}
function hasSavedObjectId(obj: Record<string, any>): obj is { savedObjectId: string } {
return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string';
}
/**
* @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts,
* combine both implementations into a common approach.
*/
function getIndexPatternIds(output: EmbeddableOutput): 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[])
: [];
}
export function getEmbeddableVariables(embeddable: EmbeddableWithQueryInput): PanelValues {
const input = embeddable.getInput();
const output = embeddable.getOutput();
const indexPatternsIds = getIndexPatternIds(output);
return deleteUndefinedKeys({
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,
});
}
const getContextPanelScopeValues = (contextScopeInput: unknown): PanelValues => {
function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } {
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;
return getEmbeddableVariables(embeddable);
};
export const getContextScopeValues = (contextScopeInput: unknown): ContextValues => {
return {
panel: getContextPanelScopeValues(contextScopeInput),
};
};
type VariableDescription = Pick<UrlTemplateEditorVariable, 'title' | 'documentation'>;
const variableDescriptions: Record<string, undefined | VariableDescription> = {
id: {
title: i18n.translate('xpack.urlDrilldown.context.panel.id.title', {
defaultMessage: 'Panel ID.',
}),
documentation: i18n.translate('xpack.urlDrilldown.context.panel.id.documentation', {
defaultMessage: 'ID of the panel where drilldown is executed.',
}),
},
title: {
title: i18n.translate('xpack.urlDrilldown.context.panel.title.title', {
defaultMessage: 'Panel title.',
}),
documentation: i18n.translate('xpack.urlDrilldown.context.panel.title.documentation', {
defaultMessage: 'Title of the panel where drilldown is executed.',
}),
},
filters: {
title: i18n.translate('xpack.urlDrilldown.context.panel.filters.title', {
defaultMessage: 'Panel filters.',
}),
documentation: i18n.translate('xpack.urlDrilldown.context.panel.filters.documentation', {
defaultMessage: 'List of Kibana filters applied to a panel.',
}),
},
'query.query': {
title: i18n.translate('xpack.urlDrilldown.context.panel.query.query.title', {
defaultMessage: 'Query string.',
}),
},
'query.language': {
title: i18n.translate('xpack.urlDrilldown.context.panel.query.language.title', {
defaultMessage: 'Query language.',
}),
},
'timeRange.from': {
title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.from.title', {
defaultMessage: 'Time picker "from" value.',
}),
},
'timeRange.to': {
title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.to.title', {
defaultMessage: 'Time picker "to" value.',
}),
},
indexPatternId: {
title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternId.title', {
defaultMessage: 'Index pattern ID.',
}),
documentation: i18n.translate(
'xpack.urlDrilldown.context.panel.timeRange.indexPatternId.documentation',
{
defaultMessage: 'First index pattern ID used by the panel.',
}
),
},
indexPatternIds: {
title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.title', {
defaultMessage: 'Index pattern IDs.',
}),
documentation: i18n.translate(
'xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.documentation',
{
defaultMessage: 'List of all index pattern IDs used by the panel.',
}
),
},
savedObjectId: {
title: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.title', {
defaultMessage: 'Saved object ID.',
}),
documentation: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.documentation', {
defaultMessage: 'ID of the saved object behind the panel.',
}),
},
};
const kind = monaco.languages.CompletionItemKind.Variable;
const sortPrefix = '2.';
const formatValue = (value: unknown) => {
if (typeof value === 'object') {
return '\n' + JSON.stringify(value, null, 4);
}
return String(value);
};
const getPanelVariableList = (values: PanelValues): UrlTemplateEditorVariable[] => {
const variables: UrlTemplateEditorVariable[] = [];
const flattenedValues = getFlattenedObject(values);
const keys = Object.keys(flattenedValues).sort();
for (const key of keys) {
const description = variableDescriptions[key];
const label = 'context.panel.' + key;
if (!description) {
variables.push({
label,
sortText: sortPrefix + label,
documentation: !!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : '',
kind,
});
continue;
}
variables.push({
label,
sortText: sortPrefix + label,
title: description.title,
documentation:
(description.documentation || '') +
(!!description.documentation && !!flattenedValues[key] ? '\n\n' : '') +
(!!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : ''),
kind,
});
}
return variables;
};
export const getContextVariableList = (
context: ActionFactoryContext
): UrlTemplateEditorVariable[] => {
const values = getContextScopeValues(context);
const variables: UrlTemplateEditorVariable[] = getPanelVariableList(values.panel);
return variables;
};

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getEventScopeValues,
getEventVariableList,
ValueClickTriggerEventScope,
} from './event_variables';
import {
RowClickContext,
ROW_CLICK_TRIGGER,
} from '../../../../../../../src/plugins/ui_actions/public';
import { createPoint, rowClickData } from '../test/data';
describe('VALUE_CLICK_TRIGGER', () => {
describe('supports `points[]`', () => {
test('getEventScopeValues()', () => {
const mockDataPoints = [
createPoint({ field: 'field0', value: 'value0' }),
createPoint({ field: 'field1', value: 'value1' }),
createPoint({ field: 'field2', value: 'value2' }),
];
const eventScope = getEventScopeValues({
data: { data: mockDataPoints },
}) as ValueClickTriggerEventScope;
expect(eventScope.key).toBe('field0');
expect(eventScope.value).toBe('value0');
expect(eventScope.points).toHaveLength(mockDataPoints.length);
expect(eventScope.points).toMatchInlineSnapshot(`
Array [
Object {
"key": "field0",
"value": "value0",
},
Object {
"key": "field1",
"value": "value1",
},
Object {
"key": "field2",
"value": "value2",
},
]
`);
});
});
describe('handles undefined, null or missing values', () => {
test('undefined or missing values are removed from the result scope', () => {
const point = createPoint({ field: undefined } as any);
const eventScope = getEventScopeValues({
data: { data: [point] },
}) as ValueClickTriggerEventScope;
expect('key' in eventScope).toBeFalsy();
expect('value' in eventScope).toBeFalsy();
});
test('null value stays in the result scope', () => {
const point = createPoint({ field: 'field', value: null });
const eventScope = getEventScopeValues({
data: { data: [point] },
}) as ValueClickTriggerEventScope;
expect(eventScope.value).toBeNull();
});
});
});
describe('ROW_CLICK_TRIGGER', () => {
test('getEventVariableList() returns correct list of runtime variables', () => {
const vars = getEventVariableList({
triggers: [ROW_CLICK_TRIGGER],
});
expect(vars.map(({ label }) => label)).toEqual([
'event.values',
'event.keys',
'event.columnNames',
'event.rowIndex',
]);
});
test('getEventScopeValues() returns correct variables for row click trigger', () => {
const context = ({
embeddable: {},
data: rowClickData as any,
} as unknown) as RowClickContext;
const res = getEventScopeValues(context);
expect(res).toEqual({
rowIndex: 1,
values: ['IT', '2.25', 3, 0, 2],
keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'],
columnNames: [
'Top values of DestCountry',
'Top values of FlightTimeHour',
'Count of records',
'Average of DistanceMiles',
'Unique count of OriginAirportID',
],
});
});
});

View file

@ -0,0 +1,298 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { monaco } from '@kbn/monaco';
import {
isRangeSelectTriggerContext,
isValueClickTriggerContext,
isRowClickTriggerContext,
isContextMenuTriggerContext,
RangeSelectContext,
SELECT_RANGE_TRIGGER,
ValueClickContext,
VALUE_CLICK_TRIGGER,
} from '../../../../../../../src/plugins/embeddable/public';
import type {
ActionContext,
ActionFactoryContext,
EmbeddableWithQueryInput,
} from '../url_drilldown';
import {
RowClickContext,
ROW_CLICK_TRIGGER,
} from '../../../../../../../src/plugins/ui_actions/public';
import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public';
import { deleteUndefinedKeys, toPrimitiveOrUndefined, Primitive } from './util';
/**
* URL drilldown event scope, available as `{{event.*}}` Handlebars variables.
*/
export type UrlDrilldownEventScope =
| ValueClickTriggerEventScope
| RangeSelectTriggerEventScope
| RowClickTriggerEventScope
| ContextMenuTriggerEventScope;
export type EventScopeInput = ActionContext;
export interface ValueClickTriggerEventScope {
key?: string;
value: Primitive;
negate: boolean;
points: Array<{ key?: string; value: Primitive }>;
}
export interface RangeSelectTriggerEventScope {
key: string;
from?: string | number;
to?: string | number;
}
export interface RowClickTriggerEventScope {
rowIndex: number;
values: Primitive[];
keys: string[];
columnNames: string[];
}
export type ContextMenuTriggerEventScope = object;
const getEventScopeFromRangeSelectTriggerContext = (
eventScopeInput: RangeSelectContext
): RangeSelectTriggerEventScope => {
const { table, column: columnIndex, range } = eventScopeInput.data;
const column = table.columns[columnIndex];
return deleteUndefinedKeys({
key: toPrimitiveOrUndefined(column?.meta.field) as string,
from: toPrimitiveOrUndefined(range[0]) as string | number | undefined,
to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined,
});
};
const getEventScopeFromValueClickTriggerContext = (
eventScopeInput: ValueClickContext
): ValueClickTriggerEventScope => {
const negate = eventScopeInput.data.negate ?? false;
const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => {
const column = table.columns[columnIndex];
return {
value: toPrimitiveOrUndefined(value) as Primitive,
key: column?.meta?.field,
};
});
return deleteUndefinedKeys({
key: points[0]?.key,
value: points[0]?.value,
negate,
points,
});
};
const getEventScopeFromRowClickTriggerContext = (
ctx: RowClickContext
): RowClickTriggerEventScope => {
const { data } = ctx;
const embeddable = ctx.embeddable as EmbeddableWithQueryInput;
const { rowIndex } = data;
const columns = data.columns || data.table.columns.map(({ id }) => id);
const values: Primitive[] = [];
const keys: string[] = [];
const columnNames: string[] = [];
const row = data.table.rows[rowIndex];
for (const columnId of columns) {
const column = data.table.columns.find(({ id }) => id === columnId);
if (!column) {
// This should never happe, but in case it does we log data necessary for debugging.
// eslint-disable-next-line no-console
console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null);
throw new Error('Could not find a datatable column.');
}
values.push(row[columnId]);
keys.push(column.meta.field || '');
columnNames.push(column.name || column.meta.field || '');
}
const scope: RowClickTriggerEventScope = {
rowIndex,
values,
keys,
columnNames,
};
return scope;
};
export const getEventScopeValues = (eventScopeInput: EventScopeInput): UrlDrilldownEventScope => {
if (isRangeSelectTriggerContext(eventScopeInput)) {
return getEventScopeFromRangeSelectTriggerContext(eventScopeInput);
} else if (isValueClickTriggerContext(eventScopeInput)) {
return getEventScopeFromValueClickTriggerContext(eventScopeInput);
} else if (isRowClickTriggerContext(eventScopeInput)) {
return getEventScopeFromRowClickTriggerContext(eventScopeInput);
} else if (isContextMenuTriggerContext(eventScopeInput)) {
return {};
} else {
throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger");
}
};
const kind = monaco.languages.CompletionItemKind.Event;
const sortPrefix = '1.';
const valueClickVariables: readonly UrlTemplateEditorVariable[] = [
{
label: 'event.value',
sortText: sortPrefix + 'event.value',
title: i18n.translate('xpack.urlDrilldown.click.event.value.title', {
defaultMessage: 'Click value.',
}),
documentation: i18n.translate('xpack.urlDrilldown.click.event.value.documentation', {
defaultMessage: 'Value behind clicked data point.',
}),
kind,
},
{
label: 'event.key',
sortText: sortPrefix + 'event.key',
title: i18n.translate('xpack.urlDrilldown.click.event.key.title', {
defaultMessage: 'Name of clicked field.',
}),
documentation: i18n.translate('xpack.urlDrilldown.click.event.key.documentation', {
defaultMessage: 'Field name behind clicked data point.',
}),
kind,
},
{
label: 'event.negate',
sortText: sortPrefix + 'event.negate',
title: i18n.translate('xpack.urlDrilldown.click.event.negate.title', {
defaultMessage: 'Whether the filter is negated.',
}),
documentation: i18n.translate('xpack.urlDrilldown.click.event.negate.documentation', {
defaultMessage: 'Boolean, indicating whether clicked data point resulted in negative filter.',
}),
kind,
},
{
label: 'event.points',
sortText: sortPrefix + 'event.points',
title: i18n.translate('xpack.urlDrilldown.click.event.points.title', {
defaultMessage: 'List of all clicked points.',
}),
documentation: i18n.translate('xpack.urlDrilldown.click.event.points.documentation', {
defaultMessage:
'Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient.',
}),
kind,
},
];
const rowClickVariables: readonly UrlTemplateEditorVariable[] = [
{
label: 'event.values',
sortText: sortPrefix + 'event.values',
title: i18n.translate('xpack.urlDrilldown.row.event.values.title', {
defaultMessage: 'List of row cell values.',
}),
documentation: i18n.translate('xpack.urlDrilldown.row.event.values.documentation', {
defaultMessage: 'An array of all cell values for the raw on which the action will execute.',
}),
kind,
},
{
label: 'event.keys',
sortText: sortPrefix + 'event.keys',
title: i18n.translate('xpack.urlDrilldown.row.event.keys.title', {
defaultMessage: 'List of row cell fields.',
}),
documentation: i18n.translate('xpack.urlDrilldown.row.event.keys.documentation', {
defaultMessage: 'An array of field names for each column.',
}),
kind,
},
{
label: 'event.columnNames',
sortText: sortPrefix + 'event.columnNames',
title: i18n.translate('xpack.urlDrilldown.row.event.columnNames.title', {
defaultMessage: 'List of table column names.',
}),
documentation: i18n.translate('xpack.urlDrilldown.row.event.columnNames.documentation', {
defaultMessage: 'An array of column names.',
}),
kind,
},
{
label: 'event.rowIndex',
sortText: sortPrefix + 'event.rowIndex',
title: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.title', {
defaultMessage: 'Clicked row index.',
}),
documentation: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.documentation', {
defaultMessage: 'Number, representing the row that was clicked, starting from 0.',
}),
kind,
},
];
const selectRangeVariables: readonly UrlTemplateEditorVariable[] = [
{
label: 'event.key',
sortText: sortPrefix + 'event.key',
title: i18n.translate('xpack.urlDrilldown.range.event.key.title', {
defaultMessage: 'Name of aggregation field.',
}),
documentation: i18n.translate('xpack.urlDrilldown.range.event.key.documentation', {
defaultMessage: 'Aggregation field behind the selected range, if available.',
}),
kind,
},
{
label: 'event.from',
sortText: sortPrefix + 'event.from',
title: i18n.translate('xpack.urlDrilldown.range.event.from.title', {
defaultMessage: 'Range start value.',
}),
documentation: i18n.translate('xpack.urlDrilldown.range.event.from.documentation', {
defaultMessage:
'`from` value of the selected range. Depending on your data, could be either a date or number.',
}),
kind,
},
{
label: 'event.to',
sortText: sortPrefix + 'event.to',
title: i18n.translate('xpack.urlDrilldown.range.event.to.title', {
defaultMessage: 'Range end value.',
}),
documentation: i18n.translate('xpack.urlDrilldown.range.event.to.documentation', {
defaultMessage:
'`to` value of the selected range. Depending on your data, could be either a date or number.',
}),
kind,
},
];
export const getEventVariableList = (
context: ActionFactoryContext
): UrlTemplateEditorVariable[] => {
const [trigger] = context.triggers;
switch (trigger) {
case VALUE_CLICK_TRIGGER:
return [...valueClickVariables];
case ROW_CLICK_TRIGGER:
return [...rowClickVariables];
case SELECT_RANGE_TRIGGER:
return [...selectRangeVariables];
}
return [];
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { monaco } from '@kbn/monaco';
import { txtValue } from './i18n';
import { UrlDrilldownGlobalScope } from '../../../../../ui_actions_enhanced/public';
import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public';
const kind = monaco.languages.CompletionItemKind.Constant;
const sortPrefix = '3.';
export const getGlobalVariableList = (
values: UrlDrilldownGlobalScope
): UrlTemplateEditorVariable[] => {
const globalVariables: UrlTemplateEditorVariable[] = [
{
label: 'kibanaUrl',
sortText: sortPrefix + 'kibanaUrl',
title: i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation.title', {
defaultMessage: 'Link to Kibana homepage.',
}),
documentation:
i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation', {
defaultMessage:
'Kibana base URL. Useful for creating URL drilldowns that navigate within Kibana.',
}) +
'\n\n' +
txtValue(values.kibanaUrl),
kind,
},
];
return globalVariables;
};

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const txtValue = (value: string) =>
i18n.translate('xpack.urlDrilldown.valuePreview', {
defaultMessage: 'Value: {value}',
values: {
value,
},
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type Primitive = string | number | boolean | null;
export const toPrimitiveOrUndefined = (v: unknown): Primitive | undefined => {
if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null)
return v;
if (typeof v === 'object' && v instanceof Date) return v.toISOString();
if (typeof v === 'undefined') return undefined;
return String(v);
};
export const deleteUndefinedKeys = <T extends Record<string, any>>(obj: T): T => {
Object.keys(obj).forEach((key) => {
if (obj[key] === undefined) {
delete obj[key];
}
});
return obj;
};

View file

@ -47,6 +47,7 @@ export class UrlDrilldownPlugin
startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax,
getVariablesHelpDocsLink: () =>
startServices().core.docLinks.links.dashboard.urlDrilldownVariables,
uiSettings: core.uiSettings,
})
);

View file

@ -2,13 +2,13 @@
"id": "uiActionsEnhanced",
"version": "kibana",
"configPath": ["xpack", "ui_actions_enhanced"],
"server": true,
"ui": true,
"requiredPlugins": [
"embeddable",
"uiActions",
"licensing"
],
"server": true,
"ui": true,
"requiredBundles": [
"kibanaUtils",
"kibanaReact",

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config/url_drilldown_collect_config';
export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config';

View file

@ -25,13 +25,6 @@ export const txtUrlPreviewHelpText = i18n.translate(
}
);
export const txtAddVariableButtonTitle = i18n.translate(
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle',
{
defaultMessage: 'Add variable',
}
);
export const txtUrlTemplateLabel = i18n.translate(
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel',
{
@ -46,20 +39,6 @@ export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate(
}
);
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',
{

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { UrlDrilldownCollectConfig, UrlDrilldownCollectConfigProps } from './lazy';

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as React from 'react';
import type { UrlDrilldownCollectConfigProps } from './url_drilldown_collect_config';
const UrlDrilldownCollectConfigLazy = React.lazy(() =>
import('./url_drilldown_collect_config').then(({ UrlDrilldownCollectConfig }) => ({
default: UrlDrilldownCollectConfig,
}))
);
export type { UrlDrilldownCollectConfigProps };
export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfigProps> = (props) => {
return (
<React.Suspense fallback={null}>
<UrlDrilldownCollectConfigLazy {...props} />
</React.Suspense>
);
};

View file

@ -20,7 +20,14 @@ export const Demo = () => {
<UrlDrilldownCollectConfig
config={config}
onConfig={onConfig}
variables={['event.key', 'event.value']}
variables={[
{
label: 'event.key',
},
{
label: 'event.value',
},
]}
/>
{JSON.stringify(config)}
</>

View file

@ -5,55 +5,49 @@
* 2.0.
*/
import React, { useRef, useState } from 'react';
import React, { useRef } from 'react';
import {
EuiFormRow,
EuiIcon,
EuiLink,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiSelectable,
EuiText,
EuiTextArea,
EuiSelectableOption,
EuiSwitch,
EuiAccordion,
EuiSpacer,
EuiPanel,
EuiTextColor,
} from '@elastic/eui';
import { monaco } from '@kbn/monaco';
import { UrlDrilldownConfig } from '../../types';
import './index.scss';
import {
txtAddVariableButtonTitle,
txtUrlTemplateSyntaxHelpLinkText,
txtUrlTemplateVariablesHelpLinkText,
txtUrlTemplateVariablesFilterPlaceholderText,
txtUrlTemplateLabel,
txtUrlTemplateOpenInNewTab,
txtUrlTemplatePlaceholder,
txtUrlTemplateAdditionalOptions,
txtUrlTemplateEncodeUrl,
txtUrlTemplateEncodeDescription,
} from './i18n';
import { VariablePopover } from '../variable_popover';
import {
UrlTemplateEditor,
UrlTemplateEditorVariable,
} from '../../../../../../../../src/plugins/kibana_react/public';
export interface UrlDrilldownCollectConfig {
export interface UrlDrilldownCollectConfigProps {
config: UrlDrilldownConfig;
variables: string[];
variables: UrlTemplateEditorVariable[];
onConfig: (newConfig: UrlDrilldownConfig) => void;
syntaxHelpDocsLink?: string;
variablesHelpDocsLink?: string;
}
export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfig> = ({
export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfigProps> = ({
config,
variables,
onConfig,
syntaxHelpDocsLink,
variablesHelpDocsLink,
}) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const [showUrlError, setShowUrlError] = React.useState(false);
const urlTemplate = config.url.template ?? '';
@ -72,19 +66,16 @@ export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfig> = ({
const isEmpty = !urlTemplate;
const isInvalid = showUrlError && isEmpty;
const variablesDropdown = (
<AddVariableButton
<VariablePopover
variables={variables}
variablesHelpLink={variablesHelpDocsLink}
onSelect={(variable: string) => {
if (textAreaRef.current) {
updateUrlTemplate(
urlTemplate.substr(0, textAreaRef.current!.selectionStart) +
`{{${variable}}}` +
urlTemplate.substr(textAreaRef.current!.selectionEnd)
);
} else {
updateUrlTemplate(urlTemplate + `{{${variable}}}`);
}
const editor = editorRef.current;
if (!editor) return;
editor.trigger('keyboard', 'type', {
text: '{{' + variable + '}}',
});
}}
/>
);
@ -105,17 +96,13 @@ export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfig> = ({
}
labelAppend={variablesDropdown}
>
<EuiTextArea
fullWidth
isInvalid={isInvalid}
name="url"
data-test-subj="urlInput"
<UrlTemplateEditor
variables={variables}
value={urlTemplate}
placeholder={txtUrlTemplatePlaceholder}
onChange={(event) => updateUrlTemplate(event.target.value)}
onBlur={() => setShowUrlError(true)}
rows={3}
inputRef={textAreaRef}
onChange={(newUrlTemplate) => updateUrlTemplate(newUrlTemplate)}
onEditor={(editor) => {
editorRef.current = editor;
}}
/>
</EuiFormRow>
<EuiSpacer size={'l'} />
@ -156,71 +143,3 @@ export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfig> = ({
</>
);
};
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"
>
<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,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const txtAddVariableButtonTitle = i18n.translate(
'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle',
{
defaultMessage: 'Add variable',
}
);
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',
}
);

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import {
EuiIcon,
EuiLink,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiSelectable,
EuiText,
EuiSelectableOption,
} from '@elastic/eui';
import {
txtAddVariableButtonTitle,
txtUrlTemplateVariablesHelpLinkText,
txtUrlTemplateVariablesFilterPlaceholderText,
} from './i18n';
import { UrlTemplateEditorVariable } from '../../../../../../../../src/plugins/kibana_react/public';
export interface Props {
variables: UrlTemplateEditorVariable[];
onSelect: (variable: string) => void;
variablesHelpLink?: string;
}
export const VariablePopover: React.FC<Props> = ({ variables, onSelect, variablesHelpLink }) => {
const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false);
const closePopover = () => setIsVariablesPopoverOpen(false);
const options: EuiSelectableOption[] = variables.map(({ label }) => ({
key: label,
label,
}));
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"
>
<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

@ -5,6 +5,7 @@
* 2.0.
*/
import { Key } from 'selenium-webdriver';
import { FtrProviderContext } from '../../ftr_provider_context';
const CREATE_DRILLDOWN_FLYOUT_DATA_TEST_SUBJ = 'createDrilldownFlyout';
@ -24,7 +25,8 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon
const flyout = getService('flyout');
const comboBox = getService('comboBox');
const esArchiver = getService('esArchiver');
const find = getService('find');
const browser = getService('browser');
return new (class DashboardDrilldownsManage {
readonly DASHBOARD_WITH_PIE_CHART_NAME = 'Dashboard with Pie Chart';
readonly DASHBOARD_WITH_AREA_CHART_NAME = 'Dashboard With Area Chart';
@ -116,8 +118,22 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon
}
}
async eraseInput(maxChars: number) {
const keys = [
...Array(maxChars).fill(Key.ARROW_RIGHT),
...Array(maxChars).fill(Key.BACK_SPACE),
];
await browser
.getActions()
.sendKeys(...keys)
.perform();
}
async fillInURLTemplate(destinationURLTemplate: string) {
await testSubjects.setValue('urlInput', destinationURLTemplate);
const monaco = await find.byCssSelector('.urlTemplateEditor__container .monaco-editor');
await monaco.clickMouseButton();
await this.eraseInput(300);
await browser.pressKeys(destinationURLTemplate);
}
async saveChanges() {