Skip to content

Commit

Permalink
[Canvas] Add Monaco to the Canvas Expression Editor (#41790)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
poffdeluxe authored Aug 26, 2019
1 parent 5cf45db commit ebe22f4
Show file tree
Hide file tree
Showing 37 changed files with 1,197 additions and 565 deletions.
5 changes: 3 additions & 2 deletions x-pack/dev-tools/jest/create_jest_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 2 additions & 0 deletions x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions x-pack/legacy/plugins/canvas/.storybook/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
/>
Expand Down
113 changes: 83 additions & 30 deletions x-pack/legacy/plugins/canvas/common/lib/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

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

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}
}
}
Expand All @@ -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, '');
Expand Down Expand Up @@ -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);
Expand All @@ -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)
);

Expand All @@ -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'
)
Expand All @@ -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);
Expand Down Expand Up @@ -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)
);
Expand Down
2 changes: 0 additions & 2 deletions x-pack/legacy/plugins/canvas/common/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ $canvasLayoutFontSize: $euiFontSizeS;
flex-direction: column;
flex-grow: 1;
max-height: 100vh;
max-width: 100%;
}

.canvasLayout__cols {
Expand Down
4 changes: 4 additions & 0 deletions x-pack/legacy/plugins/canvas/public/components/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
13 changes: 13 additions & 0 deletions x-pack/legacy/plugins/canvas/public/components/editor/README.md
Original file line number Diff line number Diff line change
@@ -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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit ebe22f4

Please sign in to comment.