diff --git a/x-pack/plugins/canvas/__tests__/fixtures/function_specs.js b/x-pack/plugins/canvas/__tests__/fixtures/function_specs.js new file mode 100644 index 00000000000000..6a8ffdcc7df047 --- /dev/null +++ b/x-pack/plugins/canvas/__tests__/fixtures/function_specs.js @@ -0,0 +1,6 @@ +import { Fn } from '../../common/lib/fn'; +import { functions as browserFns } from '../../canvas_plugin_src/functions/browser'; +import { functions as commonFns } from '../../canvas_plugin_src/functions/common'; +import { functions as serverFns } from '../../canvas_plugin_src/functions/server/src'; + +export const functionSpecs = [...browserFns, ...commonFns, ...serverFns].map(fn => new Fn(fn())); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/font.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/font.js index 7816283c370b3e..b05945c500e11f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/font.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/font.js @@ -160,9 +160,9 @@ describe('font', () => { expect(result.spec).to.have.property('textAlign', 'right'); expect(result.css).to.contain('text-align:right'); - result = fn(null, { align: 'justified' }); - expect(result.spec).to.have.property('textAlign', 'justified'); - expect(result.css).to.contain('text-align:justified'); + result = fn(null, { align: 'justify' }); + expect(result.spec).to.have.property('textAlign', 'justify'); + expect(result.css).to.contain('text-align:justify'); }); it(`defaults to 'left'`, () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.js index f588138e3f43f9..c4e465b76c812c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.js @@ -23,6 +23,7 @@ export const alterColumn = () => ({ types: ['string'], help: 'The type to convert the column to. Leave blank to not change type', default: null, + options: ['null', 'boolean', 'number', 'string'], }, name: { types: ['string'], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axisConfig.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axisConfig.js index f8ae4a5af333d8..0d37c3b32c45fa 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axisConfig.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/axisConfig.js @@ -22,6 +22,7 @@ export const axisConfig = () => ({ position: { types: ['string'], help: 'Position of the axis labels - top, bottom, left, and right', + options: ['top', 'bottom', 'left', 'right'], default: '', }, min: { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.js index c3ff1f7e3f6eaa..c9dd2245b86966 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.js @@ -22,6 +22,7 @@ export const compare = () => ({ help: 'The operator to use in the comparison: ' + ' eq (equal), ne (not equal), lt (less than), gt (greater than), lte (less than equal), gte (greater than eq)', + options: ['eq', 'ne', 'lt', 'gt', 'lte', 'gte'], }, to: { aliases: ['this', 'b'], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.js index 4fe85a72115d13..2a0637b9f1df79 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.js @@ -41,11 +41,13 @@ export const containerStyle = () => ({ types: ['string'], help: 'Valid CSS background size string', default: 'contain', + options: ['contain', 'cover', 'auto'], }, backgroundRepeat: { types: ['string'], help: 'Valid CSS background repeat string', default: 'no-repeat', + options: ['repeat-x', 'repeat', 'space', 'round', 'no-repeat', 'space'], }, opacity: { types: ['number', 'null'], @@ -53,7 +55,8 @@ export const containerStyle = () => ({ }, overflow: { types: ['string'], - help: `Sets overflow of the container`, + help: 'Sets overflow of the container', + options: ['visible', 'hidden', 'scroll', 'auto'], }, }, fn: (context, args) => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/font.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/font.js index ef78b7b9dd847b..c8b0141d2ef245 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/font.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/font.js @@ -7,6 +7,23 @@ import inlineStyle from 'inline-style'; import { openSans } from '../../../common/lib/fonts'; +const weights = [ + 'normal', + 'bold', + 'bolder', + 'lighter', + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', +]; +const alignments = ['center', 'left', 'right', 'justify']; + export const font = () => ({ name: 'font', aliases: [], @@ -40,41 +57,28 @@ export const font = () => ({ help: 'Set the font weight, e.g. normal, bold, bolder, lighter, 100, 200, 300, 400, 500, 600, 700, 800, 900', default: 'normal', + options: weights, }, underline: { types: ['boolean'], default: false, help: 'Underline the text, true or false', + options: [true, false], }, italic: { types: ['boolean'], default: false, help: 'Italicize, true or false', + options: [true, false], }, align: { types: ['string'], help: 'Horizontal text alignment', default: 'left', + options: alignments, }, }, fn: (context, args) => { - const weights = [ - 'normal', - 'bold', - 'bolder', - 'lighter', - '100', - '200', - '300', - '400', - '500', - '600', - '700', - '800', - '900', - ]; - const alignments = ['center', 'left', 'right', 'justified']; - if (!weights.includes(args.weight)) throw new Error(`Invalid font weight: ${args.weight}`); if (!alignments.includes(args.align)) throw new Error(`Invalid text alignment: ${args.align}`); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.js index 55af5f60ef97d8..5bf6c429331f72 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.js @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { includes } from 'lodash'; import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; import { elasticLogo } from '../../lib/elastic_logo'; +const modes = ['contain', 'cover', 'stretch']; + export const image = () => ({ name: 'image', aliases: [], @@ -31,11 +32,11 @@ export const image = () => ({ '"cover" will fill the container with the image, cropping from the sides or bottom as needed.' + '"stretch" will resize the height and width of the image to 100% of the container', default: 'contain', + options: modes, }, }, fn: (context, { dataurl, mode }) => { - if (!includes(['contain', 'cover', 'stretch'], mode)) - throw '"mode" must be "contain", "cover", or "stretch"'; + if (!modes.includes(mode)) throw '"mode" must be "contain", "cover", or "stretch"'; const modeStyle = mode === 'stretch' ? '100% 100%' : mode; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.js index 7a601f71b22296..4e5e04815d9c5e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.js @@ -24,11 +24,13 @@ export const palette = () => ({ types: ['boolean'], default: false, help: 'Prefer to make a gradient where supported and useful?', + options: [true, false], }, reverse: { type: ['boolean'], default: false, help: 'Reverse the palette', + options: [true, false], }, }, fn: (context, args) => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.js index fbdd2e9dde2107..2cc0fbaeb30c00 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.js @@ -42,6 +42,7 @@ export const pie = () => ({ types: ['boolean'], default: true, help: 'Show pie labels', + options: [true, false], }, labelRadius: { types: ['number'], @@ -57,6 +58,7 @@ export const pie = () => ({ types: ['string', 'boolean'], help: 'Legend position, nw, sw, ne, se or false', default: false, + options: ['nw', 'sw', 'ne', 'se', false], }, tilt: { types: ['number'], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.js index 2cf996f23cef00..c76a7d658da09a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.js @@ -47,6 +47,7 @@ export const plot = () => ({ types: ['string', 'boolean'], help: 'Legend position, nw, sw, ne, se or false', default: 'ne', + options: ['nw', 'sw', 'ne', 'se', false], }, yaxis: { types: ['boolean', 'axisConfig'], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.js index 859804c75a39e5..453730b3f861af 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/progress.js @@ -6,7 +6,17 @@ import { get } from 'lodash'; import { openSans } from '../../../common/lib/fonts'; -import { shapes } from '../../renderers/progress/shapes'; + +const shapes = [ + 'gauge', + 'horizontalBar', + 'horizontalPill', + 'semicircle', + 'unicorn', + 'verticalBar', + 'verticalPill', + 'wheel', +]; export const progress = () => ({ name: 'progress', @@ -20,9 +30,8 @@ export const progress = () => ({ shape: { type: ['string'], alias: ['_'], - help: `Select ${Object.keys(shapes) - .map((key, i, src) => (i === src.length - 1 ? `or ${shapes[key].name}` : shapes[key].name)) - .join(', ')}`, + help: `Select ${shapes.slice(0, -1).join(', ')}, or ${shapes.slice(-1)[0]}`, + options: shapes, default: 'gauge', }, max: { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.js index 716ca8abc340df..50a832553ce4d1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.js @@ -17,6 +17,7 @@ export const render = () => ({ types: ['string', 'null'], help: 'The element type to use in rendering. You probably want a specialized function instead, such as plot or grid', + options: ['debug', 'error', 'image', 'pie', 'plot', 'shape', 'table', 'text'], }, css: { types: ['string', 'null'], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.js index 8c9e4f2af5914a..9d54dc9363b954 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.js @@ -30,6 +30,7 @@ export const revealImage = () => ({ types: ['string'], help: 'Where to start from. Eg, top, left, bottom or right', default: 'bottom', + options: ['top', 'left', 'bottom', 'right'], }, }, fn: (percent, args) => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/seriesStyle.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/seriesStyle.js index 5afb7263147c3c..f6be205e7e8241 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/seriesStyle.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/seriesStyle.js @@ -45,6 +45,8 @@ export const seriesStyle = () => ({ types: ['number', 'boolean'], displayName: 'Fill points', help: 'Should we fill points?', + default: false, + options: [true, false], }, stack: { types: ['number', 'null'], @@ -56,6 +58,7 @@ export const seriesStyle = () => ({ types: ['boolean'], displayName: 'Horizontal bars orientation', help: 'Sets the orientation of bars in the chart to horizontal', + options: [true, false], }, }, fn: (context, args) => ({ type: name, ...args }), diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.js index 27379a759608d6..9edae93afbd69a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.js @@ -18,6 +18,24 @@ export const shape = () => ({ help: 'Pick a shape', aliases: ['_'], default: 'square', + options: [ + 'arrow', + 'arrowMulti', + 'bookmark', + 'cross', + 'circle', + 'hexagon', + 'kite', + 'pentagon', + 'rhombus', + 'semicircle', + 'speechBubble', + 'square', + 'star', + 'tag', + 'triangle', + 'triangleRight', + ], }, fill: { types: ['string', 'null'], @@ -39,6 +57,7 @@ export const shape = () => ({ types: ['boolean'], help: 'Select true to maintain aspect ratio', default: false, + options: [true, false], }, }, fn: (context, { shape, fill, border, borderWidth, maintainAspect }) => ({ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.js index b6b554c032281d..abc61aa7eea62e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/sort.js @@ -25,6 +25,7 @@ export const sort = () => ({ types: ['boolean'], help: 'Reverse the sort order. If reverse is not specified, the datatable will be sorted in ascending order.', + options: [true, false], }, }, fn: (context, args) => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.js index ec3d21738dcdf4..93899d79d7d54e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/table.js @@ -22,6 +22,7 @@ export const table = () => ({ types: ['boolean'], default: true, help: 'Show pagination controls. If set to false only the first page will be displayed', + options: [true, false], }, perPage: { types: ['number'], @@ -32,6 +33,7 @@ export const table = () => ({ types: ['boolean'], default: true, help: 'Show or hide the header row with titles for each column', + options: [true, false], }, }, fn: (context, args) => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilterControl.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilterControl.js index ef7466622c08b9..fd856b14bcc733 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilterControl.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/timefilterControl.js @@ -22,6 +22,7 @@ export const timefilterControl = () => ({ type: ['boolean'], help: 'Show the time filter as a button that triggers a popover', default: true, + options: [true, false], }, }, fn: (context, args) => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/src/demodata/index.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/src/demodata/index.js index 9137e3e11657ed..41507eaae8e7f1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/src/demodata/index.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/src/demodata/index.js @@ -22,6 +22,7 @@ export const demodata = () => ({ aliases: ['_'], help: 'The name of the demo data set to use', default: 'ci', + options: ['ci', 'shirts'], }, }, fn: (context, args) => { diff --git a/x-pack/plugins/canvas/common/lib/__tests__/autocomplete.js b/x-pack/plugins/canvas/common/lib/__tests__/autocomplete.js new file mode 100644 index 00000000000000..72d79adf2a0307 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/__tests__/autocomplete.js @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { functionSpecs } from '../../../__tests__/fixtures/function_specs'; +import { getAutocompleteSuggestions } from '../autocomplete'; + +describe('getAutocompleteSuggestions', () => { + it('should suggest functions', () => { + const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0); + expect(suggestions.length).to.be(functionSpecs.length); + expect(suggestions[0].start).to.be(0); + expect(suggestions[0].end).to.be(0); + }); + + it('should suggest functions filtered by text', () => { + const expression = 'pl'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0); + const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression)); + expect(nonmatching.length).to.be(0); + expect(suggestions[0].start).to.be(0); + expect(suggestions[0].end).to.be(expression.length); + }); + + it('should suggest arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + expect(suggestions.length).to.be(Object.keys(plotFn.args).length); + expect(suggestions[0].start).to.be(expression.length); + expect(suggestions[0].end).to.be(expression.length); + }); + + it('should suggest arguments filtered by text', () => { + const expression = 'plot axis'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis')); + expect(suggestions.length).to.be(matchingArgs.length); + expect(suggestions[0].start).to.be('plot '.length); + expect(suggestions[0].end).to.be('plot axis'.length); + }); + + it('should suggest values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).to.be(shapeFn.args.shape.options.length); + expect(suggestions[0].start).to.be(expression.length); + expect(suggestions[0].end).to.be(expression.length); + }); + + it('should suggest values filtered by text', () => { + const expression = 'shape shape=ar'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter(key => key.includes('ar')); + expect(suggestions.length).to.be(matchingValues.length); + expect(suggestions[0].start).to.be(expression.length - 'ar'.length); + expect(suggestions[0].end).to.be(expression.length); + }); + + it('should suggest functions inside an expression', () => { + const expression = 'if {}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + expect(suggestions.length).to.be(functionSpecs.length); + expect(suggestions[0].start).to.be(expression.length - 1); + expect(suggestions[0].end).to.be(expression.length - 1); + }); + + it('should suggest arguments inside an expression', () => { + const expression = 'if {lt }'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const ltFn = functionSpecs.find(spec => spec.name === 'lt'); + expect(suggestions.length).to.be(Object.keys(ltFn.args).length); + expect(suggestions[0].start).to.be(expression.length - 1); + expect(suggestions[0].end).to.be(expression.length - 1); + }); + + it('should suggest values inside an expression', () => { + const expression = 'if {shape shape=}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).to.be(shapeFn.args.shape.options.length); + expect(suggestions[0].start).to.be(expression.length - 1); + expect(suggestions[0].end).to.be(expression.length - 1); + }); + + it('should suggest values inside quotes', () => { + const expression = 'shape shape="ar"'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter(key => key.includes('ar')); + expect(suggestions.length).to.be(matchingValues.length); + expect(suggestions[0].start).to.be(expression.length - '"ar"'.length); + expect(suggestions[0].end).to.be(expression.length); + }); + + it('should prioritize functions that start with text', () => { + const expression = 't'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table')); + const fontIndex = suggestions.findIndex(suggestion => suggestion.text.includes('font')); + expect(tableIndex).to.be.lessThan(fontIndex); + }); + + it('should prioritize functions that match the previous function type', () => { + const expression = 'plot | '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(renderIndex).to.be.lessThan(anyIndex); + }); + + it('should alphabetize functions', () => { + const expression = ''; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(anyIndex).to.be.lessThan(metricIndex); + }); + + it('should prioritize arguments that start with text', () => { + const expression = 'plot y'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(yaxisIndex).to.be.lessThan(defaultStyleIndex); + }); + + it('should prioritize unnamed arguments', () => { + const expression = 'case '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when')); + const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then')); + expect(whenIndex).to.be.lessThan(thenIndex); + }); + + it('should alphabetize arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(defaultStyleIndex).to.be.lessThan(yaxisIndex); + }); + + it('should quote string values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).to.match(/^".*"$/); + }); + + it('should not quote sub expression value suggestions', () => { + const expression = 'plot font='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).to.be('{font}'); + }); + + it('should not quote booleans', () => { + const expression = 'font underline=true'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).to.be('true'); + }); +}); diff --git a/x-pack/plugins/canvas/common/lib/__tests__/get_by_alias.js b/x-pack/plugins/canvas/common/lib/__tests__/get_by_alias.js index f00092d573d680..eaeeeade4cc593 100644 --- a/x-pack/plugins/canvas/common/lib/__tests__/get_by_alias.js +++ b/x-pack/plugins/canvas/common/lib/__tests__/get_by_alias.js @@ -8,46 +8,67 @@ import expect from 'expect.js'; import { getByAlias } from '../get_by_alias'; describe('getByAlias', () => { - const fns = { - foo: { aliases: ['f'] }, - bar: { aliases: ['b'] }, + const fnsObject = { + foo: { name: 'foo', aliases: ['f'] }, + bar: { name: 'bar', aliases: ['b'] }, }; + const fnsArray = [{ name: 'foo', aliases: ['f'] }, { name: 'bar', aliases: ['b'] }]; + it('returns the function by name', () => { - expect(getByAlias(fns, 'foo')).to.be(fns.foo); - expect(getByAlias(fns, 'bar')).to.be(fns.bar); + expect(getByAlias(fnsObject, 'foo')).to.be(fnsObject.foo); + expect(getByAlias(fnsObject, 'bar')).to.be(fnsObject.bar); + expect(getByAlias(fnsArray, 'foo')).to.be(fnsArray[0]); + expect(getByAlias(fnsArray, 'bar')).to.be(fnsArray[1]); }); it('returns the function by alias', () => { - expect(getByAlias(fns, 'f')).to.be(fns.foo); - expect(getByAlias(fns, 'b')).to.be(fns.bar); + expect(getByAlias(fnsObject, 'f')).to.be(fnsObject.foo); + expect(getByAlias(fnsObject, 'b')).to.be(fnsObject.bar); + expect(getByAlias(fnsArray, 'f')).to.be(fnsArray[0]); + expect(getByAlias(fnsArray, 'b')).to.be(fnsArray[1]); }); it('returns the function by case-insensitive name', () => { - expect(getByAlias(fns, 'FOO')).to.be(fns.foo); - expect(getByAlias(fns, 'BAR')).to.be(fns.bar); + expect(getByAlias(fnsObject, 'FOO')).to.be(fnsObject.foo); + expect(getByAlias(fnsObject, 'BAR')).to.be(fnsObject.bar); + expect(getByAlias(fnsArray, 'FOO')).to.be(fnsArray[0]); + expect(getByAlias(fnsArray, 'BAR')).to.be(fnsArray[1]); }); it('returns the function by case-insensitive alias', () => { - expect(getByAlias(fns, 'F')).to.be(fns.foo); - expect(getByAlias(fns, 'B')).to.be(fns.bar); + expect(getByAlias(fnsObject, 'F')).to.be(fnsObject.foo); + expect(getByAlias(fnsObject, 'B')).to.be(fnsObject.bar); + expect(getByAlias(fnsArray, 'F')).to.be(fnsArray[0]); + expect(getByAlias(fnsArray, 'B')).to.be(fnsArray[1]); }); it('handles empty strings', () => { - const emptyStringFns = { '': {} }; - const emptyStringAliasFns = { foo: { aliases: [''] } }; - expect(getByAlias(emptyStringFns, '')).to.be(emptyStringFns['']); - expect(getByAlias(emptyStringAliasFns, '')).to.be(emptyStringAliasFns.foo); + const emptyStringFnsObject = { '': { name: '' } }; + const emptyStringAliasFnsObject = { foo: { name: 'foo', aliases: [''] } }; + expect(getByAlias(emptyStringFnsObject, '')).to.be(emptyStringFnsObject['']); + expect(getByAlias(emptyStringAliasFnsObject, '')).to.be(emptyStringAliasFnsObject.foo); + + const emptyStringFnsArray = [{ name: '' }]; + const emptyStringAliasFnsArray = [{ name: 'foo', aliases: [''] }]; + expect(getByAlias(emptyStringFnsArray, '')).to.be(emptyStringFnsArray[0]); + expect(getByAlias(emptyStringAliasFnsArray, '')).to.be(emptyStringAliasFnsArray[0]); }); it('handles "undefined" strings', () => { - const emptyStringFns = { undefined: {} }; - const emptyStringAliasFns = { foo: { aliases: ['undefined'] } }; - expect(getByAlias(emptyStringFns, 'undefined')).to.be(emptyStringFns.undefined); - expect(getByAlias(emptyStringAliasFns, 'undefined')).to.be(emptyStringAliasFns.foo); + const undefinedFnsObject = { undefined: { name: 'undefined' } }; + const undefinedAliasFnsObject = { foo: { name: 'undefined', aliases: ['undefined'] } }; + expect(getByAlias(undefinedFnsObject, 'undefined')).to.be(undefinedFnsObject.undefined); + expect(getByAlias(undefinedAliasFnsObject, 'undefined')).to.be(undefinedAliasFnsObject.foo); + + const emptyStringFnsArray = [{ name: 'undefined' }]; + const emptyStringAliasFnsArray = [{ name: 'foo', aliases: ['undefined'] }]; + expect(getByAlias(emptyStringFnsArray, 'undefined')).to.be(emptyStringFnsArray[0]); + expect(getByAlias(emptyStringAliasFnsArray, 'undefined')).to.be(emptyStringAliasFnsArray[0]); }); it('returns undefined if not found', () => { - expect(getByAlias(fns, 'baz')).to.be(undefined); + expect(getByAlias(fnsObject, 'baz')).to.be(undefined); + expect(getByAlias(fnsArray, 'baz')).to.be(undefined); }); }); diff --git a/x-pack/plugins/canvas/common/lib/arg.js b/x-pack/plugins/canvas/common/lib/arg.js index d220e30d232379..7713fcb342bc25 100644 --- a/x-pack/plugins/canvas/common/lib/arg.js +++ b/x-pack/plugins/canvas/common/lib/arg.js @@ -10,12 +10,13 @@ export function Arg(config) { if (config.name === '_') throw Error('Arg names must not be _. Use it in aliases instead.'); this.name = config.name; this.required = config.required || false; - this.help = config.help; + this.help = config.help || ''; this.types = config.types || []; this.default = config.default; this.aliases = config.aliases || []; this.multi = config.multi == null ? false : config.multi; this.resolve = config.resolve == null ? true : config.resolve; + this.options = config.options || []; this.accepts = type => { if (!this.types.length) return true; return includes(config.types, type); diff --git a/x-pack/plugins/canvas/common/lib/autocomplete.js b/x-pack/plugins/canvas/common/lib/autocomplete.js new file mode 100644 index 00000000000000..d87e199de46717 --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/autocomplete.js @@ -0,0 +1,209 @@ +/* + * 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 { uniq } from 'lodash'; +import { parse } from './grammar'; +import { getByAlias } from './get_by_alias'; + +const MARKER = 'CANVAS_SUGGESTION_MARKER'; + +/** + * Generates the AST with the given expression and then returns the function and argument definitions + * at the given position in the expression, if there are any. + */ +export function getFnArgDefAtPosition(specs, expression, position) { + const text = expression.substr(0, position) + MARKER + expression.substr(position); + try { + const ast = parse(text, { addMeta: true }); + const { ast: newAst, fnIndex, argName } = getFnArgAtPosition(ast, position); + const fn = newAst.node.chain[fnIndex].node; + + const fnDef = getByAlias(specs, fn.function.replace(MARKER, '')); + if (fnDef && argName) { + const argDef = getByAlias(fnDef.args, argName); + return { fnDef, argDef }; + } + return { fnDef }; + } catch (e) { + // Fail silently + } + return []; +} + +/** + * Gets a list of suggestions for the given expression at the given position. It does this by + * inserting a marker at the given position, then parsing the resulting expression. This way we can + * see what the marker would turn into, which tells us what sorts of things to suggest. For + * example, if the marker turns into a function name, then we suggest functions. If it turns into + * an unnamed argument, we suggest argument names. If it turns into a value, we suggest values. + */ +export function getAutocompleteSuggestions(specs, expression, position) { + const text = expression.substr(0, position) + MARKER + expression.substr(position); + try { + const ast = parse(text, { addMeta: true }); + const { ast: newAst, fnIndex, argName, argIndex } = getFnArgAtPosition(ast, position); + const fn = newAst.node.chain[fnIndex].node; + + if (fn.function.includes(MARKER)) return getFnNameSuggestions(specs, newAst, fnIndex); + + if (argName === '_') return getArgNameSuggestions(specs, newAst, fnIndex, argName, argIndex); + + if (argName) return getArgValueSuggestions(specs, newAst, fnIndex, argName, argIndex); + } catch (e) { + // Fail silently + } + return []; +} + +/** + * Get the function and argument (if there is one) at the given position. + */ +function getFnArgAtPosition(ast, position) { + 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) { + if (value.node !== null && value.node.type === 'expression') + return getFnArgAtPosition(value, position); + return { ast, fnIndex, argName, argIndex }; + } + } + } + return { ast, fnIndex }; +} + +function getFnNameSuggestions(specs, ast, fnIndex) { + // 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, ''); + const matchingFnDefs = specs.filter(({ name }) => textMatches(name, query)); + + // Sort by whether or not the function expects the previous function's return type, then by + // whether or not the function name starts with the text at the marker, then alphabetically + const prevFn = ast.node.chain[fnIndex - 1]; + const prevFnDef = prevFn && getByAlias(specs, prevFn.node.function); + const prevFnType = prevFnDef && prevFnDef.type; + const comparator = combinedComparator( + prevFnTypeComparator(prevFnType), + invokeWithProp(startsWithComparator(query), 'name'), + invokeWithProp(alphanumericalComparator, 'name') + ); + const fnDefs = matchingFnDefs.sort(comparator); + + return fnDefs.map(fnDef => { + return { type: 'function', text: fnDef.name + ' ', start, end: end - MARKER.length, fnDef }; + }); +} + +function getArgNameSuggestions(specs, ast, fnIndex, argName, argIndex) { + // Get the list of args from the function definition + const fn = ast.node.chain[fnIndex].node; + const fnDef = getByAlias(specs, fn.function); + if (!fnDef) return []; + + // We use the exact text instead of the value because it is always a string and might be quoted + const { text, start, end } = fn.arguments[argName][argIndex]; + + // Filter the list of args by the text at the marker + const query = text.replace(MARKER, ''); + const matchingArgDefs = Object.values(fnDef.args).filter(({ name }) => textMatches(name, query)); + + // Filter the list of args by those which aren't already present (unless they allow multi) + const argEntries = Object.entries(fn.arguments).map(([name, values]) => { + return [name, values.filter(value => !value.text.includes(MARKER))]; + }); + const unusedArgDefs = matchingArgDefs.filter(argDef => { + if (argDef.multi) return true; + return !argEntries.some(([name, values]) => { + return values.length && (name === argDef.name || argDef.aliases.includes(name)); + }); + }); + + // Sort by whether or not the arg is also the unnamed, then by whether or not the arg name starts + // with the text at the marker, then alphabetically + const comparator = combinedComparator( + unnamedArgComparator, + invokeWithProp(startsWithComparator(query), 'name'), + invokeWithProp(alphanumericalComparator, 'name') + ); + const argDefs = unusedArgDefs.sort(comparator); + + return argDefs.map(argDef => { + return { type: 'argument', text: argDef.name + '=', start, end: end - MARKER.length, argDef }; + }); +} + +function getArgValueSuggestions(specs, ast, fnIndex, argName, argIndex) { + // Get the list of values from the argument definition + const fn = ast.node.chain[fnIndex].node; + const fnDef = getByAlias(specs, fn.function); + if (!fnDef) return []; + const argDef = getByAlias(fnDef.args, argName); + if (!argDef) return []; + + // Get suggestions from the argument definition, including the default + const { start, end, node } = fn.arguments[argName][argIndex]; + const query = node.replace(MARKER, ''); + const suggestions = uniq(argDef.options.concat(argDef.default || [])); + + // Filter the list of suggestions by the text at the marker + const filtered = suggestions.filter(option => textMatches(String(option), query)); + + // Sort by whether or not the value starts with the text at the marker, then alphabetically + const comparator = combinedComparator(startsWithComparator(query), alphanumericalComparator); + const sorted = filtered.sort(comparator); + + return sorted.map(value => { + const text = maybeQuote(value) + ' '; + return { start, end: end - MARKER.length, type: 'value', text }; + }); +} + +function textMatches(text, query) { + return text.toLowerCase().includes(query.toLowerCase().trim()); +} + +function maybeQuote(value) { + if (typeof value === 'string') { + if (value.match(/^\{.*\}$/)) return value; + return `"${value.replace(/"/g, '\\"')}"`; + } + return value; +} + +function prevFnTypeComparator(prevFnType) { + return (a, b) => + Boolean(b.context.types && b.context.types.includes(prevFnType)) - + Boolean(a.context.types && a.context.types.includes(prevFnType)); +} + +function unnamedArgComparator(a, b) { + return b.aliases.includes('_') - a.aliases.includes('_'); +} + +function alphanumericalComparator(a, b) { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function startsWithComparator(query) { + return (a, b) => String(b).startsWith(query) - String(a).startsWith(query); +} + +function combinedComparator(...comparators) { + return (a, b) => + comparators.reduce((acc, comparator) => { + if (acc !== 0) return acc; + return comparator(a, b); + }, 0); +} + +function invokeWithProp(fn, prop) { + return (...args) => fn(...args.map(arg => arg[prop])); +} diff --git a/x-pack/plugins/canvas/common/lib/get_by_alias.js b/x-pack/plugins/canvas/common/lib/get_by_alias.js index ff705f07af516c..c9986a50240086 100644 --- a/x-pack/plugins/canvas/common/lib/get_by_alias.js +++ b/x-pack/plugins/canvas/common/lib/get_by_alias.js @@ -6,16 +6,15 @@ /** * This is used for looking up function/argument definitions. It looks through - * the given object for a case-insensitive match, which could be either the - * name of the key itself, or something under the `aliases` property. + * the given object/array for a case-insensitive match, which could be either the + * `name` itself, or something under the `aliases` property. */ export function getByAlias(specs, name) { const lowerCaseName = name.toLowerCase(); - const key = Object.keys(specs).find(key => { - if (key.toLowerCase() === lowerCaseName) return true; - return (specs[key].aliases || []).some(alias => { + return Object.values(specs).find(({ name, aliases }) => { + if (name.toLowerCase() === lowerCaseName) return true; + return (aliases || []).some(alias => { return alias.toLowerCase() === lowerCaseName; }); }); - if (typeof key !== undefined) return specs[key]; } diff --git a/x-pack/plugins/canvas/common/lib/grammar.js b/x-pack/plugins/canvas/common/lib/grammar.js index c72312321af607..2e87b52d68f22e 100644 --- a/x-pack/plugins/canvas/common/lib/grammar.js +++ b/x-pack/plugins/canvas/common/lib/grammar.js @@ -145,18 +145,18 @@ function peg$parse(input, options) { peg$c1 = peg$literalExpectation("|", false), peg$c2 = function(first, fn) { return fn; }, peg$c3 = function(first, rest) { - return { + return addMeta({ type: 'expression', chain: [first].concat(rest) - }; + }, text(), location()); }, peg$c4 = peg$otherExpectation("function"), peg$c5 = function(name, arg_list) { - return { + return addMeta({ type: 'function', function: name, arguments: arg_list - }; + }, text(), location()); }, peg$c6 = "=", peg$c7 = peg$literalExpectation("=", false), @@ -173,25 +173,28 @@ function peg$parse(input, options) { peg$c14 = "}", peg$c15 = peg$literalExpectation("}", false), peg$c16 = function(expression) { return expression; }, - peg$c17 = function(arg) { return arg; }, - peg$c18 = function(args) { + peg$c17 = function(value) { + return addMeta(value, text(), location()); + }, + peg$c18 = function(arg) { return arg; }, + peg$c19 = function(args) { return args.reduce((accumulator, { name, value }) => ({ ...accumulator, [name]: (accumulator[name] || []).concat(value) }), {}); }, - peg$c19 = /^[a-zA-Z0-9_\-]/, - peg$c20 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_", "-"], false, false), - peg$c21 = function(name) { + peg$c20 = /^[a-zA-Z0-9_\-]/, + peg$c21 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_", "-"], false, false), + peg$c22 = function(name) { return name.join(''); }, - peg$c22 = peg$otherExpectation("literal"), - peg$c23 = "\"", - peg$c24 = peg$literalExpectation("\"", false), - peg$c25 = function(chars) { return chars.join(''); }, - peg$c26 = "'", - peg$c27 = peg$literalExpectation("'", false), - peg$c28 = function(string) { // this also matches nulls, booleans, and numbers + peg$c23 = peg$otherExpectation("literal"), + peg$c24 = "\"", + peg$c25 = peg$literalExpectation("\"", false), + peg$c26 = function(chars) { return chars.join(''); }, + peg$c27 = "'", + peg$c28 = peg$literalExpectation("'", false), + peg$c29 = function(string) { // this also matches nulls, booleans, and numbers var result = string.join(''); // Sort of hacky, but PEG doesn't have backtracking so // a null/boolean/number rule is hard to read, and performs worse @@ -201,19 +204,19 @@ function peg$parse(input, options) { if (isNaN(Number(result))) return result; // 5bears return Number(result); }, - peg$c29 = /^[ \t\r\n]/, - peg$c30 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false), - peg$c31 = "\\", - peg$c32 = peg$literalExpectation("\\", false), - peg$c33 = /^["'(){}<>[\]$`|= \t\n\r]/, - peg$c34 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], false, false), - peg$c35 = function(sequence) { return sequence; }, - peg$c36 = /^[^"'(){}<>[\]$`|= \t\n\r]/, - peg$c37 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], true, false), - peg$c38 = /^[^"]/, - peg$c39 = peg$classExpectation(["\""], true, false), - peg$c40 = /^[^']/, - peg$c41 = peg$classExpectation(["'"], true, false), + peg$c30 = /^[ \t\r\n]/, + peg$c31 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false), + peg$c32 = "\\", + peg$c33 = peg$literalExpectation("\\", false), + peg$c34 = /^["'(){}<>[\]$`|= \t\n\r]/, + peg$c35 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], false, false), + peg$c36 = function(sequence) { return sequence; }, + peg$c37 = /^[^"'(){}<>[\]$`|= \t\n\r]/, + peg$c38 = peg$classExpectation(["\"", "'", "(", ")", "{", "}", "<", ">", "[", "]", "$", "`", "|", "=", " ", "\t", "\n", "\r"], true, false), + peg$c39 = /^[^"]/, + peg$c40 = peg$classExpectation(["\""], true, false), + peg$c41 = /^[^']/, + peg$c42 = peg$classExpectation(["'"], true, false), peg$currPos = 0, peg$savedPos = 0, @@ -352,63 +355,90 @@ function peg$parse(input, options) { } function peg$parseexpression() { - var s0, s1, s2, s3, s4, s5; + var s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; - s1 = peg$parsefunction(); + s1 = peg$parsespace(); + if (s1 === peg$FAILED) { + s1 = null; + } if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 124) { - s4 = peg$c0; - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c1); } - } - if (s4 !== peg$FAILED) { - s5 = peg$parsefunction(); - if (s5 !== peg$FAILED) { - peg$savedPos = s3; - s4 = peg$c2(s1, s5); - s3 = s4; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$currPos; + s2 = peg$parsefunction(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$currPos; if (input.charCodeAt(peg$currPos) === 124) { - s4 = peg$c0; + s5 = peg$c0; peg$currPos++; } else { - s4 = peg$FAILED; + s5 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$c1); } } - if (s4 !== peg$FAILED) { - s5 = peg$parsefunction(); + if (s5 !== peg$FAILED) { + s6 = peg$parsespace(); + if (s6 === peg$FAILED) { + s6 = null; + } + if (s6 !== peg$FAILED) { + s7 = peg$parsefunction(); + if (s7 !== peg$FAILED) { + peg$savedPos = s4; + s5 = peg$c2(s2, s7); + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 124) { + s5 = peg$c0; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c1); } + } if (s5 !== peg$FAILED) { - peg$savedPos = s3; - s4 = peg$c2(s1, s5); - s3 = s4; + s6 = peg$parsespace(); + if (s6 === peg$FAILED) { + s6 = null; + } + if (s6 !== peg$FAILED) { + s7 = peg$parsefunction(); + if (s7 !== peg$FAILED) { + peg$savedPos = s4; + s5 = peg$c2(s2, s7); + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } } else { - peg$currPos = s3; - s3 = peg$FAILED; + peg$currPos = s4; + s4 = peg$FAILED; } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c3(s2, s3); + s0 = s1; } else { - peg$currPos = s3; - s3 = peg$FAILED; + peg$currPos = s0; + s0 = peg$FAILED; } - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c3(s1, s2); - s0 = s1; } else { peg$currPos = s0; s0 = peg$FAILED; @@ -422,26 +452,17 @@ function peg$parse(input, options) { } function peg$parsefunction() { - var s0, s1, s2, s3; + var s0, s1, s2; peg$silentFails++; s0 = peg$currPos; - s1 = peg$parsespace(); - if (s1 === peg$FAILED) { - s1 = null; - } + s1 = peg$parseidentifier(); if (s1 !== peg$FAILED) { - s2 = peg$parseidentifier(); + s2 = peg$parsearg_list(); if (s2 !== peg$FAILED) { - s3 = peg$parsearg_list(); - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c5(s2, s3); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } + peg$savedPos = s0; + s1 = peg$c5(s1, s2); + s0 = s1; } else { peg$currPos = s0; s0 = peg$FAILED; @@ -522,7 +543,7 @@ function peg$parse(input, options) { } function peg$parseargument() { - var s0, s1, s2, s3, s4, s5, s6; + var s0, s1, s2, s3, s4; s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 36) { @@ -544,37 +565,19 @@ function peg$parse(input, options) { if (peg$silentFails === 0) { peg$fail(peg$c13); } } if (s2 !== peg$FAILED) { - s3 = peg$parsespace(); - if (s3 === peg$FAILED) { - s3 = null; - } + s3 = peg$parseexpression(); if (s3 !== peg$FAILED) { - s4 = peg$parseexpression(); + if (input.charCodeAt(peg$currPos) === 125) { + s4 = peg$c14; + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c15); } + } if (s4 !== peg$FAILED) { - s5 = peg$parsespace(); - if (s5 === peg$FAILED) { - s5 = null; - } - if (s5 !== peg$FAILED) { - if (input.charCodeAt(peg$currPos) === 125) { - s6 = peg$c14; - peg$currPos++; - } else { - s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c15); } - } - if (s6 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$c16(s4); - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } + peg$savedPos = s0; + s1 = peg$c16(s3); + s0 = s1; } else { peg$currPos = s0; s0 = peg$FAILED; @@ -592,7 +595,13 @@ function peg$parse(input, options) { s0 = peg$FAILED; } if (s0 === peg$FAILED) { - s0 = peg$parseliteral(); + s0 = peg$currPos; + s1 = peg$parseliteral(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c17(s1); + } + s0 = s1; } return s0; @@ -609,7 +618,7 @@ function peg$parse(input, options) { s4 = peg$parseargument_assignment(); if (s4 !== peg$FAILED) { peg$savedPos = s2; - s3 = peg$c17(s4); + s3 = peg$c18(s4); s2 = s3; } else { peg$currPos = s2; @@ -627,7 +636,7 @@ function peg$parse(input, options) { s4 = peg$parseargument_assignment(); if (s4 !== peg$FAILED) { peg$savedPos = s2; - s3 = peg$c17(s4); + s3 = peg$c18(s4); s2 = s3; } else { peg$currPos = s2; @@ -645,7 +654,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c18(s1); + s1 = peg$c19(s1); s0 = s1; } else { peg$currPos = s0; @@ -664,22 +673,22 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = []; - if (peg$c19.test(input.charAt(peg$currPos))) { + if (peg$c20.test(input.charAt(peg$currPos))) { s2 = input.charAt(peg$currPos); peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c20); } + if (peg$silentFails === 0) { peg$fail(peg$c21); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { s1.push(s2); - if (peg$c19.test(input.charAt(peg$currPos))) { + if (peg$c20.test(input.charAt(peg$currPos))) { s2 = input.charAt(peg$currPos); peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c20); } + if (peg$silentFails === 0) { peg$fail(peg$c21); } } } } else { @@ -687,7 +696,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c21(s1); + s1 = peg$c22(s1); } s0 = s1; @@ -705,7 +714,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c22); } + if (peg$silentFails === 0) { peg$fail(peg$c23); } } return s0; @@ -716,11 +725,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 34) { - s1 = peg$c23; + s1 = peg$c24; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c24); } + if (peg$silentFails === 0) { peg$fail(peg$c25); } } if (s1 !== peg$FAILED) { s2 = []; @@ -731,15 +740,15 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c23; + s3 = peg$c24; peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c24); } + if (peg$silentFails === 0) { peg$fail(peg$c25); } } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c25(s2); + s1 = peg$c26(s2); s0 = s1; } else { peg$currPos = s0; @@ -756,11 +765,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 39) { - s1 = peg$c26; + s1 = peg$c27; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c27); } + if (peg$silentFails === 0) { peg$fail(peg$c28); } } if (s1 !== peg$FAILED) { s2 = []; @@ -771,15 +780,15 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 39) { - s3 = peg$c26; + s3 = peg$c27; peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c27); } + if (peg$silentFails === 0) { peg$fail(peg$c28); } } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c25(s2); + s1 = peg$c26(s2); s0 = s1; } else { peg$currPos = s0; @@ -814,7 +823,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c28(s1); + s1 = peg$c29(s1); } s0 = s1; @@ -825,22 +834,22 @@ function peg$parse(input, options) { var s0, s1; s0 = []; - if (peg$c29.test(input.charAt(peg$currPos))) { + if (peg$c30.test(input.charAt(peg$currPos))) { s1 = input.charAt(peg$currPos); peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c30); } + if (peg$silentFails === 0) { peg$fail(peg$c31); } } if (s1 !== peg$FAILED) { while (s1 !== peg$FAILED) { s0.push(s1); - if (peg$c29.test(input.charAt(peg$currPos))) { + if (peg$c30.test(input.charAt(peg$currPos))) { s1 = input.charAt(peg$currPos); peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c30); } + if (peg$silentFails === 0) { peg$fail(peg$c31); } } } } else { @@ -855,32 +864,32 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c31; + s1 = peg$c32; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } + if (peg$silentFails === 0) { peg$fail(peg$c33); } } if (s1 !== peg$FAILED) { - if (peg$c33.test(input.charAt(peg$currPos))) { + if (peg$c34.test(input.charAt(peg$currPos))) { s2 = input.charAt(peg$currPos); peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c34); } + if (peg$silentFails === 0) { peg$fail(peg$c35); } } if (s2 === peg$FAILED) { if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c31; + s2 = peg$c32; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } + if (peg$silentFails === 0) { peg$fail(peg$c33); } } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c35(s2); + s1 = peg$c36(s2); s0 = s1; } else { peg$currPos = s0; @@ -891,12 +900,12 @@ function peg$parse(input, options) { s0 = peg$FAILED; } if (s0 === peg$FAILED) { - if (peg$c36.test(input.charAt(peg$currPos))) { + if (peg$c37.test(input.charAt(peg$currPos))) { s0 = input.charAt(peg$currPos); peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c37); } + if (peg$silentFails === 0) { peg$fail(peg$c38); } } } @@ -908,32 +917,32 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c31; + s1 = peg$c32; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } + if (peg$silentFails === 0) { peg$fail(peg$c33); } } if (s1 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c23; + s2 = peg$c24; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c24); } + if (peg$silentFails === 0) { peg$fail(peg$c25); } } if (s2 === peg$FAILED) { if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c31; + s2 = peg$c32; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } + if (peg$silentFails === 0) { peg$fail(peg$c33); } } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c35(s2); + s1 = peg$c36(s2); s0 = s1; } else { peg$currPos = s0; @@ -944,12 +953,12 @@ function peg$parse(input, options) { s0 = peg$FAILED; } if (s0 === peg$FAILED) { - if (peg$c38.test(input.charAt(peg$currPos))) { + if (peg$c39.test(input.charAt(peg$currPos))) { s0 = input.charAt(peg$currPos); peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c39); } + if (peg$silentFails === 0) { peg$fail(peg$c40); } } } @@ -961,32 +970,32 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c31; + s1 = peg$c32; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } + if (peg$silentFails === 0) { peg$fail(peg$c33); } } if (s1 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 39) { - s2 = peg$c26; + s2 = peg$c27; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c27); } + if (peg$silentFails === 0) { peg$fail(peg$c28); } } if (s2 === peg$FAILED) { if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c31; + s2 = peg$c32; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c32); } + if (peg$silentFails === 0) { peg$fail(peg$c33); } } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$c35(s2); + s1 = peg$c36(s2); s0 = s1; } else { peg$currPos = s0; @@ -997,18 +1006,25 @@ function peg$parse(input, options) { s0 = peg$FAILED; } if (s0 === peg$FAILED) { - if (peg$c40.test(input.charAt(peg$currPos))) { + if (peg$c41.test(input.charAt(peg$currPos))) { s0 = input.charAt(peg$currPos); peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$c41); } + if (peg$silentFails === 0) { peg$fail(peg$c42); } } } return s0; } + + function addMeta(node, text, { start: { offset: start }, end: { offset: end } }) { + if (!options.addMeta) return node; + return { node, text, start, end }; + } + + peg$result = peg$startRuleFunction(); if (peg$result !== peg$FAILED && peg$currPos === input.length) { diff --git a/x-pack/plugins/canvas/common/lib/grammar.peg b/x-pack/plugins/canvas/common/lib/grammar.peg index 9701bec23e2b89..fea9564b67a271 100644 --- a/x-pack/plugins/canvas/common/lib/grammar.peg +++ b/x-pack/plugins/canvas/common/lib/grammar.peg @@ -7,28 +7,35 @@ * You shouldn't be futzing around in the grammar very often anyway. */ +{ + function addMeta(node, text, { start: { offset: start }, end: { offset: end } }) { + if (!options.addMeta) return node; + return { node, text, start, end }; + } +} + /* ----- Expressions ----- */ start = expression expression - = first:function rest:('|' fn:function { return fn; })* { - return { + = space? first:function rest:('|' space? fn:function { return fn; })* { + return addMeta({ type: 'expression', chain: [first].concat(rest) - }; + }, text(), location()); } /* ----- Functions ----- */ function "function" - = space? name:identifier arg_list:arg_list { - return { + = name:identifier arg_list:arg_list { + return addMeta({ type: 'function', function: name, arguments: arg_list - }; + }, text(), location()); } /* ----- Arguments ----- */ @@ -42,8 +49,10 @@ argument_assignment } argument - = '$'? '{' space? expression:expression space? '}' { return expression; } - / literal + = '$'? '{' expression:expression '}' { return expression; } + / value:literal { + return addMeta(value, text(), location()); + } arg_list = args:(space arg:argument_assignment { return arg; })* space? { diff --git a/x-pack/plugins/canvas/public/components/autocomplete/autocomplete.js b/x-pack/plugins/canvas/public/components/autocomplete/autocomplete.js new file mode 100644 index 00000000000000..a57f367407d899 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/autocomplete/autocomplete.js @@ -0,0 +1,240 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, keyCodes } from '@elastic/eui'; + +/** + * An autocomplete component. Currently this is only used for the expression editor but in theory + * it could be extended to any autocomplete-related component. It expects these props: + * + * header: The header node + * items: The list of items for autocompletion + * onSelect: The function to invoke when an item is selected (passing in the item) + * children: Any child nodes, which should include the text input itself + * reference: A function that is passed the selected item which generates a reference node + */ +export class Autocomplete extends React.Component { + static propTypes = { + header: PropTypes.node, + items: PropTypes.array, + onSelect: PropTypes.func, + children: PropTypes.node, + reference: PropTypes.func, + }; + + constructor() { + super(); + this.state = { + isOpen: false, + isFocused: false, + isMousedOver: false, + selectedIndex: -1, + }; + + // These are used for automatically scrolling items into view when selected + this.containerRef = null; + this.itemRefs = []; + } + + componentDidUpdate(prevProps, prevState) { + if ( + this.props.items && + prevProps.items !== this.props.items && + this.props.items.length === 1 && + this.state.selectedIndex !== 0 + ) + this.selectFirst(); + + if (prevState.selectedIndex !== this.state.selectedIndex) this.scrollIntoView(); + } + + selectFirst() { + this.setState({ selectedIndex: 0 }); + } + + isVisible() { + const { isOpen, isFocused, isMousedOver } = this.state; + const { items, reference } = this.props; + + // We have to check isMousedOver because the blur event fires before the click event, which + // means if we didn't keep track of isMousedOver, we wouldn't even get the click event + const visible = isOpen && (isFocused || isMousedOver); + const hasItems = items && items.length; + const hasReference = reference(this.getSelectedItem()); + + return visible && (hasItems || hasReference); + } + + getSelectedItem() { + return this.props.items && this.props.items[this.state.selectedIndex]; + } + + selectPrevious() { + const { items } = this.props; + const { selectedIndex } = this.state; + if (selectedIndex > 0) this.setState({ selectedIndex: selectedIndex - 1 }); + else this.setState({ selectedIndex: items.length - 1 }); + } + + selectNext() { + const { items } = this.props; + const { selectedIndex } = this.state; + if (selectedIndex >= 0 && selectedIndex < items.length - 1) + this.setState({ selectedIndex: selectedIndex + 1 }); + else this.setState({ selectedIndex: 0 }); + } + + scrollIntoView() { + const { + containerRef, + itemRefs, + state: { selectedIndex }, + } = this; + const itemRef = itemRefs[selectedIndex]; + if (!containerRef || !itemRef) return; + containerRef.scrollTop = Math.max( + Math.min(containerRef.scrollTop, itemRef.offsetTop), + itemRef.offsetTop + itemRef.offsetHeight - containerRef.offsetHeight + ); + } + + onSubmit = () => { + const { selectedIndex } = this.state; + const { items, onSelect } = this.props; + onSelect(items[selectedIndex]); + this.setState({ selectedIndex: -1 }); + }; + + /** + * Handle key down events for the menu, including selecting the previous and next items, making + * the item selection, closing the menu, etc. + */ + onKeyDown = e => { + const { ESCAPE, TAB, ENTER, UP, DOWN, LEFT, RIGHT } = keyCodes; + const { keyCode } = e; + const { items } = this.props; + const { isOpen, selectedIndex } = this.state; + + if ([ESCAPE, LEFT, RIGHT].includes(keyCode)) this.setState({ isOpen: false }); + + if ([TAB, ENTER].includes(keyCode) && isOpen && selectedIndex >= 0) { + e.preventDefault(); + this.onSubmit(); + } else if (keyCode === UP && items.length > 0 && isOpen) { + e.preventDefault(); + this.selectPrevious(); + } else if (keyCode === DOWN && items.length > 0 && isOpen) { + e.preventDefault(); + this.selectNext(); + } else if (e.key === 'Backspace') { + this.setState({ isOpen: true }); + } else if (!['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) { + this.setState({ selectedIndex: -1 }); + } + }; + + /** + * On key press (character keys), show the menu. We don't want to willy nilly show the menu + * whenever ANY key down event happens (like arrow keys) cuz that would be just downright + * annoying. + */ + onKeyPress = () => { + this.setState({ isOpen: true }); + }; + + onFocus = () => { + this.setState({ + isFocused: true, + }); + }; + + onBlur = () => { + this.setState({ + isFocused: false, + }); + }; + + onMouseDown = () => { + this.setState({ + isOpen: false, + }); + }; + + onMouseEnter = () => { + this.setState({ + isMousedOver: true, + }); + }; + + onMouseLeave = () => { + this.setState({ isMousedOver: false }); + }; + + render() { + const { header, items, reference } = this.props; + return ( +
+ {this.isVisible() ? ( + + + {items && items.length ? ( + +
(this.containerRef = ref)} + role="listbox" + > + {header} + {items.map((item, i) => ( +
(this.itemRefs[i] = ref)} + className={ + 'autocompleteItem' + + (this.state.selectedIndex === i ? ' autocompleteItem--isActive' : '') + } + onMouseEnter={() => this.setState({ selectedIndex: i })} + onClick={this.onSubmit} + role="option" + > + {item.text} +
+ ))} +
+
+ ) : ( + '' + )} + +
{reference(this.getSelectedItem())}
+
+
+
+ ) : ( + '' + )} +
{this.props.children}
+
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/autocomplete/autocomplete.scss b/x-pack/plugins/canvas/public/components/autocomplete/autocomplete.scss new file mode 100644 index 00000000000000..c9da34d87d0a15 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/autocomplete/autocomplete.scss @@ -0,0 +1,51 @@ +.autocomplete { + position: relative; +} + +.autocompletePopup { + position: absolute; + top: -262px; + height: 260px; + width: 100%; +} + +.autocompleteItems { + border-right: $euiBorderThin; +} + +.autocompleteItems, .autocompleteReference { + height: 258px; + overflow: auto; + @include euiScrollBar; +} + +.autocompleteReference { + padding: $euiSizeS $euiSizeM; + background-color: tintOrShade($euiColorLightestShade, 65%, 20%); +} + +.autocompleteItem { + padding: $euiSizeS $euiSizeM; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: $euiCodeFontFamily; + font-weight: $euiFontWeightRegular; +} + +.autocompleteItem--isActive { + color: $euiColorPrimary; + background-color: $euiFocusBackgroundColor; +} + +.autocompleteType { + padding: $euiSizeS; +} + +.autocompleteTable .euiTable { + background-color: transparent; +} + +.autocompleteDescList .euiDescriptionList__description { + margin-right: $euiSizeS; +} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/autocomplete/index.js b/x-pack/plugins/canvas/public/components/autocomplete/index.js new file mode 100644 index 00000000000000..d897737e6f686d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/autocomplete/index.js @@ -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 { Autocomplete } from './autocomplete'; diff --git a/x-pack/plugins/canvas/public/components/context_menu/context_menu.js b/x-pack/plugins/canvas/public/components/context_menu/context_menu.js deleted file mode 100644 index 43bbb9288940da..00000000000000 --- a/x-pack/plugins/canvas/public/components/context_menu/context_menu.js +++ /dev/null @@ -1,56 +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'; - -export const ContextMenu = ({ - items, - onSelect, - itemsStyle, - itemComponent, - children, - isOpen, - selectedIndex, - setSelectedIndex, - onKeyDown, - onKeyPress, -}) => ( -
- {children} - {isOpen && items.length ? ( -
- {items.map((item, i) => ( -
onSelect(item)} - onMouseOver={() => setSelectedIndex(i)} - > - {itemComponent({ item })} -
- ))} -
- ) : ( - '' - )} -
-); - -ContextMenu.propTypes = { - items: PropTypes.array, - onSelect: PropTypes.func, - itemsStyle: PropTypes.object, - itemComponent: PropTypes.func, - children: PropTypes.node, - isOpen: PropTypes.bool, - selectedIndex: PropTypes.number, - setSelectedIndex: PropTypes.func, - onKeyDown: PropTypes.func, - onKeyPress: PropTypes.func, -}; diff --git a/x-pack/plugins/canvas/public/components/context_menu/context_menu.scss b/x-pack/plugins/canvas/public/components/context_menu/context_menu.scss deleted file mode 100644 index f982f8b3deeddb..00000000000000 --- a/x-pack/plugins/canvas/public/components/context_menu/context_menu.scss +++ /dev/null @@ -1,28 +0,0 @@ -.contextMenu { - position: relative; - - .contextMenu__items { - position: absolute; - z-index: 1; - width: 100%; - display: flex; - flex-direction: column; - background: $euiColorEmptyShade; - border: $euiBorderThin; - - .contextMenu__item { - padding: $euiSizeS; - background-color: $euiColorEmptyShade; - border: $euiBorderThin; - color: $euiTextColor; - display: flex; - align-self: flex-start; - width: 100%; - - &-isActive { - background-color: $euiColorDarkShade; - color: $euiColorGhost; - } - } - } -} diff --git a/x-pack/plugins/canvas/public/components/context_menu/index.js b/x-pack/plugins/canvas/public/components/context_menu/index.js deleted file mode 100644 index d58137cf03c5e1..00000000000000 --- a/x-pack/plugins/canvas/public/components/context_menu/index.js +++ /dev/null @@ -1,18 +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 { compose, withState, withHandlers } from 'recompose'; -import { ContextMenu as Component } from './context_menu'; -import { onKeyDownProvider, onKeyPressProvider } from './key_handlers'; - -export const ContextMenu = compose( - withState('isOpen', 'setIsOpen', true), - withState('selectedIndex', 'setSelectedIndex', -1), - withHandlers({ - onKeyDown: onKeyDownProvider, - onKeyPress: onKeyPressProvider, - }) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/context_menu/key_handlers.js b/x-pack/plugins/canvas/public/components/context_menu/key_handlers.js deleted file mode 100644 index 5b40f9920a4096..00000000000000 --- a/x-pack/plugins/canvas/public/components/context_menu/key_handlers.js +++ /dev/null @@ -1,44 +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. - */ - -/** - * Handle key down events for the menu, including selecting the previous and - * next items, making the item selection, closing the menu, etc. - */ -export const onKeyDownProvider = ({ - items, - onSelect, - isOpen, - setIsOpen, - selectedIndex, - setSelectedIndex, -}) => e => { - if (!isOpen || !items.length) return; - const { key } = e; - if (key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((selectedIndex - 1 + items.length) % items.length); - } else if (key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((selectedIndex + 1) % items.length); - } else if (['Enter', 'Tab'].includes(key) && selectedIndex >= 0) { - e.preventDefault(); - onSelect(items[selectedIndex]); - setSelectedIndex(-1); - } else if (key === 'Escape') { - setIsOpen(false); - } -}; - -/** - * On key press (character keys), show the menu. We don't want to willy nilly - * show the menu whenever ANY key down event happens (like arrow keys) cuz that - * would be just downright annoying. - */ -export const onKeyPressProvider = ({ setIsOpen, setSelectedIndex }) => () => { - setIsOpen(true); - setSelectedIndex(-1); -}; diff --git a/x-pack/plugins/canvas/public/components/expression/expression.js b/x-pack/plugins/canvas/public/components/expression/expression.js index d901529616abc7..9644bb05d31d3c 100644 --- a/x-pack/plugins/canvas/public/components/expression/expression.js +++ b/x-pack/plugins/canvas/public/components/expression/expression.js @@ -6,21 +6,25 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { - EuiPanel, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, -} from '@elastic/eui'; +import { EuiPanel, EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { ExpressionInput } from '../expression_input'; -export const Expression = ({ formState, updateValue, setExpression, done, error }) => { +export const Expression = ({ + functionDefinitions, + formState, + updateValue, + setExpression, + done, + error, +}) => { return ( - - + @@ -43,6 +47,7 @@ export const Expression = ({ formState, updateValue, setExpression, done, error }; Expression.propTypes = { + functionDefinitions: PropTypes.array, formState: PropTypes.object, updateValue: PropTypes.func, setExpression: PropTypes.func, diff --git a/x-pack/plugins/canvas/public/components/expression/index.js b/x-pack/plugins/canvas/public/components/expression/index.js index d5f706765380ff..16dc36da13e55d 100644 --- a/x-pack/plugins/canvas/public/components/expression/index.js +++ b/x-pack/plugins/canvas/public/components/expression/index.js @@ -15,6 +15,7 @@ import { renderComponent, } from 'recompose'; import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad'; +import { getFunctionDefinitions } from '../../state/selectors/app'; import { setExpression, flushContext } from '../../state/actions/elements'; import { fromExpression } from '../../../common/lib/ast'; import { ElementNotSelected } from './element_not_selected'; @@ -23,6 +24,7 @@ import { Expression as Component } from './expression'; const mapStateToProps = state => ({ pageId: getSelectedPage(state), element: getSelectedElement(state), + functionDefinitions: getFunctionDefinitions(state), }); const mapDispatchToProps = dispatch => ({ diff --git a/x-pack/plugins/canvas/public/components/expression_input/argument_reference.js b/x-pack/plugins/canvas/public/components/expression_input/argument_reference.js new file mode 100644 index 00000000000000..dd72f0080024b6 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression_input/argument_reference.js @@ -0,0 +1,44 @@ +/* + * 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 }) => ( +
+ +

{argDef.name}

+
+ + + + + + +
+); + +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, +}; diff --git a/x-pack/plugins/canvas/public/components/expression_input/expression_input.js b/x-pack/plugins/canvas/public/components/expression_input/expression_input.js index 1f9a8a9c714534..076c8e07589e9c 100644 --- a/x-pack/plugins/canvas/public/components/expression_input/expression_input.js +++ b/x-pack/plugins/canvas/public/components/expression_input/expression_input.js @@ -6,14 +6,23 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { EuiTextArea, EuiFormRow } from '@elastic/eui'; -import { ContextMenu } from '../context_menu'; -import { matchPairsProvider } from './match_pairs'; -import { Suggestion } from './suggestion'; +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, onChange }) { + constructor({ value }) { super(); + + this.undoHistory = []; + this.redoHistory = []; + this.state = { selection: { start: value.length, @@ -21,11 +30,6 @@ export class ExpressionInput extends React.Component { }, suggestions: [], }; - - this.matchPairs = matchPairsProvider({ - setValue: onChange, - setSelection: selection => this.setState({ selection }), - }); } componentDidUpdate() { @@ -35,6 +39,53 @@ export class ExpressionInput extends React.Component { 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); + } + + 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; @@ -45,36 +96,42 @@ export class ExpressionInput extends React.Component { this.updateState({ value, selection }); }; - onSuggestionSelect = suggestion => { - const value = - this.props.value.substr(0, suggestion.location.start) + - suggestion.value + - this.props.value.substr(suggestion.location.end); - const selection = { - start: suggestion.location.start + suggestion.value.length, - end: suggestion.location.start + suggestion.value.length, - }; - this.updateState({ value, selection }); - }; - updateState = ({ value, selection }) => { - const suggestions = []; + this.stash(this.props.value); + const suggestions = getAutocompleteSuggestions( + this.props.functionDefinitions, + value, + selection.start + ); this.props.onChange(value); this.setState({ selection, suggestions }); }; - // TODO: Use a hidden div and measure it rather than using hardcoded values - getContextMenuItemsStyle = () => { - const { value } = this.props; - const { - selection: { end }, - } = this.state; - const numberOfNewlines = value.substr(0, end).split('\n').length; - const padding = 12; - const lineHeight = 22; - const textareaHeight = 200; - const top = Math.min(padding + numberOfNewlines * lineHeight, textareaHeight) + 'px'; - return { top }; + getHeader = () => { + const { suggestions } = this.state; + if (!suggestions.length) return ''; + return ( + +

{startCase(suggestions[0].type)}

+
+ ); + }; + + getReference = selectedItem => { + const { fnDef, argDef } = selectedItem || {}; + if (argDef) return ; + if (fnDef) return ; + + const { fnDef: fnDefAtPosition, argDef: argDefAtPosition } = getFnArgDefAtPosition( + this.props.functionDefinitions, + this.props.value, + this.state.selection.start + ); + + if (argDefAtPosition) return ; + if (fnDefAtPosition) return ; + + return ''; }; render() { @@ -86,28 +143,30 @@ export class ExpressionInput extends React.Component { : 'This is the coded expression that backs this element. You better know what you are doing here.'; return (
- - + + (this.ref = ref)} + spellcheck="false" /> - - + +
); } } ExpressionInput.propTypes = { + functionDefinitions: PropTypes.array, value: PropTypes.string, onChange: PropTypes.func, error: PropTypes.string, diff --git a/x-pack/plugins/canvas/public/components/expression_input/function_reference.js b/x-pack/plugins/canvas/public/components/expression_input/function_reference.js new file mode 100644 index 00000000000000..0f9076b7613a83 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/expression_input/function_reference.js @@ -0,0 +1,74 @@ +/* + * 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 }) => ( +
+ +

{fnDef.name}

+
+ + + + + + + + + +
+); + +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, +}; diff --git a/x-pack/plugins/canvas/public/components/expression_input/match_pairs.js b/x-pack/plugins/canvas/public/components/expression_input/match_pairs.js deleted file mode 100644 index 01d96f7304b759..00000000000000 --- a/x-pack/plugins/canvas/public/components/expression_input/match_pairs.js +++ /dev/null @@ -1,62 +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. - */ - -/** - * Provides an `onKeyDown` handler that automatically inserts matching pairs. - * Specifically, it does the following: - * - * 1. If we don't have a multi-character selection, and the key is a closer, - * and the character in front of the cursor is the same, simply move the - * cursor forward. - * 2. If the key is an opener, insert the opener at the beginning of the - * selection, and the closer at the end of the selection, and move the - * selection forward. - * 3. If we don't have a multi-character selection, and the backspace is hit, - * and the characters before and after the cursor correspond to a pair, - * remove both characters and move the cursor backward. - */ -export const matchPairsProvider = ({ - pairs = ['()', '[]', '{}', `''`, '""'], - setValue, - setSelection, -}) => { - const openers = pairs.map(pair => pair[0]); - const closers = pairs.map(pair => pair[1]); - return e => { - const { target, key } = e; - const { value, selectionStart, selectionEnd } = target; - if ( - selectionStart === selectionEnd && - closers.includes(key) && - value.charAt(selectionEnd) === key - ) { - // 1. (See above) - e.preventDefault(); - setSelection({ start: selectionStart + 1, end: selectionEnd + 1 }); - } else if (openers.includes(key)) { - // 2. (See above) - e.preventDefault(); - setValue( - value.substr(0, selectionStart) + - key + - value.substring(selectionStart, selectionEnd) + - closers[openers.indexOf(key)] + - value.substr(selectionEnd) - ); - setSelection({ start: selectionStart + 1, end: selectionEnd + 1 }); - } else if ( - selectionStart === selectionEnd && - key === 'Backspace' && - !e.metaKey && - pairs.includes(value.substr(selectionEnd - 1, 2)) - ) { - // 3. (See above) - e.preventDefault(); - setValue(value.substr(0, selectionEnd - 1) + value.substr(selectionEnd + 1)); - setSelection({ start: selectionStart - 1, end: selectionEnd - 1 }); - } - }; -}; diff --git a/x-pack/plugins/canvas/public/components/expression_input/suggestion.js b/x-pack/plugins/canvas/public/components/expression_input/suggestion.js deleted file mode 100644 index ec8b6467a6ad98..00000000000000 --- a/x-pack/plugins/canvas/public/components/expression_input/suggestion.js +++ /dev/null @@ -1,19 +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'; - -export const Suggestion = ({ item }) => ( -
-
{item.name}
-
{item.description}
-
-); - -Suggestion.propTypes = { - item: PropTypes.object, -}; diff --git a/x-pack/plugins/canvas/public/components/expression_input/suggestion.scss b/x-pack/plugins/canvas/public/components/expression_input/suggestion.scss deleted file mode 100644 index f1748578d1ffa6..00000000000000 --- a/x-pack/plugins/canvas/public/components/expression_input/suggestion.scss +++ /dev/null @@ -1,12 +0,0 @@ -.canvasExpressionSuggestion { - display: flex; - - .canvasExpressionSuggestion__name { - width: 120px; - font-weight: bold; - } - - .canvasExpressionSuggestion__desc { - width: calc(100% - 120px); - } -} diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss index 9c40e5d99b3504..bf69fba54feb4b 100644 --- a/x-pack/plugins/canvas/public/style/index.scss +++ b/x-pack/plugins/canvas/public/style/index.scss @@ -20,12 +20,12 @@ @import '../components/arg_add_popover/arg_add_popover'; @import '../components/arg_form/arg_form'; @import '../components/asset_manager/asset_manager'; +@import '../components/autocomplete/autocomplete'; @import '../components/border_connection/border_connection'; @import '../components/border_resize_handle/border_resize_handle'; @import '../components/color_dot/color_dot'; @import '../components/color_palette/color_palette'; @import '../components/color_picker_mini/color_picker_mini'; -@import '../components/context_menu/context_menu'; @import '../components/datasource/datasource'; @import '../components/datasource/datasource_preview/datasource_preview'; @import '../components/datatable/datatable'; @@ -33,7 +33,6 @@ @import '../components/dom_preview/dom_preview'; @import '../components/element_content/element_content'; @import '../components/element_types/element_types'; -@import '../components/expression_input/suggestion'; @import '../components/fullscreen/fullscreen'; @import '../components/function_form/function_form'; @import '../components/hover_annotation/hover_annotation'; diff --git a/x-pack/plugins/canvas/public/style/main.scss b/x-pack/plugins/canvas/public/style/main.scss index d3e0b00894c6b9..acf8a648454c79 100644 --- a/x-pack/plugins/canvas/public/style/main.scss +++ b/x-pack/plugins/canvas/public/style/main.scss @@ -24,7 +24,7 @@ .canvasTextArea--code { @include euiScrollBar; font-size: $euiFontSize; - font-family: monospace; + font-family: $euiCodeFontFamily; width: 100%; max-width: 100%; }