mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
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:
parent
4d01440012
commit
8ed1c3ca3e
33 changed files with 1646 additions and 720 deletions
|
@ -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.
|
||||
|
||||
|
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -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';
|
|
@ -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';
|
198
src/plugins/kibana_react/public/url_template_editor/language.ts
Normal file
198
src/plugins/kibana_react/public/url_template_editor/language.ts
Normal 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;
|
|
@ -0,0 +1,5 @@
|
|||
.urlTemplateEditor__container {
|
||||
.monaco-editor .lines-content.monaco-editor-background {
|
||||
margin: $euiSizeS;
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
));
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 [];
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -47,6 +47,7 @@ export class UrlDrilldownPlugin
|
|||
startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax,
|
||||
getVariablesHelpDocsLink: () =>
|
||||
startServices().core.docLinks.links.dashboard.urlDrilldownVariables,
|
||||
uiSettings: core.uiSettings,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config/url_drilldown_collect_config';
|
||||
export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config';
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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)}
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue