[Canvas] Add Monaco to the Canvas Expression Editor (#41790) (#43995)

* First version of Editor component and integration with the expression editor

* Adding resize detector

* Remove blue border on editor select

* Adding types for the react resize detector

* Adding worker and a few more monaco plugins

* Suggestion completion rework

* Add resize detector types as well as an IE11 full width bug fix

* Adding correct types for function definitions and monaco

* change CSS class names, add border to input

* Adding boolean styling

* Slight refactor of canvas function/arg types and adding first pass of hover

* Fixing hover interaction for functions and arguments

* Namespacing Code monaco css overrides

* Styling cleanup and simple README

* Setting up tests including some storyshots for the ExpressionInput component and Editor component

* Prop documentation for both the ExpressionInput and Editor components

* Adding Editor snapshots

* tiny cleanup

* Moving language registration, adding autocomplete suggestion types, and cleaning up editor

* Some documentation and cleanup from PR feedback

* Fixing types, adding documentation

* clean up editor, remove autocomplete toggle

* More PR cleanup

* Test fix, type fix

* fix issues around errors. code cleanup
This commit is contained in:
Poff Poffenberger 2019-08-26 10:58:54 -05:00 committed by GitHub
parent 0e85f914c5
commit 6175c3534f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1198 additions and 566 deletions

View file

@ -44,8 +44,9 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) {
'^.+\\.html?$': 'jest-raw-loader',
},
transformIgnorePatterns: [
// ignore all node_modules except @elastic/eui which requires babel transforms to handle dynamic import()
'[/\\\\]node_modules(?![\\/\\\\]@elastic[\\/\\\\]eui)[/\\\\].+\\.js$',
// ignore all node_modules except @elastic/eui and monaco-editor which both require babel transforms to handle dynamic import()
// since ESM modules are not natively supported in Jest yet (https://github.com/facebook/jest/issues/4842)
'[/\\\\]node_modules(?![\\/\\\\]@elastic[\\/\\\\]eui)(?![\\/\\\\]monaco-editor)[/\\\\].+\\.js$',
],
snapshotSerializers: [`${kibanaDirectory}/node_modules/enzyme-to-json/serializer`],
reporters: [

View file

@ -43,6 +43,8 @@ jest.mock('@elastic/eui/packages/react-datepicker', () => {
};
});
jest.mock('plugins/interpreter/registries', () => ({}));
// Disabling this test due to https://github.com/elastic/eui/issues/2242
jest.mock(
'../public/components/workpad_header/workpad_export/__examples__/disabled_panel.examples',

View file

@ -127,6 +127,10 @@ module.exports = async ({ config }) => {
KIBANA_ROOT,
'packages/kbn-interpreter/target/common'
);
config.resolve.alias['plugins/interpreter/registries'] = path.resolve(
KIBANA_ROOT,
'packages/kbn-interpreter/target/common/registries'
);
return config;
};

View file

@ -20,7 +20,7 @@ const TextAreaArgInput = ({ updateValue, value, confirm, commit, renderError, ar
return (
<EuiForm>
<EuiTextArea
className="canvasTextArea--code"
className="canvasTextArea__code"
id={argId}
rows={10}
value={value}

View file

@ -60,7 +60,7 @@ class EssqlDatasource extends PureComponent {
<EuiTextArea
placeholder={this.defaultQuery}
isInvalid={isInvalid}
className="canvasTextArea--code"
className="canvasTextArea__code"
value={this.getQuery()}
onChange={this.onChange}
/>

View file

@ -65,7 +65,7 @@ const TimelionDatasource = ({ args, updateArgs, defaultIndex }) => {
<EuiFormRow label="Query" helpText="Lucene Query String syntax">
<EuiTextArea
className="canvasTextArea--code"
className="canvasTextArea__code"
value={getQuery()}
onChange={e => setArg(argName, e.target.value)}
/>

View file

@ -12,10 +12,48 @@ import {
ExpressionFunctionAST,
ExpressionArgAST,
CanvasFunction,
CanvasArg,
CanvasArgValue,
} from '../../types';
const MARKER = 'CANVAS_SUGGESTION_MARKER';
interface BaseSuggestion {
text: string;
start: number;
end: number;
}
interface FunctionSuggestion extends BaseSuggestion {
type: 'function';
fnDef: CanvasFunction;
}
type ArgSuggestionValue = CanvasArgValue & {
name: string;
};
interface ArgSuggestion extends BaseSuggestion {
type: 'argument';
argDef: ArgSuggestionValue;
}
interface ValueSuggestion extends BaseSuggestion {
type: 'value';
}
export type AutocompleteSuggestion = FunctionSuggestion | ArgSuggestion | ValueSuggestion;
interface FnArgAtPosition {
ast: ExpressionASTWithMeta;
fnIndex: number;
argName?: string;
argIndex?: number;
argStart?: number;
argEnd?: number;
}
// If you parse an expression with the "addMeta" option it completely
// changes the type of returned object. The following types
// enhance the existing AST types with the appropriate meta information
@ -62,19 +100,14 @@ function isExpression(
return typeof maybeExpression.node === 'object';
}
type valueof<T> = T[keyof T];
type ValuesOfUnion<T> = T extends any ? valueof<T> : never;
// All of the possible Arg Values
type ArgValue = ValuesOfUnion<CanvasFunction['args']>;
// All of the argument objects
type CanvasArg = CanvasFunction['args'];
// Overloads to change return type based on specs
function getByAlias(specs: CanvasFunction[], name: string): CanvasFunction;
// eslint-disable-next-line @typescript-eslint/unified-signatures
function getByAlias(specs: CanvasArg, name: string): ArgValue;
function getByAlias(specs: CanvasFunction[] | CanvasArg, name: string): CanvasFunction | ArgValue {
function getByAlias(specs: CanvasArg, name: string): CanvasArgValue;
function getByAlias(
specs: CanvasFunction[] | CanvasArg,
name: string
): CanvasFunction | CanvasArgValue {
return untypedGetByAlias(specs, name);
}
@ -87,23 +120,24 @@ export function getFnArgDefAtPosition(
expression: string,
position: number
) {
const text = expression.substr(0, position) + MARKER + expression.substr(position);
try {
const ast: ExpressionASTWithMeta = parse(text, { addMeta: true }) as ExpressionASTWithMeta;
const ast: ExpressionASTWithMeta = parse(expression, {
addMeta: true,
}) as ExpressionASTWithMeta;
const { ast: newAst, fnIndex, argName } = getFnArgAtPosition(ast, position);
const { ast: newAst, fnIndex, argName, argStart, argEnd } = getFnArgAtPosition(ast, position);
const fn = newAst.node.chain[fnIndex].node;
const fnDef = getByAlias(specs, fn.function.replace(MARKER, ''));
const fnDef = getByAlias(specs, fn.function);
if (fnDef && argName) {
const argDef = getByAlias(fnDef.args, argName);
return { fnDef, argDef };
return { fnDef, argDef, argStart, argEnd };
}
return { fnDef };
} catch (e) {
// Fail silently
}
return [];
return {};
}
/**
@ -117,7 +151,7 @@ export function getAutocompleteSuggestions(
specs: CanvasFunction[],
expression: string,
position: number
) {
): AutocompleteSuggestion[] {
const text = expression.substr(0, position) + MARKER + expression.substr(position);
try {
const ast = parse(text, { addMeta: true }) as ExpressionASTWithMeta;
@ -151,20 +185,39 @@ export function getAutocompleteSuggestions(
It returns which function the cursor is in, as well as which argument for that function the cursor is in
if any.
*/
function getFnArgAtPosition(
ast: ExpressionASTWithMeta,
position: number
): { ast: ExpressionASTWithMeta; fnIndex: number; argName?: string; argIndex?: number } {
function getFnArgAtPosition(ast: ExpressionASTWithMeta, position: number): FnArgAtPosition {
const fnIndex = ast.node.chain.findIndex(fn => fn.start <= position && position <= fn.end);
const fn = ast.node.chain[fnIndex];
for (const [argName, argValues] of Object.entries(fn.node.arguments)) {
for (let argIndex = 0; argIndex < argValues.length; argIndex++) {
const value = argValues[argIndex];
if (value.start <= position && position <= value.end) {
let argStart = value.start;
let argEnd = value.end;
if (argName !== '_') {
// If an arg name is specified, expand our start position to include
// the arg name plus the `=` character
argStart = argStart - (argName.length + 1);
// If the arg value is an expression, expand our start and end position
// to include the opening and closing braces
if (value.node !== null && isExpression(value)) {
argStart--;
argEnd++;
}
}
if (argStart <= position && position <= argEnd) {
// If the current position is on an expression and NOT on the expression's
// argument name (`font=` for example), recurse within the expression
if (
value.node !== null &&
isExpression(value) &&
(argName === '_' || !(argStart <= position && position <= argStart + argName.length + 1))
) {
return getFnArgAtPosition(value, position);
}
return { ast, fnIndex, argName, argIndex };
return { ast, fnIndex, argName, argIndex, argStart, argEnd };
}
}
}
@ -175,7 +228,7 @@ function getFnNameSuggestions(
specs: CanvasFunction[],
ast: ExpressionASTWithMeta,
fnIndex: number
) {
): FunctionSuggestion[] {
// Filter the list of functions by the text at the marker
const { start, end, node: fn } = ast.node.chain[fnIndex];
const query = fn.function.replace(MARKER, '');
@ -205,7 +258,7 @@ function getArgNameSuggestions(
fnIndex: number,
argName: string,
argIndex: number
) {
): ArgSuggestion[] {
// Get the list of args from the function definition
const fn = ast.node.chain[fnIndex].node;
const fnDef = getByAlias(specs, fn.function);
@ -218,7 +271,7 @@ function getArgNameSuggestions(
// Filter the list of args by the text at the marker
const query = text.replace(MARKER, '');
const matchingArgDefs = Object.entries<ArgValue>(fnDef.args).filter(([name]) =>
const matchingArgDefs = Object.entries<CanvasArgValue>(fnDef.args).filter(([name]) =>
textMatches(name, query)
);
@ -245,11 +298,11 @@ function getArgNameSuggestions(
// with the text at the marker, then alphabetically
const comparator = combinedComparator(
unnamedArgComparator,
invokeWithProp<string, 'name', ArgValue & { name: string }, number>(
invokeWithProp<string, 'name', CanvasArgValue & { name: string }, number>(
startsWithComparator(query),
'name'
),
invokeWithProp<string, 'name', ArgValue & { name: string }, number>(
invokeWithProp<string, 'name', CanvasArgValue & { name: string }, number>(
alphanumericalComparator,
'name'
)
@ -267,7 +320,7 @@ function getArgValueSuggestions(
fnIndex: number,
argName: string,
argIndex: number
) {
): ValueSuggestion[] {
// Get the list of values from the argument definition
const fn = ast.node.chain[fnIndex].node;
const fnDef = getByAlias(specs, fn.function);
@ -331,7 +384,7 @@ function prevFnTypeComparator(prevFnType: any) {
};
}
function unnamedArgComparator(a: ArgValue, b: ArgValue): number {
function unnamedArgComparator(a: CanvasArgValue, b: CanvasArgValue): number {
return (
(b.aliases && b.aliases.includes('_') ? 1 : 0) - (a.aliases && a.aliases.includes('_') ? 1 : 0)
);

View file

@ -16,8 +16,6 @@ export const API_ROUTE_WORKPAD_STRUCTURES = `${API_ROUTE}/workpad-structures`;
export const API_ROUTE_CUSTOM_ELEMENT = `${API_ROUTE}/custom-element`;
export const LOCALSTORAGE_PREFIX = `kibana.canvas`;
export const LOCALSTORAGE_CLIPBOARD = `${LOCALSTORAGE_PREFIX}.clipboard`;
export const LOCALSTORAGE_AUTOCOMPLETE_ENABLED = `${LOCALSTORAGE_PREFIX}.isAutocompleteEnabled`;
export const LOCALSTORAGE_EXPRESSION_EDITOR_FONT_SIZE = `${LOCALSTORAGE_PREFIX}.expressionEditorFontSize`;
export const LOCALSTORAGE_LASTPAGE = 'canvas:lastpage';
export const FETCH_TIMEOUT = 30000; // 30 seconds
export const CANVAS_USAGE_TYPE = 'canvas';

View file

@ -12,6 +12,7 @@ $canvasLayoutFontSize: $euiFontSizeS;
flex-direction: column;
flex-grow: 1;
max-height: 100vh;
max-width: 100%;
}
.canvasLayout__cols {

View file

@ -12,6 +12,7 @@ import { getInterpreter } from 'plugins/interpreter/interpreter';
import { getAppReady, getBasePath } from '../../state/selectors/app';
import { appReady, appError } from '../../state/actions/app';
import { elementsRegistry } from '../../lib/elements_registry';
import { registerLanguage } from '../../lib/monaco_language_def';
import { templatesRegistry } from '../../lib/templates_registry';
import { tagsRegistry } from '../../lib/tags_registry';
import { elementSpecs } from '../../../canvas_plugin_src/elements';
@ -72,6 +73,9 @@ const mapDispatchToProps = dispatch => ({
try {
await getInterpreter();
// Register the expression language with the Monaco Editor
registerLanguage();
// set app state to ready
dispatch(appReady());
} catch (e) {

View file

@ -254,7 +254,7 @@ export class Autocomplete extends React.Component {
) : (
''
)}
<div className="canvasAutocomplete--inner" onMouseDown={this.onMouseDown}>
<div className="canvasAutocomplete__inner" onMouseDown={this.onMouseDown}>
{this.props.children}
</div>
</div>

View file

@ -0,0 +1,13 @@
# Editor Component
This re-usable code editor component was built as a layer of abstraction on top of the [Monaco Code Editor](https://microsoft.github.io/monaco-editor/) (and the [React Monaco Editor component](https://github.com/react-monaco-editor/react-monaco-editor)). The goal of this component is to expose a set of the most-used, most-helpful features from Monaco in a way that's easy to use out of the box. If a use case requires additional features, this component still allows access to all other Monaco features.
This editor component allows easy access to:
* [Syntax highlighting (including custom language highlighting)](https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages)
* [Suggestion/autocompletion widget](https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-completion-provider-example)
* Function signature widget
* [Hover widget](https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-hover-provider-example)
[_TODO: Examples of each_](https://github.com/elastic/kibana/issues/43812)
The Monaco editor doesn't automatically resize the editor area on window or container resize so this component includes a [resize detector](https://github.com/maslianok/react-resize-detector) to cause the Monaco editor to re-layout and adjust its size when the window or container size changes

View file

@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots components/Editor custom log language 1`] = `
<div>
<div
className="react-monaco-editor-container"
style={
Object {
"height": "250px",
"width": "100%",
}
}
/>
<div />
</div>
`;
exports[`Storyshots components/Editor default 1`] = `
<div>
<div
className="react-monaco-editor-container"
style={
Object {
"height": "250px",
"width": "100%",
}
}
/>
<div />
</div>
`;
exports[`Storyshots components/Editor html 1`] = `
<div>
<div
className="react-monaco-editor-container"
style={
Object {
"height": "250px",
"width": "100%",
}
}
/>
<div />
</div>
`;

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import React from 'react';
import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api';
import { Editor } from '../editor';
import 'monaco-editor/esm/vs/basic-languages/html/html.contribution.js';
// A sample language definition with a few example tokens
const simpleLogLang: monacoEditor.languages.IMonarchLanguage = {
tokenizer: {
root: [
[/\[error.*/, 'constant'],
[/\[notice.*/, 'variable'],
[/\[info.*/, 'string'],
[/\[[a-zA-Z 0-9:]+\]/, 'tag'],
],
},
};
monacoEditor.languages.register({ id: 'loglang' });
monacoEditor.languages.setMonarchTokensProvider('loglang', simpleLogLang);
const logs = `
[Sun Mar 7 20:54:27 2004] [notice] [client xx.xx.xx.xx] This is a notice!
[Sun Mar 7 20:58:27 2004] [info] [client xx.xx.xx.xx] (104)Connection reset by peer: client stopped connection before send body completed
[Sun Mar 7 21:16:17 2004] [error] [client xx.xx.xx.xx] File does not exist: /home/httpd/twiki/view/Main/WebHome
`;
const html = `<section>
<span>Hello World!</span>
</section>`;
storiesOf('components/Editor', module)
.add('default', () => (
<div>
<Editor languageId="plaintext" height={250} value="Hello!" onChange={action('onChange')} />
</div>
))
.add('html', () => (
<div>
<Editor languageId="html" height={250} value={html} onChange={action('onChange')} />
</div>
))
.add('custom log language', () => (
<div>
<Editor languageId="loglang" height={250} value={logs} onChange={action('onChange')} />
</div>
));

View file

@ -0,0 +1,3 @@
.react-monaco-editor-container .monaco-editor .inputarea:focus {
animation: none; // Removes textarea EUI blue underline animation from EUI
}

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import ReactResizeDetector from 'react-resize-detector';
import MonacoEditor, { EditorDidMount, EditorWillMount } from 'react-monaco-editor';
import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/base/common/worker/simpleWorker';
import 'monaco-editor/esm/vs/base/worker/defaultWorkerFactory';
import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js';
import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js';
import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js'; // Needed for suggestions
import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover
import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature
import { theme } from './editor_theme';
interface Props {
/** Width of editor. Defaults to 100%. */
width?: string | number;
/** Height of editor. Defaults to 100%. */
height?: string | number;
/** ID of the editor language */
languageId: string;
/** Value of the editor */
value: string;
/** Function invoked when text in editor is changed */
onChange: (value: string) => void;
/**
* Options for the Monaco Code Editor
* Documentation of options can be found here:
* https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.ieditorconstructionoptions.html
*/
options?: monacoEditor.editor.IEditorConstructionOptions;
/**
* Suggestion provider for autocompletion
* Documentation for the provider can be found here:
* https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.completionitemprovider.html
*/
suggestionProvider?: monacoEditor.languages.CompletionItemProvider;
/**
* Signature provider for function parameter info
* Documentation for the provider can be found here:
* https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.signaturehelpprovider.html
*/
signatureProvider?: monacoEditor.languages.SignatureHelpProvider;
/**
* Hover provider for hover documentation
* Documentation for the provider can be found here:
* https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.hoverprovider.html
*/
hoverProvider?: monacoEditor.languages.HoverProvider;
/**
* Function called before the editor is mounted in the view
*/
editorWillMount?: EditorWillMount;
/**
* Function called before the editor is mounted in the view
* and completely replaces the setup behavior called by the component
*/
overrideEditorWillMount?: EditorWillMount;
/**
* Function called after the editor is mounted in the view
*/
editorDidMount?: EditorDidMount;
}
export class Editor extends React.Component<Props, {}> {
_editor: monacoEditor.editor.IStandaloneCodeEditor | null = null;
_editorWillMount = (monaco: typeof monacoEditor) => {
if (this.props.overrideEditorWillMount) {
this.props.overrideEditorWillMount(monaco);
return;
}
if (this.props.editorWillMount) {
this.props.editorWillMount(monaco);
}
monaco.languages.onLanguage(this.props.languageId, () => {
if (this.props.suggestionProvider) {
monaco.languages.registerCompletionItemProvider(
this.props.languageId,
this.props.suggestionProvider
);
}
if (this.props.signatureProvider) {
monaco.languages.registerSignatureHelpProvider(
this.props.languageId,
this.props.signatureProvider
);
}
if (this.props.hoverProvider) {
monaco.languages.registerHoverProvider(this.props.languageId, this.props.hoverProvider);
}
});
// Register the theme
monaco.editor.defineTheme('euiColors', theme);
};
_editorDidMount = (
editor: monacoEditor.editor.IStandaloneCodeEditor,
monaco: typeof monacoEditor
) => {
this._editor = editor;
if (this.props.editorDidMount) {
this.props.editorDidMount(editor, monaco);
}
};
render() {
const { languageId, value, onChange, width, height, options } = this.props;
return (
<React.Fragment>
<MonacoEditor
theme="euiColors"
language={languageId}
value={value}
onChange={onChange}
editorWillMount={this._editorWillMount}
editorDidMount={this._editorDidMount}
width={width}
height={height}
options={options}
/>
<ReactResizeDetector handleWidth handleHeight onResize={this._updateDimensions} />
</React.Fragment>
);
}
_updateDimensions = () => {
if (this._editor) {
this._editor.layout();
}
};
}

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api';
import darkTheme from '@elastic/eui/dist/eui_theme_dark.json';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
import chrome from 'ui/chrome';
// NOTE: For talk around where this theme information will ultimately live,
// please see this discuss issue: https://github.com/elastic/kibana/issues/43814
const IS_DARK_THEME = chrome.getUiSettingsClient().get('theme:darkMode');
const themeName = IS_DARK_THEME ? darkTheme : lightTheme;
const themeColors = {
keyword: themeName.euiColorAccent,
comment: themeName.euiColorDarkShade,
delimiter: themeName.euiColorSecondary,
string: themeName.euiColorPrimary,
number: themeName.euiColorWarning,
regexp: themeName.euiColorPrimary,
types: `${IS_DARK_THEME ? themeName.euiColorVis5 : themeName.euiColorVis9}`,
annotation: themeName.euiColorLightShade,
tag: themeName.euiColorAccent,
symbol: themeName.euiColorDanger,
foreground: themeName.euiColorDarkestShade,
editorBackground: themeName.euiColorEmptyShade,
lineNumbers: themeName.euiColorDarkShade,
editorIndentGuide: themeName.euiColorLightShade,
selectionBackground: `${IS_DARK_THEME ? '#343551' : '#E3E4ED'}`,
editorWidgetBackground: themeName.euiColorLightestShade,
editorWidgetBorder: themeName.euiColorLightShade,
findMatchBackground: themeName.euiColorWarning,
findMatchHighlightBackground: themeName.euiColorWarning,
};
export const theme: monacoEditor.editor.IStandaloneThemeData = {
base: 'vs',
inherit: true,
rules: [
{
token: '',
foreground: themeName.euiColorDarkestShade,
background: themeName.euiColorEmptyShade,
},
{ token: 'invalid', foreground: themeName.euiColorAccent },
{ token: 'emphasis', fontStyle: 'italic' },
{ token: 'strong', fontStyle: 'bold' },
{ token: 'variable', foreground: themeName.euiColorPrimary },
{ token: 'variable.predefined', foreground: themeName.euiColorSecondary },
{ token: 'constant', foreground: themeName.euiColorAccent },
{ token: 'comment', foreground: themeName.euiColorMediumShade },
{ token: 'number', foreground: themeName.euiColorWarning },
{ token: 'number.hex', foreground: themeName.euiColorPrimary },
{ token: 'regexp', foreground: themeName.euiColorDanger },
{ token: 'annotation', foreground: themeName.euiColorMediumShade },
{ token: 'type', foreground: themeName.euiColorVis0 },
{ token: 'delimiter', foreground: themeName.euiColorDarkestShade },
{ token: 'delimiter.html', foreground: themeName.euiColorDarkShade },
{ token: 'delimiter.xml', foreground: themeName.euiColorPrimary },
{ token: 'tag', foreground: themeName.euiColorDanger },
{ token: 'tag.id.jade', foreground: themeName.euiColorPrimary },
{ token: 'tag.class.jade', foreground: themeName.euiColorPrimary },
{ token: 'meta.scss', foreground: themeName.euiColorAccent },
{ token: 'metatag', foreground: themeName.euiColorSecondary },
{ token: 'metatag.content.html', foreground: themeName.euiColorDanger },
{ token: 'metatag.html', foreground: themeName.euiColorMediumShade },
{ token: 'metatag.xml', foreground: themeName.euiColorMediumShade },
{ token: 'metatag.php', fontStyle: 'bold' },
{ token: 'key', foreground: themeName.euiColorWarning },
{ token: 'string.key.json', foreground: themeName.euiColorDanger },
{ token: 'string.value.json', foreground: themeName.euiColorPrimary },
{ token: 'attribute.name', foreground: themeName.euiColorDanger },
{ token: 'attribute.name.css', foreground: themeName.euiColorSecondary },
{ token: 'attribute.value', foreground: themeName.euiColorPrimary },
{ token: 'attribute.value.number', foreground: themeName.euiColorWarning },
{ token: 'attribute.value.unit', foreground: themeName.euiColorWarning },
{ token: 'attribute.value.html', foreground: themeName.euiColorPrimary },
{ token: 'attribute.value.xml', foreground: themeName.euiColorPrimary },
{ token: 'string', foreground: themeName.euiColorDanger },
{ token: 'string.html', foreground: themeName.euiColorPrimary },
{ token: 'string.sql', foreground: themeName.euiColorDanger },
{ token: 'string.yaml', foreground: themeName.euiColorPrimary },
{ token: 'keyword', foreground: themeName.euiColorPrimary },
{ token: 'keyword.json', foreground: themeName.euiColorPrimary },
{ token: 'keyword.flow', foreground: themeName.euiColorWarning },
{ token: 'keyword.flow.scss', foreground: themeName.euiColorPrimary },
{ token: 'operator.scss', foreground: themeName.euiColorDarkShade },
{ token: 'operator.sql', foreground: themeName.euiColorMediumShade },
{ token: 'operator.swift', foreground: themeName.euiColorMediumShade },
{ token: 'predefined.sql', foreground: themeName.euiColorMediumShade },
],
colors: {
'editor.foreground': themeColors.foreground,
'editor.background': themeColors.editorBackground,
'editorLineNumber.foreground': themeColors.lineNumbers,
'editorLineNumber.activeForeground': themeColors.lineNumbers,
'editorIndentGuide.background': themeColors.editorIndentGuide,
'editor.selectionBackground': themeColors.selectionBackground,
'editorWidget.border': themeColors.editorWidgetBorder,
'editorWidget.background': themeColors.editorWidgetBackground,
},
};

View file

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

View file

@ -10,12 +10,11 @@ import {
EuiPanel,
EuiButton,
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
EuiRange,
EuiToolTip,
EuiLink,
EuiPortal,
} from '@elastic/eui';
import { Shortcuts } from 'react-shortcuts';
import { ExpressionInput } from '../expression_input';
@ -23,9 +22,6 @@ import { ToolTipShortcut } from '../tool_tip_shortcut';
const { useRef } = React;
const minFontSize = 12;
const maxFontSize = 32;
const shortcut = (ref, cmd, callback) => (
<Shortcuts
name="EXPRESSION"
@ -48,112 +44,100 @@ export const Expression = ({
setExpression,
done,
error,
isAutocompleteEnabled,
toggleAutocompleteEnabled,
fontSize,
setFontSize,
isCompact,
toggleCompactView,
}) => {
const refExpressionInput = useRef(null);
return (
const handleRun = () => {
setExpression(formState.expression);
// If fullScreen and you hit run, toggle back down so you can see your work
if (!isCompact && !error) {
toggleCompactView();
}
};
const expressionPanel = (
<EuiPanel
className={`canvasTray__panel canvasExpression--${isCompact ? 'compactSize' : 'fullSize'}`}
className={`canvasTray__panel canvasTray__panel--holdingExpression canvasExpression--${
isCompact ? 'compactSize' : 'fullSize'
}`}
paddingSize="none"
>
{shortcut(refExpressionInput, 'RUN', () => {
if (!error) {
setExpression(formState.expression);
}
})}
{/* Error code below is to pass a non breaking space so the editor does not jump */}
<ExpressionInput
ref={refExpressionInput}
fontSize={fontSize}
isCompact={isCompact}
functionDefinitions={functionDefinitions}
error={error}
error={error ? error : `\u00A0`}
value={formState.expression}
onChange={updateValue}
isAutocompleteEnabled={isAutocompleteEnabled}
/>
<div className="canvasExpression--controls">
<EuiToolTip content={isCompact ? 'Maximize' : 'Minimize'}>
<EuiButtonIcon
size="s"
onClick={toggleCompactView}
iconType="expand"
color="subdued"
aria-label="Toggle expression window height"
/>
</EuiToolTip>
</div>
<EuiFlexGroup
className="canvasExpression--settings"
justifyContent="spaceBetween"
alignItems="center"
gutterSize="l"
>
<EuiFlexItem grow={false}>
<EuiSwitch
id="autocompleteOptIn"
name="popswitch"
label="Enable autocomplete"
checked={isAutocompleteEnabled}
onChange={toggleAutocompleteEnabled}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem style={{ fontSize: `${minFontSize}px` }} grow={false}>
A
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiRange
value={fontSize}
min={minFontSize}
step={4}
max={maxFontSize}
onChange={e => setFontSize(e.target.value)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ fontSize: `${maxFontSize}px` }}>
A
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
color={formState.dirty ? 'danger' : 'primary'}
onClick={done}
>
{formState.dirty ? 'Cancel' : 'Close'}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
<span>
Run the expression <ToolTipShortcut namespace="EXPRESSION" action="RUN" />
</span>
}
>
<EuiButton
fill
disabled={!!error}
onClick={() => setExpression(formState.expression)}
size="s"
<div className="canvasExpression__settings">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiToolTip
content={
<span>
Run the expression <ToolTipShortcut namespace="EXPRESSION" action="RUN" />
</span>
}
>
Run
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiButton fill disabled={!!error} onClick={handleRun} size="s">
Run
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
color={formState.dirty ? 'danger' : 'primary'}
onClick={done}
>
{formState.dirty ? 'Cancel' : 'Close'}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiLink
href="https://www.elastic.co/guide/en/kibana/current/canvas-function-reference.html"
target="_blank"
>
Learn expression syntax
</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="fullScreen" onClick={toggleCompactView} size="xs">
{isCompact ? 'Maximize' : 'Minimize'} editor
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</EuiPanel>
);
if (isCompact) {
return expressionPanel;
} else {
// Portal is required to show above the navigation
return <EuiPortal>{expressionPanel}</EuiPortal>;
}
};
Expression.propTypes = {
@ -163,6 +147,4 @@ Expression.propTypes = {
setExpression: PropTypes.func,
done: PropTypes.func,
error: PropTypes.string,
isAutocompleteEnabled: PropTypes.bool,
toggleAutocompleteEnabled: PropTypes.func,
};

View file

@ -1,56 +1,47 @@
.canvasExpressionInput {
// Hack needed because weird editor layout
.euiFormErrorText {
padding-left: $euiSize;
}
}
.canvasExpression--compactSize {
max-height: 480px;
}
.canvasExpression--controls {
position: absolute;
right: $euiSizeXL;
top: $euiSizeL;
.canvasExpressionInput__editor {
height: $euiSize * 16;
padding-top: $euiSize;
flex-grow: 1;
}
.canvasExpression--fullSize {
height: calc(100vh - 200px); // space for global nav and autocomplete popup
display: flex;
flex-direction: column;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: $euiZMask - 1;
.expressionInput {
.canvasExpressionInput {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.expressionInput--inner {
.canvasExpressionInput__inner {
flex-grow: 1;
display: flex;
}
.canvasExpression--settings {
flex-grow: 0;
}
.canvasTextArea--code {
.canvasTextArea__code {
flex-grow: 1;
padding-right: $euiSizeXXL;
}
.autocomplete {
flex-grow: 1;
display: flex;
flex-direction: column;
.canvasAutocomplete--inner {
flex-grow: 1;
display: flex;
flex-direction: column;
}
}
.autocompletePopup {
top: -102px;
height: 100px;
}
.autocompleteItems,
.autocompleteReference {
height: 98px;
}
}
.canvasExpression__settings {
padding: 0 $euiSize $euiSize;
}

View file

@ -15,20 +15,12 @@ import {
renderComponent,
} from 'recompose';
import { fromExpression } from '@kbn/interpreter/common';
import { Storage } from 'ui/storage';
import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad';
import { setExpression, flushContext } from '../../state/actions/elements';
import { getFunctionDefinitions } from '../../lib/function_definitions';
import { getWindow } from '../../lib/get_window';
import {
LOCALSTORAGE_AUTOCOMPLETE_ENABLED,
LOCALSTORAGE_EXPRESSION_EDITOR_FONT_SIZE,
} from '../../../common/lib/constants';
import { ElementNotSelected } from './element_not_selected';
import { Expression as Component } from './expression';
const storage = new Storage(getWindow().localStorage);
const mapStateToProps = state => ({
pageId: getSelectedPage(state),
element: getSelectedElement(state),
@ -88,20 +80,8 @@ export const Expression = compose(
expression,
dirty: false,
})),
withState('isAutocompleteEnabled', 'setIsAutocompleteEnabled', () => {
const setting = storage.get(LOCALSTORAGE_AUTOCOMPLETE_ENABLED);
return setting === null ? true : setting;
}),
withState('fontSize', 'setFontSize', () => {
const fontSize = storage.get(LOCALSTORAGE_EXPRESSION_EDITOR_FONT_SIZE);
return fontSize === null ? 16 : fontSize;
}),
withState('isCompact', 'setCompact', true),
withHandlers({
toggleAutocompleteEnabled: ({ isAutocompleteEnabled, setIsAutocompleteEnabled }) => () => {
storage.set(LOCALSTORAGE_AUTOCOMPLETE_ENABLED, !isAutocompleteEnabled);
setIsAutocompleteEnabled(!isAutocompleteEnabled);
},
toggleCompactView: ({ isCompact, setCompact }) => () => {
setCompact(!isCompact);
},
@ -118,10 +98,6 @@ export const Expression = compose(
}));
setExpression(exp);
},
setFontSize: ({ setFontSize }) => size => {
storage.set(LOCALSTORAGE_EXPRESSION_EDITOR_FONT_SIZE, size);
setFontSize(size);
},
}),
expressionLifecycle,
withPropsOnChange(['formState'], ({ formState }) => ({

View file

@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots components/ExpressionInput default 1`] = `
<div
className="canvasExpressionInput"
>
<div
className="euiFormRow euiFormRow--fullWidth canvasExpressionInput__inner"
id="generated-id-row"
>
<div
className="canvasExpressionInput__editor"
id="generated-id"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="react-monaco-editor-container"
style={
Object {
"height": "100%",
"width": "100%",
}
}
/>
<div />
</div>
</div>
</div>
`;

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { ExpressionInput } from '../expression_input';
import { language, LANGUAGE_ID } from '../../../lib/monaco_language_def';
const sampleFunctionDef = {
name: 'markdown',
type: 'render',
aliases: [],
help:
'Adds an element that renders Markdown text. TIP: Use the `markdown` function for single numbers, metrics, and paragraphs of text.',
args: {
content: {
name: 'content',
required: false,
help:
'A string of text that contains Markdown. To concatenate, pass the `string` function multiple times.',
types: ['string'],
default: '""',
aliases: ['_', 'expression'],
multi: true,
resolve: false,
options: [],
},
font: {
name: 'font',
required: false,
help: 'The CSS font properties for the content. For example, font-family or font-weight.',
types: ['style'],
default: '{font}',
aliases: [],
multi: false,
resolve: true,
options: [],
},
},
context: {
types: ['datatable', 'null'],
},
fn: () => {
return true;
},
};
language.keywords = [sampleFunctionDef.name];
monaco.languages.register({ id: LANGUAGE_ID });
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, language);
storiesOf('components/ExpressionInput', module).add('default', () => (
<ExpressionInput
value="markdown"
isCompact={true}
onChange={action('onChange')}
functionDefinitions={[sampleFunctionDef as any]}
/>
));

View file

@ -1,50 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import Markdown from 'markdown-it';
import { EuiTitle, EuiText, EuiSpacer, EuiDescriptionList } from '@elastic/eui';
const md = new Markdown();
export const ArgumentReference = ({ argDef }) => (
<div>
<EuiTitle size="xs">
<h3>{argDef.name}</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText dangerouslySetInnerHTML={getHelp(argDef)} />
<EuiSpacer size="s" />
<EuiDescriptionList type="inline" compressed listItems={getArgListItems(argDef)} />
</div>
);
function getHelp(argDef) {
return { __html: md.render(argDef.help) };
}
function getArgListItems(argDef) {
const { aliases, types, default: def, required } = argDef;
const items = [];
if (aliases.length) {
items.push({ title: 'Aliases', description: aliases.join(', ') });
}
if (types.length) {
items.push({ title: 'Types', description: types.join(', ') });
}
if (def != null) {
items.push({ title: 'Default', description: def });
}
items.push({ title: 'Required', description: String(Boolean(required)) });
return items;
}
ArgumentReference.propTypes = {
argDef: PropTypes.object,
};

View file

@ -1,223 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { EuiTextArea, EuiFormRow, EuiTitle } from '@elastic/eui';
import { debounce, startCase } from 'lodash';
import { Autocomplete } from '../autocomplete';
import {
getAutocompleteSuggestions,
getFnArgDefAtPosition,
} from '../../../common/lib/autocomplete';
import { FunctionReference } from './function_reference';
import { ArgumentReference } from './argument_reference';
export class ExpressionInput extends React.Component {
constructor({ value }) {
super();
this.undoHistory = [];
this.redoHistory = [];
this.state = {
selection: {
start: value.length,
end: value.length,
},
suggestions: [],
};
}
componentDidUpdate() {
if (!this.ref) {
return;
}
const { selection } = this.state;
const { start, end } = selection;
this.ref.setSelectionRange(start, end);
}
undo() {
if (!this.undoHistory.length) {
return;
}
const value = this.undoHistory.pop();
this.redoHistory.push(this.props.value);
this.props.onChange(value);
}
redo() {
if (!this.redoHistory.length) {
return;
}
const value = this.redoHistory.pop();
this.undoHistory.push(this.props.value);
this.props.onChange(value);
}
getSelection() {
if (!this.ref) {
return null;
}
const start = this.ref.selectionStart;
const finish = this.ref.selectionEnd;
return this.ref.value.substring(start, finish);
}
stash = debounce(
value => {
this.undoHistory.push(value);
this.redoHistory = [];
},
500,
{ leading: true, trailing: false }
);
onKeyDown = e => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
this.redo();
} else {
this.undo();
}
}
if (e.key === 'y') {
e.preventDefault();
this.redo();
}
}
};
onSuggestionSelect = item => {
const { text, start, end } = item;
const value = this.props.value.substr(0, start) + text + this.props.value.substr(end);
const selection = { start: start + text.length, end: start + text.length };
this.updateState({ value, selection });
// This is needed for when the suggestion was selected by clicking on it
this.ref.focus();
};
onChange = e => {
const { target } = e;
const { value, selectionStart, selectionEnd } = target;
const selection = {
start: selectionStart,
end: selectionEnd,
};
this.updateState({ value, selection });
};
updateState = ({ value, selection }) => {
this.stash(this.props.value);
const suggestions = getAutocompleteSuggestions(
this.props.functionDefinitions,
value,
selection.start
);
this.props.onChange(value);
this.setState({ selection, suggestions });
};
getHeader = () => {
const { suggestions } = this.state;
if (!suggestions.length) {
return '';
}
return (
<EuiTitle className="autocompleteType" size="xs">
<h3>{startCase(suggestions[0].type)}</h3>
</EuiTitle>
);
};
getReference = selectedItem => {
const { fnDef, argDef } = selectedItem || {};
if (argDef) {
return <ArgumentReference argDef={argDef} />;
}
if (fnDef) {
return <FunctionReference fnDef={fnDef} />;
}
const { fnDef: fnDefAtPosition, argDef: argDefAtPosition } = getFnArgDefAtPosition(
this.props.functionDefinitions,
this.props.value,
this.state.selection.start
);
if (argDefAtPosition) {
return <ArgumentReference argDef={argDefAtPosition} />;
}
if (fnDefAtPosition) {
return <FunctionReference fnDef={fnDefAtPosition} />;
}
return '';
};
render() {
const { value, error, isAutocompleteEnabled, fontSize } = this.props;
const { suggestions } = this.state;
const helpText = error
? null
: 'This is the coded expression that backs this element. You better know what you are doing here.';
return (
<div className="expressionInput">
<EuiFormRow
className="expressionInput--inner"
fullWidth
isInvalid={Boolean(error)}
error={error}
helpText={helpText}
>
{isAutocompleteEnabled ? (
<Autocomplete
header={this.getHeader()}
items={suggestions}
onSelect={this.onSuggestionSelect}
reference={this.getReference}
>
<EuiTextArea
onKeyDown={this.onKeyDown}
className="canvasTextArea--code"
value={value}
onChange={this.onChange}
inputRef={ref => (this.ref = ref)}
spellCheck="false"
style={{ fontSize: `${fontSize}px` }}
resize="none"
/>
</Autocomplete>
) : (
<EuiTextArea
onKeyDown={this.onKeyDown}
className="canvasTextArea--code"
value={value}
onChange={this.onChange}
inputRef={ref => (this.ref = ref)}
spellCheck="false"
style={{ fontSize: `${fontSize}px` }}
resize="none"
/>
)}
</EuiFormRow>
</div>
);
}
}
ExpressionInput.propTypes = {
functionDefinitions: PropTypes.array,
value: PropTypes.string,
onChange: PropTypes.func,
error: PropTypes.string,
isAutocompleteEnabled: PropTypes.bool,
};

View file

@ -0,0 +1,280 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { EuiFormRow } from '@elastic/eui';
import { debounce } from 'lodash';
import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api';
import { Editor } from '../editor';
import { CanvasFunction } from '../../../types';
import {
AutocompleteSuggestion,
getAutocompleteSuggestions,
getFnArgDefAtPosition,
} from '../../../common/lib/autocomplete';
import { LANGUAGE_ID } from '../../lib/monaco_language_def';
import { getFunctionReferenceStr, getArgReferenceStr } from './reference';
interface Props {
/** Font size of text within the editor */
/** Canvas function defintions */
functionDefinitions: CanvasFunction[];
/** Optional string for displaying error messages */
error?: string;
/** Value of expression */
value: string;
/** Function invoked when expression value is changed */
onChange: (value?: string) => void;
/** In full screen mode or not */
isCompact: boolean;
}
export class ExpressionInput extends React.Component<Props> {
static propTypes = {
functionDefinitions: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
error: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
undoHistory: string[];
redoHistory: string[];
constructor(props: Props) {
super(props);
this.undoHistory = [];
this.redoHistory = [];
}
undo() {
if (!this.undoHistory.length) {
return;
}
const value = this.undoHistory.pop();
this.redoHistory.push(this.props.value);
this.props.onChange(value);
}
redo() {
if (!this.redoHistory.length) {
return;
}
const value = this.redoHistory.pop();
this.undoHistory.push(this.props.value);
this.props.onChange(value);
}
stash = debounce(
(value: string) => {
this.undoHistory.push(value);
this.redoHistory = [];
},
500,
{ leading: true, trailing: false }
);
onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
this.redo();
} else {
this.undo();
}
}
if (e.key === 'y') {
e.preventDefault();
this.redo();
}
}
};
onChange = (value: string) => {
this.updateState({ value });
};
updateState = ({ value }: { value: string }) => {
this.stash(this.props.value);
this.props.onChange(value);
};
provideSuggestions = (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => {
const text = model.getValue();
const textRange = model.getFullModelRange();
const lengthAfterPosition = model.getValueLengthInRange({
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: textRange.endLineNumber,
endColumn: textRange.endColumn,
});
const wordUntil = model.getWordUntilPosition(position);
const wordRange = new monacoEditor.Range(
position.lineNumber,
wordUntil.startColumn,
position.lineNumber,
wordUntil.endColumn
);
const aSuggestions = getAutocompleteSuggestions(
this.props.functionDefinitions,
text,
text.length - lengthAfterPosition
);
const suggestions = aSuggestions.map((s: AutocompleteSuggestion) => {
if (s.type === 'argument') {
return {
label: s.argDef.name,
kind: monacoEditor.languages.CompletionItemKind.Field,
documentation: { value: getArgReferenceStr(s.argDef), isTrusted: true },
insertText: s.text,
command: {
title: 'Trigger Suggestion Dialog',
id: 'editor.action.triggerSuggest',
},
range: wordRange,
};
} else if (s.type === 'value') {
return {
label: s.text,
kind: monacoEditor.languages.CompletionItemKind.Value,
insertText: s.text,
command: {
title: 'Trigger Suggestion Dialog',
id: 'editor.action.triggerSuggest',
},
range: wordRange,
};
} else {
return {
label: s.fnDef.name,
kind: monacoEditor.languages.CompletionItemKind.Function,
documentation: {
value: getFunctionReferenceStr(s.fnDef),
isTrusted: true,
},
insertText: s.text,
command: {
title: 'Trigger Suggestion Dialog',
id: 'editor.action.triggerSuggest',
},
range: wordRange,
};
}
});
return {
suggestions,
};
};
providerHover = (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => {
const text = model.getValue();
const word = model.getWordAtPosition(position);
if (!word) {
return {
contents: [],
};
}
const absPosition = model.getValueLengthInRange({
startLineNumber: 0,
startColumn: 0,
endLineNumber: position.lineNumber,
endColumn: word.endColumn,
});
const { fnDef, argDef, argStart, argEnd } = getFnArgDefAtPosition(
this.props.functionDefinitions,
text,
absPosition
);
if (argDef && argStart && argEnd) {
// Use the start/end position of the arg to generate a complete range to highlight
// that includes the arg name and its complete value
const startPos = model.getPositionAt(argStart);
const endPos = model.getPositionAt(argEnd);
const argRange = new monacoEditor.Range(
startPos.lineNumber,
startPos.column,
endPos.lineNumber,
endPos.column
);
return {
contents: [{ value: getArgReferenceStr(argDef), isTrusted: true }],
range: argRange,
};
} else if (fnDef) {
return {
contents: [
{
value: getFunctionReferenceStr(fnDef),
isTrusted: true,
},
],
};
}
return {
contents: [],
};
};
render() {
const { value, error, isCompact } = this.props;
return (
<div className="canvasExpressionInput">
<EuiFormRow
className="canvasExpressionInput__inner"
fullWidth
isInvalid={Boolean(error)}
error={error}
>
<div className="canvasExpressionInput__editor">
<Editor
languageId={LANGUAGE_ID}
value={value}
onChange={this.onChange}
suggestionProvider={{
triggerCharacters: [' '],
provideCompletionItems: this.provideSuggestions,
}}
hoverProvider={{
provideHover: this.providerHover,
}}
options={{
fontSize: isCompact ? 12 : 16,
scrollBeyondLastLine: false,
quickSuggestions: true,
minimap: {
enabled: false,
},
wordBasedSuggestions: false,
}}
/>
</div>
</EuiFormRow>
</div>
);
}
}

View file

@ -1,82 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import Markdown from 'markdown-it';
import { EuiTitle, EuiText, EuiSpacer, EuiBasicTable, EuiDescriptionList } from '@elastic/eui';
import { startCase } from 'lodash';
const md = new Markdown();
export const FunctionReference = ({ fnDef }) => (
<div>
<EuiTitle size="xs">
<h3>{fnDef.name}</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText dangerouslySetInnerHTML={getHelp(fnDef)} />
<EuiSpacer size="m" />
<EuiDescriptionList
type="inline"
className="autocompleteDescList"
compressed
listItems={getFnListItems(fnDef)}
/>
<EuiSpacer size="m" />
<EuiBasicTable
className="autocompleteTable"
items={getArgItems(fnDef.args)}
columns={getArgColumns()}
/>
</div>
);
function getHelp(fnDef) {
return { __html: md.render(fnDef.help) };
}
function getFnListItems(fnDef) {
const { aliases, context, type } = fnDef;
const items = [];
if (aliases.length) {
items.push({ title: 'Aliases', description: aliases.join(', ') });
}
if (context.types) {
items.push({ title: 'Accepts', description: context.types.join(', ') });
}
if (type) {
items.push({ title: 'Returns', description: type });
}
return items;
}
function getArgItems(args) {
return Object.entries(args).map(([name, argDef]) => ({
argument: name + (argDef.required ? '*' : ''),
aliases: (argDef.aliases || []).join(', '),
types: (argDef.types || []).join(', '),
default: argDef.default || '',
description: argDef.help || '',
}));
}
function getArgColumns() {
return ['argument', 'aliases', 'types', 'default', 'description'].map(field => {
const column = { field, name: startCase(field), truncateText: field !== 'description' };
if (field === 'description') {
column.width = '50%';
}
return column;
});
}
FunctionReference.propTypes = {
fnDef: PropTypes.object,
};

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CanvasFunction, CanvasArgValue } from '../../../types';
/**
* Given a function definition, this function returns a markdown string
* that includes the context the function accepts, what the function returns
* as well as the general help/documentation text associated with the function
*/
export function getFunctionReferenceStr(fnDef: CanvasFunction) {
const { help, context, type } = fnDef;
const doc = `**Accepts**: ${
context && context.types ? context.types.join(' | ') : 'null'
}, **Returns**: ${type ? type : 'null'}
\n\n${help}`;
return doc;
}
/**
* Given an argument defintion, this function returns a markdown string
* that includes the aliases of the argument, types accepted for the argument,
* the default value of the argument, whether or not its required, and
* the general help/documentation text associated with the argument
*/
export function getArgReferenceStr(argDef: CanvasArgValue) {
const { aliases, types, default: def, required, help } = argDef;
const ref = `**Aliases**: ${
aliases && aliases.length ? aliases.join(' | ') : 'null'
}, **Types**: ${types && types.length ? types.join(' | ') : 'null'}
\n\n${def != null ? '**Default**: ' + def + ', ' : ''}**Required**: ${String(Boolean(required))}
\n\n${help}`;
return ref;
}

View file

@ -6,4 +6,9 @@
.canvasTray__panel {
background-color: $euiPageBackgroundColor;
border-radius: 0;
&.canvasTray__panel--holdingExpression {
background-color: $euiColorEmptyShade;
}
}

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
// @ts-ignore
import { registries } from 'plugins/interpreter/registries';
import { CanvasFunction } from '../../types';
export const LANGUAGE_ID = 'canvas-expression';
/**
* Extends the default type for a Monarch language so we can use
* attribute references (like @keywords to reference the keywords list)
* in the defined tokenizer
*/
interface Language extends monaco.languages.IMonarchLanguage {
keywords: string[];
symbols: RegExp;
escapes: RegExp;
digits: RegExp;
boolean: ['true', 'false'];
}
/**
* Defines the Monarch tokenizer for syntax highlighting in Monaco of the
* expression language. The tokenizer defines a set of regexes and actions/tokens
* to mark the detected words/characters.
* For more information, the Monarch documentation can be found here:
* https://microsoft.github.io/monaco-editor/monarch.html
*/
export const language: Language = {
keywords: [],
symbols: /[=|]/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
digits: /\d+(_+\d+)*/,
boolean: ['true', 'false'],
tokenizer: {
root: [
[/[{}]/, 'delimiter.bracket'],
{
include: 'common',
},
],
common: [
// identifiers and keywords
[
/[a-z_$][\w$]*/,
{
cases: {
'@keywords': 'keyword',
'@boolean': 'keyword',
'@default': 'identifier',
},
},
],
[/(@digits)/, 'number'],
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
[/@symbols/, 'delimiter'],
],
string_double: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^\\']+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/'/, 'string', '@pop'],
],
bracketCounting: [
[/\{/, 'delimiter.bracket', '@bracketCounting'],
[/\}/, 'delimiter.bracket', '@pop'],
{ include: 'common' },
],
},
};
export function registerLanguage() {
const functions = registries.browserFunctions.toArray();
language.keywords = functions.map((fn: CanvasFunction) => fn.name);
monaco.languages.register({ id: LANGUAGE_ID });
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, language);
}

View file

@ -30,6 +30,7 @@
@import '../components/debug/debug';
@import '../components/dom_preview/dom_preview';
@import '../components/dragbox_annotation/dragbox_annotation';
@import '../components/editor/editor';
@import '../components/element_card/element_card';
@import '../components/element_content/element_content';
@import '../components/expression/expression';

View file

@ -19,15 +19,17 @@ $canvasElementCardWidth: 210px;
.canvasCheckered {
background-color: $euiColorGhost;
// sass-lint:disable-block indentation
background-image: linear-gradient(45deg, $euiColorLightShade 25%, transparent 25%),
linear-gradient(-45deg, $euiColorLightShade 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, $euiColorLightShade 75%),
linear-gradient(-45deg, transparent 75%, $euiColorLightShade 75%);
linear-gradient(-45deg, $euiColorLightShade 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, $euiColorLightShade 75%),
linear-gradient(-45deg, transparent 75%, $euiColorLightShade 75%);
background-size: $euiSizeS $euiSizeS;
position: relative;
}
.canvasTextArea--code {
.canvasTextArea__code {
@include euiScrollBar;
font-size: $euiFontSize;
font-family: $euiCodeFontFamily;
@ -39,7 +41,8 @@ $canvasElementCardWidth: 210px;
border-top: $euiBorderThin;
}
#canvas-app { // sass-lint:disable-line no-ids
// sass-lint:disable-block no-ids
#canvas-app {
overflow-y: hidden;
.window-error {

View file

@ -7,4 +7,11 @@
export default {
getBasePath: () => '/abc',
trackSubUrlForApp: () => undefined, // noop
getUiSettingsClient: () => {
return {
get: () => {
return null;
},
};
},
};

View file

@ -25,6 +25,9 @@ export type UnionToIntersection<U> =
*/
export type ValuesOf<T extends any[]> = T[number];
type valueof<T> = T[keyof T];
type ValuesOfUnion<T> = T extends any ? valueof<T> : never;
/**
* A `ExpressionFunctionFactory` is a powerful type used for any function that produces
* an `ExpressionFunction`. If it does not meet the signature for such a function,
@ -116,6 +119,13 @@ export type CanvasFunction = FunctionFactory<Functions>;
*/
export type CanvasFunctionName = CanvasFunction['name'];
/**
* A union type of all Canvas Function argument objects.
*/
export type CanvasArg = CanvasFunction['args'];
export type CanvasArgValue = ValuesOfUnion<CanvasFunction['args']>;
/**
* Represents a function called by the `case` Function.
*/

View file

@ -1,25 +1,27 @@
.monaco-editor .cursors-layer > .cursor {
display: none !important;
}
.codeContainer__editor {
.monaco-editor .cursors-layer > .cursor {
display: none !important;
}
textarea.inputarea {
display: none !important;
}
textarea.inputarea {
display: none !important;
}
.monaco-editor.mac .margin-view-overlays .line-numbers {
cursor: pointer;
background-color: $euiColorLightestShade;
}
.monaco-editor.mac .margin-view-overlays .line-numbers {
cursor: pointer;
background-color: $euiColorLightestShade;
}
.code-line-decoration + .cldr.folding {
left: -124px !important;
opacity: 1;
}
.code-line-decoration + .cldr.folding {
left: -124px !important;
opacity: 1;
}
span.mtk6 {
color: $euiColorSecondary;
}
span.mtk6 {
color: $euiColorSecondary;
}
span.mtk29 {
color: $euiColorAccent;
span.mtk29 {
color: $euiColorAccent;
}
}

View file

@ -85,6 +85,7 @@
"@types/react": "^16.8.0",
"@types/react-dom": "^16.8.0",
"@types/react-redux": "^6.0.6",
"@types/react-resize-detector": "^4.0.1",
"@types/react-router-dom": "^4.3.1",
"@types/react-sticky": "^6.0.3",
"@types/react-test-renderer": "^16.8.0",
@ -316,9 +317,11 @@
"react-markdown": "^3.4.1",
"react-markdown-renderer": "^1.4.0",
"react-moment-proptypes": "^1.6.0",
"react-monaco-editor": "^0.26.2",
"react-portal": "^3.2.0",
"react-redux": "^5.1.1",
"react-redux-request": "^1.5.6",
"react-resize-detector": "^4.2.0",
"react-router-dom": "^4.3.1",
"react-select": "^1.2.1",
"react-shortcuts": "^2.0.0",

View file

@ -3871,6 +3871,13 @@
"@types/react" "*"
redux "^4.0.0"
"@types/react-resize-detector@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/react-resize-detector/-/react-resize-detector-4.0.1.tgz#cc8f012f5957e4826e69b8d2afd59baadcac556c"
integrity sha512-i115c58mAIXGS4CnDmKv5N5KNbPcABhph7SSfpsfBEjx9KJ0JcYKDeNc73H200Eo7vVSFnAIIDs7EmVWeoZiaw==
dependencies:
"@types/react" "*"
"@types/react-router-dom@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-4.3.1.tgz#71fe2918f8f60474a891520def40a63997dafe04"
@ -23096,6 +23103,15 @@ react-moment-proptypes@^1.6.0:
dependencies:
moment ">=1.6.0"
react-monaco-editor@^0.26.2:
version "0.26.2"
resolved "https://registry.yarnpkg.com/react-monaco-editor/-/react-monaco-editor-0.26.2.tgz#a12b188529cb9ca4859a9d688ad35701c7d9c933"
integrity sha512-a7/w6l8873ankpa5cdAwXSRnwEis8V/2YVeQA0JdTh0edFhQ/2TKlgm8bOFYmGX3taBk+EVp9OMNQvYH1O73iA==
dependencies:
"@types/react" "*"
monaco-editor "^0.17.0"
prop-types "^15.7.2"
react-motion@^0.4.8:
version "0.4.8"
resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.4.8.tgz#23bb2dd27c2d8e00d229e45572d105efcf40a35e"
@ -23222,6 +23238,17 @@ react-resize-detector@^3.2.1:
prop-types "^15.6.2"
resize-observer-polyfill "^1.5.1"
react-resize-detector@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-4.2.0.tgz#b87aee6b37c9e8a52daca8736b3230cf6a2a8647"
integrity sha512-AtOaNIxs0ydua7tEoglXR3902/EdlIj9PXDu1Zj0ug2VAUnkSQjguLGzaG/N6CXLOhJSccTsUCZxjLayQ1mE9Q==
dependencies:
lodash "^4.17.11"
lodash-es "^4.17.11"
prop-types "^15.7.2"
raf-schd "^4.0.0"
resize-observer-polyfill "^1.5.1"
react-router-dom@4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.2.2.tgz#c8a81df3adc58bba8a76782e946cbd4eae649b8d"