diff --git a/x-pack/legacy/plugins/canvas/__tests__/fixtures/function_specs.js b/x-pack/legacy/plugins/canvas/__tests__/fixtures/function_specs.ts similarity index 95% rename from x-pack/legacy/plugins/canvas/__tests__/fixtures/function_specs.js rename to x-pack/legacy/plugins/canvas/__tests__/fixtures/function_specs.ts index 9bae58f385f5e..899fbf3d3d91b 100644 --- a/x-pack/legacy/plugins/canvas/__tests__/fixtures/function_specs.js +++ b/x-pack/legacy/plugins/canvas/__tests__/fixtures/function_specs.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +// @ts-ignore Untyped Library import { Fn } from '@kbn/interpreter/common'; import { functions as browserFns } from '../../canvas_plugin_src/functions/browser'; import { functions as commonFns } from '../../canvas_plugin_src/functions/common'; diff --git a/x-pack/legacy/plugins/canvas/__tests__/fixtures/workpads.js b/x-pack/legacy/plugins/canvas/__tests__/fixtures/workpads.js deleted file mode 100644 index f2db3326be189..0000000000000 --- a/x-pack/legacy/plugins/canvas/__tests__/fixtures/workpads.js +++ /dev/null @@ -1,119 +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. - */ - -export const workpads = [ - { - pages: [ - { - elements: [ - { - expression: ` - demodata | - ply by=age fn={rowCount | as count} | - staticColumn total value={math 'sum(count)'} | - mapColumn percentage fn={math 'count/total * 100'} | - sort age | - pointseries x=age y=percentage | - plot defaultStyle={seriesStyle points=0 lines=5}`, - }, - ], - }, - ], - }, - { - pages: [{ elements: [{ expression: 'filters | demodata | markdown "hello" | render' }] }], - }, - { - pages: [ - { - elements: [ - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'filters | demodata | markdown "hello" | render' }, - { expression: 'filters | demodata | pointseries | pie | render' }, - ], - }, - { elements: [{ expression: 'filters | demodata | table | render' }] }, - { elements: [{ expression: 'image | render' }] }, - { elements: [{ expression: 'image | render' }] }, - ], - }, - { - pages: [ - { - elements: [ - { expression: 'filters | demodata | markdown "hello" | render' }, - { expression: 'filters | demodata | markdown "hello" | render' }, - { expression: 'image | render' }, - ], - }, - { - elements: [ - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'filters | demodata | markdown "hello" | render' }, - { expression: 'filters | demodata | pointseries | pie | render' }, - { expression: 'image | render' }, - ], - }, - { - elements: [ - { expression: 'filters | demodata | pointseries | pie | render' }, - { - expression: - 'filters | demodata | pointseries | plot defaultStyle={seriesStyle points=0 lines=5} | render', - }, - ], - }, - ], - }, - { - pages: [ - { - elements: [ - { expression: 'demodata | render as=debug' }, - { expression: 'filters | demodata | pointseries | plot | render' }, - { expression: 'filters | demodata | table | render' }, - { expression: 'filters | demodata | table | render' }, - ], - }, - { - elements: [ - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'filters | demodata | pointseries | pie | render' }, - { expression: 'image | render' }, - ], - }, - { - elements: [ - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'demodata | render as=debug' }, - { expression: 'shape "square" | render' }, - ], - }, - ], - }, - { - pages: [ - { - elements: [ - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'filters | demodata | markdown "hello" | render' }, - ], - }, - { elements: [{ expression: 'image | render' }] }, - { elements: [{ expression: 'image | render' }] }, - { elements: [{ expression: 'filters | demodata | table | render' }] }, - ], - }, -]; - -export const elements = [ - { expression: 'demodata | pointseries | getCell | repeatImage | render' }, - { expression: 'filters | demodata | markdown "hello" | render' }, - { expression: 'filters | demodata | pointseries | pie | render' }, - { expression: 'image | render' }, -]; diff --git a/x-pack/legacy/plugins/canvas/__tests__/fixtures/workpads.ts b/x-pack/legacy/plugins/canvas/__tests__/fixtures/workpads.ts new file mode 100644 index 0000000000000..0251095c9e75e --- /dev/null +++ b/x-pack/legacy/plugins/canvas/__tests__/fixtures/workpads.ts @@ -0,0 +1,184 @@ +/* + * 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 { CanvasWorkpad, CanvasElement, CanvasPage } from '../../types'; + +const BaseWorkpad: CanvasWorkpad = { + name: 'base workpad', + id: 'base-workpad', + width: 0, + height: 0, + css: '', + page: 1, + pages: [], + colors: [], + isWriteable: true, +}; + +const BasePage: CanvasPage = { + id: 'base-page', + style: { background: 'white' }, + transition: {}, + elements: [], + groups: [], +}; +const BaseElement: CanvasElement = { + position: { + top: 0, + left: 0, + width: 0, + height: 0, + angle: 0, + parent: null, + }, + id: 'base-id', + type: 'element', + expression: 'render', + filter: '', +}; + +export const workpads: CanvasWorkpad[] = [ + { + ...BaseWorkpad, + pages: [ + { + ...BasePage, + elements: [ + { + ...BaseElement, + expression: ` + demodata | + ply by=age fn={rowCount | as count} | + staticColumn total value={math 'sum(count)'} | + mapColumn percentage fn={math 'count/total * 100'} | + sort age | + pointseries x=age y=percentage | + plot defaultStyle={seriesStyle points=0 lines=5}`, + }, + ], + }, + ], + }, + { + ...BaseWorkpad, + pages: [ + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'filters | demodata | markdown "hello" | render' }, + ], + }, + ], + }, + { + ...BaseWorkpad, + pages: [ + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'filters | demodata | markdown "hello" | render' }, + { ...BaseElement, expression: 'filters | demodata | pointseries | pie | render' }, + ], + }, + { + ...BasePage, + elements: [{ ...BaseElement, expression: 'filters | demodata | table | render' }], + }, + { ...BasePage, elements: [{ ...BaseElement, expression: 'image | render' }] }, + { ...BasePage, elements: [{ ...BaseElement, expression: 'image | render' }] }, + ], + }, + { + ...BaseWorkpad, + pages: [ + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'filters | demodata | markdown "hello" | render' }, + { ...BaseElement, expression: 'filters | demodata | markdown "hello" | render' }, + { ...BaseElement, expression: 'image | render' }, + ], + }, + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'filters | demodata | markdown "hello" | render' }, + { ...BaseElement, expression: 'filters | demodata | pointseries | pie | render' }, + { ...BaseElement, expression: 'image | render' }, + ], + }, + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'filters | demodata | pointseries | pie | render' }, + { + ...BaseElement, + expression: + 'filters | demodata | pointseries | plot defaultStyle={seriesStyle points=0 lines=5} | render', + }, + ], + }, + ], + }, + { + ...BaseWorkpad, + pages: [ + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'demodata | render as=debug' }, + { ...BaseElement, expression: 'filters | demodata | pointseries | plot | render' }, + { ...BaseElement, expression: 'filters | demodata | table | render' }, + { ...BaseElement, expression: 'filters | demodata | table | render' }, + ], + }, + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'filters | demodata | pointseries | pie | render' }, + { ...BaseElement, expression: 'image | render' }, + ], + }, + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'demodata | render as=debug' }, + { ...BaseElement, expression: 'shape "square" | render' }, + ], + }, + ], + }, + { + ...BaseWorkpad, + pages: [ + { + ...BasePage, + elements: [ + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'filters | demodata | markdown "hello" | render' }, + ], + }, + { ...BasePage, elements: [{ ...BaseElement, expression: 'image | render' }] }, + { ...BasePage, elements: [{ ...BaseElement, expression: 'image | render' }] }, + { + ...BasePage, + elements: [{ ...BaseElement, expression: 'filters | demodata | table | render' }], + }, + ], + }, +]; + +export const elements: CanvasElement[] = [ + { ...BaseElement, expression: 'demodata | pointseries | getCell | repeatImage | render' }, + { ...BaseElement, expression: 'filters | demodata | markdown "hello" | render' }, + { ...BaseElement, expression: 'filters | demodata | pointseries | pie | render' }, + { ...BaseElement, expression: 'image | render' }, +]; diff --git a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.js b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.js deleted file mode 100644 index 4b6417aed8e98..0000000000000 --- a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.js +++ /dev/null @@ -1,231 +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 { uniq } from 'lodash'; -import { parse, getByAlias } from '@kbn/interpreter/common'; - -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/legacy/plugins/canvas/common/lib/__tests__/autocomplete.js b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts similarity index 69% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/autocomplete.js rename to x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts index d4008c7f9b48e..8ee9991ec7db4 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/autocomplete.js +++ b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts @@ -4,34 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { functionSpecs } from '../../../__tests__/fixtures/function_specs'; -import { getAutocompleteSuggestions } from '../autocomplete'; +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); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(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); + expect(nonmatching.length).toBe(0); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(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); + expect(suggestions.length).toBe(Object.keys(plotFn.args).length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(expression.length); }); it('should suggest arguments filtered by text', () => { @@ -39,28 +39,28 @@ describe('getAutocompleteSuggestions', () => { 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); + expect(suggestions.length).toBe(matchingArgs.length); + expect(suggestions[0].start).toBe('plot '.length); + expect(suggestions[0].end).toBe('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); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(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); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - 'ar'.length); + expect(suggestions[0].end).toBe(expression.length); }); it('should suggest functions inside an expression', () => { @@ -70,9 +70,9 @@ describe('getAutocompleteSuggestions', () => { 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); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); }); it('should suggest arguments inside an expression', () => { @@ -83,9 +83,9 @@ describe('getAutocompleteSuggestions', () => { 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); + expect(suggestions.length).toBe(Object.keys(ltFn.args).length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); }); it('should suggest values inside an expression', () => { @@ -96,9 +96,9 @@ describe('getAutocompleteSuggestions', () => { 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); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); }); it('should suggest values inside quotes', () => { @@ -109,10 +109,10 @@ describe('getAutocompleteSuggestions', () => { 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); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - '"ar"'.length); + expect(suggestions[0].end).toBe(expression.length); }); it('should prioritize functions that start with text', () => { @@ -122,7 +122,7 @@ describe('getAutocompleteSuggestions', () => { const alterColumnIndex = suggestions.findIndex(suggestion => suggestion.text.includes('alterColumn') ); - expect(tableIndex).to.be.lessThan(alterColumnIndex); + expect(tableIndex).toBeLessThan(alterColumnIndex); }); it('should prioritize functions that match the previous function type', () => { @@ -130,7 +130,7 @@ describe('getAutocompleteSuggestions', () => { 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); + expect(renderIndex).toBeLessThan(anyIndex); }); it('should alphabetize functions', () => { @@ -138,7 +138,7 @@ describe('getAutocompleteSuggestions', () => { 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); + expect(anyIndex).toBeLessThan(metricIndex); }); it('should prioritize arguments that start with text', () => { @@ -148,7 +148,7 @@ describe('getAutocompleteSuggestions', () => { const defaultStyleIndex = suggestions.findIndex(suggestion => suggestion.text.includes('defaultStyle') ); - expect(yaxisIndex).to.be.lessThan(defaultStyleIndex); + expect(yaxisIndex).toBeLessThan(defaultStyleIndex); }); it('should prioritize unnamed arguments', () => { @@ -156,7 +156,7 @@ describe('getAutocompleteSuggestions', () => { 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); + expect(whenIndex).toBeLessThan(thenIndex); }); it('should alphabetize arguments', () => { @@ -166,24 +166,24 @@ describe('getAutocompleteSuggestions', () => { const defaultStyleIndex = suggestions.findIndex(suggestion => suggestion.text.includes('defaultStyle') ); - expect(defaultStyleIndex).to.be.lessThan(yaxisIndex); + expect(defaultStyleIndex).toBeLessThan(yaxisIndex); }); it('should quote string values', () => { const expression = 'shape shape='; const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).to.match(/^".*"$/); + expect(suggestions[0].text.trim()).toMatch(/^".*"$/); }); 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}'); + expect(suggestions[0].text.trim()).toBe('{font}'); }); it('should not quote booleans', () => { const expression = 'table paginate=true'; const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).to.be('true'); + expect(suggestions[0].text.trim()).toBe('true'); }); }); diff --git a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.ts b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.ts new file mode 100644 index 0000000000000..c8f7e13bfb313 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.ts @@ -0,0 +1,376 @@ +/* + * 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'; +// @ts-ignore Untyped Library +import { parse, getByAlias as untypedGetByAlias } from '@kbn/interpreter/common'; +import { + ExpressionAST, + ExpressionFunctionAST, + ExpressionArgAST, + CanvasFunction, +} from '../../types'; + +const MARKER = 'CANVAS_SUGGESTION_MARKER'; + +// 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 +interface ASTMetaInformation { + start: number; + end: number; + text: string; + node: T; +} + +// Wraps ExpressionArg with meta or replace ExpressionAST with ExpressionASTWithMeta +type WrapExpressionArgWithMeta = T extends ExpressionAST + ? ExpressionASTWithMeta + : ASTMetaInformation; + +type ExpressionArgASTWithMeta = WrapExpressionArgWithMeta; + +type Modify = Pick> & R; + +// Wrap ExpressionFunctionAST with meta and modify arguments to be wrapped with meta +type ExpressionFunctionASTWithMeta = Modify< + ExpressionFunctionAST, + { + arguments: { + [key: string]: ExpressionArgASTWithMeta[]; + }; + } +>; + +// Wrap ExpressionFunctionAST with meta and modify chain to be wrapped with meta +type ExpressionASTWithMeta = ASTMetaInformation< + Modify< + ExpressionAST, + { + chain: Array>; + } + > +>; + +// Typeguard for checking if ExpressionArg is a new expression +function isExpression( + maybeExpression: ExpressionArgASTWithMeta +): maybeExpression is ExpressionASTWithMeta { + return typeof maybeExpression.node === 'object'; +} + +type valueof = T[keyof T]; +type ValuesOfUnion = T extends any ? valueof : never; + +// All of the possible Arg Values +type ArgValue = ValuesOfUnion; +// 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 { + return untypedGetByAlias(specs, name); +} + +/** + * 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: CanvasFunction[], + 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: 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: CanvasFunction[], + expression: string, + position: number +) { + const text = expression.substr(0, position) + MARKER + expression.substr(position); + try { + const ast = parse(text, { addMeta: true }) as ExpressionASTWithMeta; + 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 === '_' && argIndex !== undefined) { + return getArgNameSuggestions(specs, newAst, fnIndex, argName, argIndex); + } + + if (argName && argIndex !== undefined) { + return getArgValueSuggestions(specs, newAst, fnIndex, argName, argIndex); + } + } catch (e) { + // Fail silently + } + return []; +} + +/** + Each entry of the node.chain has it's overall start and end position. For instance, + given the expression "link arg='something' | render" the link functions start position is 0 and end + position is 21. + + This function is given the full ast and the current cursor position in the expression string. + + 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 } { + 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 && isExpression(value)) { + return getFnArgAtPosition(value, position); + } + return { ast, fnIndex, argName, argIndex }; + } + } + } + return { ast, fnIndex }; +} + +function getFnNameSuggestions( + specs: CanvasFunction[], + ast: ExpressionASTWithMeta, + fnIndex: number +) { + // 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: CanvasFunction[], + ast: ExpressionASTWithMeta, + fnIndex: number, + argName: string, + argIndex: number +) { + // 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.entries(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<[string, ExpressionArgASTWithMeta[]]>( + ([name, values]) => { + return [name, values.filter(value => !value.text.includes(MARKER))]; + } + ); + + const unusedArgDefs = matchingArgDefs.filter(([matchingArgName, matchingArgDef]) => { + if (matchingArgDef.multi) { + return true; + } + return !argEntries.some(([name, values]) => { + return ( + values.length > 0 && + (name === matchingArgName || (matchingArgDef.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.map(([name, arg]) => ({ name, ...arg })).sort(comparator); + + return argDefs.map(argDef => { + return { type: 'argument', text: argDef.name + '=', start, end: end - MARKER.length, argDef }; + }); +} + +function getArgValueSuggestions( + specs: CanvasFunction[], + ast: ExpressionASTWithMeta, + fnIndex: number, + argName: string, + argIndex: number +) { + // 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]; + if (typeof node !== 'string') { + return []; + } + const query = node.replace(MARKER, ''); + const argOptions = argDef.options ? argDef.options : []; + + let suggestions = [...argOptions]; + + if (argDef.default !== undefined) { + suggestions.push(argDef.default); + } + + suggestions = uniq(suggestions); + + // 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: string, query: string): boolean { + return text.toLowerCase().includes(query.toLowerCase().trim()); +} + +function maybeQuote(value: any) { + if (typeof value === 'string') { + if (value.match(/^\{.*\}$/)) { + return value; + } + return `"${value.replace(/"/g, '\\"')}"`; + } + return value; +} + +function prevFnTypeComparator(prevFnType: any) { + return (a: CanvasFunction, b: CanvasFunction): number => { + return ( + (b.context && b.context.types && b.context.types.includes(prevFnType) ? 1 : 0) - + (a.context && a.context.types && a.context.types.includes(prevFnType) ? 1 : 0) + ); + }; +} + +function unnamedArgComparator(a: ArgValue, b: ArgValue): number { + return ( + (b.aliases && b.aliases.includes('_') ? 1 : 0) - (a.aliases && a.aliases.includes('_') ? 1 : 0) + ); +} + +function alphanumericalComparator(a: any, b: any): number { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +} + +function startsWithComparator(query: string) { + return (a: any, b: any) => + (String(b).startsWith(query) ? 1 : 0) - (String(a).startsWith(query) ? 1 : 0); +} + +type Comparator = (a: T, b: T) => number; + +function combinedComparator(...comparators: Array>): Comparator { + return (a: T, b: T) => + comparators.reduce((acc: number, comparator) => { + if (acc !== 0) { + return acc; + } + return comparator(a, b); + }, 0); +} + +function invokeWithProp< + PropType, + PropName extends string, + ArgType extends { [key in PropName]: PropType }, + FnReturnType +>(fn: (...args: PropType[]) => FnReturnType, prop: PropName): (...args: ArgType[]) => FnReturnType { + return (...args: Array<{ [key in PropName]: PropType }>) => { + return fn(...args.map(arg => arg[prop])); + }; +} diff --git a/x-pack/legacy/plugins/canvas/server/usage/collector_helpers.ts b/x-pack/legacy/plugins/canvas/server/usage/collector_helpers.ts index d86ebf4b4513e..784042fb4d94d 100644 --- a/x-pack/legacy/plugins/canvas/server/usage/collector_helpers.ts +++ b/x-pack/legacy/plugins/canvas/server/usage/collector_helpers.ts @@ -9,10 +9,14 @@ * @param cb: callback to do something with a function that has been found */ -import { AST } from '../../types'; +import { ExpressionAST, ExpressionArgAST } from '../../types'; -export function collectFns(ast: AST, cb: (functionName: string) => void) { - if (ast.type === 'expression') { +function isExpression(maybeExpression: ExpressionArgAST): maybeExpression is ExpressionAST { + return typeof maybeExpression === 'object'; +} + +export function collectFns(ast: ExpressionArgAST, cb: (functionName: string) => void) { + if (isExpression(ast)) { ast.chain.forEach(({ function: cFunction, arguments: cArguments }) => { cb(cFunction); diff --git a/x-pack/legacy/plugins/canvas/server/usage/__tests__/custom_element_collector.ts b/x-pack/legacy/plugins/canvas/server/usage/custom_element_collector.test.ts similarity index 71% rename from x-pack/legacy/plugins/canvas/server/usage/__tests__/custom_element_collector.ts rename to x-pack/legacy/plugins/canvas/server/usage/custom_element_collector.test.ts index f75ff9646c06b..f09bb704b09e3 100644 --- a/x-pack/legacy/plugins/canvas/server/usage/__tests__/custom_element_collector.ts +++ b/x-pack/legacy/plugins/canvas/server/usage/custom_element_collector.test.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { summarizeCustomElements } from '../custom_element_collector'; -import { TelemetryCustomElementDocument } from '../../../types'; +import { summarizeCustomElements } from './custom_element_collector'; +import { TelemetryCustomElementDocument } from '../../types'; function mockCustomElement(...nodeExpressions: string[]): TelemetryCustomElementDocument { return { @@ -21,7 +20,7 @@ function mockCustomElement(...nodeExpressions: string[]): TelemetryCustomElement describe('custom_element_collector.handleResponse', () => { describe('invalid responses', () => { it('returns nothing if no valid hits', () => { - expect(summarizeCustomElements([])).to.eql({}); + expect(summarizeCustomElements([])).toEqual({}); }); it('returns nothing if no valid elements', () => { @@ -31,7 +30,7 @@ describe('custom_element_collector.handleResponse', () => { }, ]; - expect(summarizeCustomElements(customElements)).to.eql({}); + expect(summarizeCustomElements(customElements)).toEqual({}); }); }); @@ -39,10 +38,10 @@ describe('custom_element_collector.handleResponse', () => { const elements = [mockCustomElement(''), mockCustomElement('')]; const data = summarizeCustomElements(elements); - expect(data.custom_elements).to.not.be(null); + expect(data.custom_elements).not.toBe(null); if (data.custom_elements) { - expect(data.custom_elements.count).to.equal(elements.length); + expect(data.custom_elements.count).toEqual(elements.length); } }); @@ -54,10 +53,10 @@ describe('custom_element_collector.handleResponse', () => { const elements = [mockCustomElement(functions1.join('|')), mockCustomElement(...functions2)]; const data = summarizeCustomElements(elements); - expect(data.custom_elements).to.not.be(null); + expect(data.custom_elements).not.toBe(null); if (data.custom_elements) { - expect(data.custom_elements.functions_in_use).to.eql(expectedFunctions); + expect(data.custom_elements.functions_in_use).toEqual(expectedFunctions); } }); @@ -74,12 +73,12 @@ describe('custom_element_collector.handleResponse', () => { ]; const result = summarizeCustomElements(elements); - expect(result.custom_elements).to.not.be(null); + expect(result.custom_elements).not.toBe(null); if (result.custom_elements) { - expect(result.custom_elements.elements.max).to.equal(functionsMax.length); - expect(result.custom_elements.elements.min).to.equal(functionsMin.length); - expect(result.custom_elements.elements.avg).to.equal(avgFunctions); + expect(result.custom_elements.elements.max).toEqual(functionsMax.length); + expect(result.custom_elements.elements.min).toEqual(functionsMin.length); + expect(result.custom_elements.elements.avg).toEqual(avgFunctions); } }); }); diff --git a/x-pack/legacy/plugins/canvas/server/usage/custom_element_collector.ts b/x-pack/legacy/plugins/canvas/server/usage/custom_element_collector.ts index b7cc439169457..9def60f9c7111 100644 --- a/x-pack/legacy/plugins/canvas/server/usage/custom_element_collector.ts +++ b/x-pack/legacy/plugins/canvas/server/usage/custom_element_collector.ts @@ -9,7 +9,7 @@ import { get } from 'lodash'; import { fromExpression } from '@kbn/interpreter/common'; import { collectFns } from './collector_helpers'; import { TelemetryCollector } from '../../types'; -import { AST, TelemetryCustomElement, TelemetryCustomElementDocument } from '../../types'; +import { ExpressionAST, TelemetryCustomElement, TelemetryCustomElementDocument } from '../../types'; const CUSTOM_ELEMENT_TYPE = 'canvas-element'; interface CustomElementSearch { @@ -48,7 +48,6 @@ function parseJsonOrNull(maybeJson: string) { /** Calculate statistics about a collection of CustomElement Documents - @param customElements - Array of CustomElement documents @returns Statistics about how Custom Elements are being used */ @@ -76,7 +75,7 @@ export function summarizeCustomElements( parsedContents.map(contents => { contents.selectedNodes.map(node => { - const ast: AST = fromExpression(node.expression) as AST; // TODO: Remove once fromExpression is properly typed + const ast: ExpressionAST = fromExpression(node.expression) as ExpressionAST; // TODO: Remove once fromExpression is properly typed collectFns(ast, (cFunction: string) => { functionSet.add(cFunction); }); diff --git a/x-pack/legacy/plugins/canvas/server/usage/__tests__/workpad_collector.ts b/x-pack/legacy/plugins/canvas/server/usage/workpad_collector.test.ts similarity index 63% rename from x-pack/legacy/plugins/canvas/server/usage/__tests__/workpad_collector.ts rename to x-pack/legacy/plugins/canvas/server/usage/workpad_collector.test.ts index a59eab5c98036..420b785771bfe 100644 --- a/x-pack/legacy/plugins/canvas/server/usage/__tests__/workpad_collector.ts +++ b/x-pack/legacy/plugins/canvas/server/usage/workpad_collector.test.ts @@ -4,15 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { summarizeWorkpads } from '../workpad_collector'; -// @ts-ignore Missing local definition -import { workpads } from '../../../__tests__/fixtures/workpads'; +import clonedeep from 'lodash.clonedeep'; +import { summarizeWorkpads } from './workpad_collector'; +import { workpads } from '../../__tests__/fixtures/workpads'; describe('usage collector handle es response data', () => { it('should summarize workpads, pages, and elements', () => { const usage = summarizeWorkpads(workpads); - expect(usage).to.eql({ + expect(usage).toEqual({ workpads: { total: 6, // num workpad documents in .kibana index }, @@ -54,29 +53,12 @@ describe('usage collector handle es response data', () => { }); it('should collect correctly if an expression has null as an argument (possible sub-expression)', () => { - const mockWorkpads = [ - { - name: 'Tweet Data Workpad 1', - id: 'workpad-ae00567f-5510-4d68-b07f-6b1661948e03', - width: 792, - height: 612, - page: 0, - pages: [ - { - elements: [ - { - expression: 'toast butter=null', - }, - ], - }, - ], - '@timestamp': '2018-07-26T02:29:00.964Z', - '@created': '2018-07-25T22:56:31.460Z', - assets: {}, - }, - ]; + const workpad = clonedeep(workpads[0]); + workpad.pages[0].elements[0].expression = 'toast butter=null'; + + const mockWorkpads = [workpad]; const usage = summarizeWorkpads(mockWorkpads); - expect(usage).to.eql({ + expect(usage).toEqual({ workpads: { total: 1 }, pages: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, elements: { total: 1, per_page: { avg: 1, min: 1, max: 1 } }, @@ -85,21 +67,11 @@ describe('usage collector handle es response data', () => { }); it('should fail gracefully if workpad has 0 pages (corrupted workpad)', () => { - const mockWorkpadsCorrupted = [ - { - name: 'Tweet Data Workpad 2', - id: 'workpad-ae00567f-5510-4d68-b07f-6b1661948e03', - width: 792, - height: 612, - page: 0, - pages: [], // pages should never be empty, and *may* prevent the ui from rendering properly - '@timestamp': '2018-07-26T02:29:00.964Z', - '@created': '2018-07-25T22:56:31.460Z', - assets: {}, - }, - ]; + const workpad = clonedeep(workpads[0]); + workpad.pages = []; + const mockWorkpadsCorrupted = [workpad]; const usage = summarizeWorkpads(mockWorkpadsCorrupted); - expect(usage).to.eql({ + expect(usage).toEqual({ workpads: { total: 1 }, pages: { total: 0, per_workpad: { avg: 0, min: 0, max: 0 } }, elements: undefined, @@ -109,6 +81,6 @@ describe('usage collector handle es response data', () => { it('should fail gracefully in general', () => { const usage = summarizeWorkpads([]); - expect(usage).to.eql({}); + expect(usage).toEqual({}); }); }); diff --git a/x-pack/legacy/plugins/canvas/server/usage/workpad_collector.ts b/x-pack/legacy/plugins/canvas/server/usage/workpad_collector.ts index 5f003c6555b6c..03b2a006e7621 100644 --- a/x-pack/legacy/plugins/canvas/server/usage/workpad_collector.ts +++ b/x-pack/legacy/plugins/canvas/server/usage/workpad_collector.ts @@ -9,23 +9,10 @@ import { sum as arraySum, min as arrayMin, max as arrayMax, get } from 'lodash'; import { fromExpression } from '@kbn/interpreter/common'; import { CANVAS_TYPE } from '../../common/lib/constants'; import { collectFns } from './collector_helpers'; -import { AST, TelemetryCollector } from '../../types'; - -interface Element { - expression: string; -} - -interface Page { - elements: Element[]; -} - -interface Workpad { - pages: Page[]; - [s: string]: any; // Only concerned with the pages here, but allow workpads to have any values -} +import { ExpressionAST, TelemetryCollector, CanvasWorkpad } from '../../types'; interface WorkpadSearch { - [CANVAS_TYPE]: Workpad; + [CANVAS_TYPE]: CanvasWorkpad; } interface WorkpadTelemetry { @@ -61,11 +48,10 @@ interface WorkpadTelemetry { /** Gather statistic about the given workpads - @param workpadDocs a collection of workpad documents @returns Workpad Telemetry Data */ -export function summarizeWorkpads(workpadDocs: Workpad[]): WorkpadTelemetry { +export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetry { const functionSet = new Set(); if (workpadDocs.length === 0) { @@ -87,7 +73,7 @@ export function summarizeWorkpads(workpadDocs: Workpad[]): WorkpadTelemetry { ); const functionCounts = workpad.pages.reduce((accum, page) => { return page.elements.map(element => { - const ast: AST = fromExpression(element.expression) as AST; // TODO: Remove once fromExpression is properly typed + const ast: ExpressionAST = fromExpression(element.expression) as ExpressionAST; // TODO: Remove once fromExpression is properly typed collectFns(ast, cFunction => { functionSet.add(cFunction); }); diff --git a/x-pack/legacy/plugins/canvas/types/elements.ts b/x-pack/legacy/plugins/canvas/types/elements.ts index 35b3a28e1ae0f..48047c327a6e9 100644 --- a/x-pack/legacy/plugins/canvas/types/elements.ts +++ b/x-pack/legacy/plugins/canvas/types/elements.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ExpressionAST } from 'src/plugins/data/common/expressions'; + export interface ElementSpec { name: string; image: string; @@ -49,16 +51,6 @@ export interface CustomElement { content: string; } -export interface AST { - type: string; - chain: Array<{ - function: string; - arguments: { - [s: string]: AST[]; - }; - }>; -} - export interface ElementPosition { /** * distance from the left edge of the page @@ -102,5 +94,5 @@ export interface PositionedElement { /** * AST of the Canvas expression for the element */ - ast: AST; + ast: ExpressionAST; }