From 390896537c88509b87e3fd44c4fa3e51bd3d2596 Mon Sep 17 00:00:00 2001 From: Tom Robinson <tlrobinson@gmail.com> Date: Tue, 13 Dec 2016 11:14:11 -0800 Subject: [PATCH] Tokenized editor --- frontend/src/metabase/lib/dom.js | 40 +++ .../src/metabase/lib/expressions/parser.js | 286 +++++++++++++----- .../expressions/ExpressionEditorTextfield.jsx | 45 +-- .../expressions/TokenizedExpression.css | 56 ++++ .../expressions/TokenizedExpression.jsx | 54 ++++ .../components/expressions/TokenizedInput.jsx | 122 ++++++++ .../unit/lib/expressions/formatter.spec.js | 2 +- .../test/unit/lib/expressions/parser.spec.js | 128 +++++--- frontend/test/unit/lib/query.spec.js | 2 +- package.json | 2 +- yarn.lock | 6 +- 11 files changed, 602 insertions(+), 141 deletions(-) create mode 100644 frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.css create mode 100644 frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.jsx create mode 100644 frontend/src/metabase/query_builder/components/expressions/TokenizedInput.jsx diff --git a/frontend/src/metabase/lib/dom.js b/frontend/src/metabase/lib/dom.js index 014ccbc784c..a5c93b10684 100644 --- a/frontend/src/metabase/lib/dom.js +++ b/frontend/src/metabase/lib/dom.js @@ -55,6 +55,18 @@ export function elementIsInView(element, percentX = 1, percentY = 1) { }); } +export function getCaretPosition(element) { + if (element.nodeName.toLowerCase() === "input" || element.nodeName.toLowerCase() === "textarea") { + return element.selectionStart; + } else { + // contenteditable + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + range.setStart(element, 0); + return range.toString().length; + } +} + export function setCaretPosition(element, position) { if (element.setSelectionRange) { element.focus(); @@ -65,5 +77,33 @@ export function setCaretPosition(element, position) { range.moveEnd("character", position); range.moveStart("character", position); range.select(); + } else { + // contenteditable + const selection = window.getSelection(); + const pos = getTextNodeAtPosition(element, position); + selection.removeAllRanges(); + const range = new Range(); + range.setStart(pos.node ,pos.position); + selection.addRange(range); } } + +export function saveCaretPosition(context) { + let position = getCaretPosition(context); + return () => setCaretPosition(context, position); +} + +function getTextNodeAtPosition(root, index) { + let treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, (elem) => { + if (index > elem.textContent.length){ + index -= elem.textContent.length; + return NodeFilter.FILTER_REJECT + } + return NodeFilter.FILTER_ACCEPT; + }); + var c = treeWalker.nextNode(); + return { + node: c ? c : root, + position: c ? index : 0 + }; +} diff --git a/frontend/src/metabase/lib/expressions/parser.js b/frontend/src/metabase/lib/expressions/parser.js index 7281b75c527..e84c922642a 100644 --- a/frontend/src/metabase/lib/expressions/parser.js +++ b/frontend/src/metabase/lib/expressions/parser.js @@ -43,13 +43,13 @@ const ExpressionsLexer = new Lexer(allTokens); class ExpressionsParser extends Parser { - constructor(input, options) { - super(input, allTokens, { recoveryEnabled: false }); - - this._options = options; + constructor(input, options = {}) { + super(input, allTokens/*, { recoveryEnabled: false }*/); let $ = this; + this._options = options; + // an expression without aggregations in it $.RULE("expression", function (outsideAggregation = false) { return $.SUBRULE($.additionExpression, [outsideAggregation]) @@ -64,35 +64,23 @@ class ExpressionsParser extends Parser { // The precedence of binary expressions is determined by // how far down the Parse Tree the binary expression appears. $.RULE("additionExpression", (outsideAggregation) => { - let value = $.SUBRULE($.multiplicationExpression, [outsideAggregation]); - $.MANY(() => { + let initial = $.SUBRULE($.multiplicationExpression, [outsideAggregation]); + let operations = $.MANY(() => { const op = $.CONSUME(AdditiveOperator); const rhsVal = $.SUBRULE2($.multiplicationExpression, [outsideAggregation]); - - // collapse multiple consecutive operators into a single MBQL statement - if (Array.isArray(value) && value[0] === op.image) { - value.push(rhsVal); - } else { - value = [op.image, value, rhsVal] - } + return [op, rhsVal]; }); - return value; + return this._math(initial, operations); }); $.RULE("multiplicationExpression", (outsideAggregation) => { - let value = $.SUBRULE($.atomicExpression, [outsideAggregation]); - $.MANY(() => { + let initial = $.SUBRULE($.atomicExpression, [outsideAggregation]); + let operations = $.MANY(() => { const op = $.CONSUME(MultiplicativeOperator); const rhsVal = $.SUBRULE2($.atomicExpression, [outsideAggregation]); - - // collapse multiple consecutive operators into a single MBQL statement - if (Array.isArray(value) && value[0] === op.image) { - value.push(rhsVal); - } else { - value = [op.image, value, rhsVal] - } + return [op, rhsVal]; }); - return value; + return this._math(initial, operations); }); $.RULE("aggregationOrMetricExpression", (outsideAggregation) => { @@ -103,48 +91,58 @@ class ExpressionsParser extends Parser { }); $.RULE("aggregationExpression", (outsideAggregation) => { - const agg = $.CONSUME(Aggregation).image; - let value = [aggregationsMap.get(agg)] - $.CONSUME(LParen); - $.OPTION(() => { - // aggregations cannot be nested, so pass false to the expression subrule - value.push($.SUBRULE($.expression, [false])); - $.MANY(() => { - $.CONSUME(Comma); - value.push($.SUBRULE2($.expression, [false])); - }); - }); - $.CONSUME(RParen); - return value; + const aggregation = $.CONSUME(Aggregation); + const lParen = $.CONSUME(LParen); + const args = $.MANY_SEP(Comma, () => $.SUBRULE($.expression, [false])); + const rParen = $.CONSUME(RParen); + + return this._aggregation(aggregation, lParen, args, rParen); }); $.RULE("metricExpression", () => { - let metricName = $.CONSUME(Identifier).image; - $.CONSUME(LParen); - $.CONSUME(RParen); - const metric = this.getMetricForName(metricName); + const metricName = $.SUBRULE($.identifier); + const lParen = $.CONSUME(LParen); + const rParen = $.CONSUME(RParen); + + const metric = this.getMetricForName(this._toString(metricName)); if (metric != null) { - return ["METRIC", metric.id]; + return this._metricReference(metricName, metric.id); } - throw new Error("Unknown metric \"" + metricName + "\""); + return this._unknownMetric(metricName, lParen, rParen); }); $.RULE("fieldExpression", () => { - let fieldName = $.OR([ - {ALT: () => JSON.parse($.CONSUME(StringLiteral).image) }, - {ALT: () => $.CONSUME(Identifier).image } + const fieldName = $.OR([ + {ALT: () => $.SUBRULE($.stringLiteral) }, + {ALT: () => $.SUBRULE($.identifier) } ]); - const field = this.getFieldForName(fieldName); + + const field = this.getFieldForName(this._toString(fieldName)); if (field != null) { - return ["field-id", field.id]; + return this._fieldReference(fieldName, field.id); } - const expression = this.getExpressionForName(fieldName); + const expression = this.getExpressionForName(this._toString(fieldName)); if (expression != null) { - return ["expression", fieldName]; + return this._expressionReference(fieldName, expression); } - throw new Error("Unknown field \"" + fieldName + "\""); + return this._unknownField(fieldName); }); + $.RULE("identifier", () => { + const identifier = $.CONSUME(Identifier); + return this._identifier(identifier); + }) + + $.RULE("stringLiteral", () => { + const stringLiteral = $.CONSUME(StringLiteral); + return this._stringLiteral(stringLiteral); + }) + + $.RULE("numberLiteral", () => { + const numberLiteral = $.CONSUME(NumberLiteral); + return this._numberLiteral(numberLiteral); + }) + $.RULE("atomicExpression", (outsideAggregation) => { return $.OR([ // aggregations not allowed inside other aggregations @@ -152,18 +150,15 @@ class ExpressionsParser extends Parser { // fields not allowed outside aggregations {GATE: () => !outsideAggregation, ALT: () => $.SUBRULE($.fieldExpression) }, {ALT: () => $.SUBRULE($.parenthesisExpression, [outsideAggregation]) }, - {ALT: () => parseFloat($.CONSUME(NumberLiteral).image) } + {ALT: () => $.SUBRULE($.numberLiteral) } ], "a number or field name"); }); $.RULE("parenthesisExpression", (outsideAggregation) => { - let expValue; - - $.CONSUME(LParen); - expValue = $.SUBRULE($.expression, [outsideAggregation]); - $.CONSUME(RParen); - - return expValue + let lParen = $.CONSUME(LParen); + let expValue = $.SUBRULE($.expression, [outsideAggregation]); + let rParen = $.CONSUME(RParen); + return this._parens(lParen, expValue, rParen); }); Parser.performSelfAnalysis(this); @@ -185,6 +180,119 @@ class ExpressionsParser extends Parser { } } +class ExpressionsParserMBQL extends ExpressionsParser { + _math(initial, operations) { + for (const [op, rhsVal] of operations) { + // collapse multiple consecutive operators into a single MBQL statement + if (Array.isArray(initial) && initial[0] === op.image) { + initial.push(rhsVal); + } else { + initial = [op.image, initial, rhsVal] + } + } + return initial; + } + _aggregation(aggregation, lParen, args, rParen) { + const aggregationName = aggregation.image; + return [aggregationsMap.get(aggregationName)].concat(args.values); + } + _metricReference(metricName, metricId) { + return ["METRIC", metricId]; + } + _fieldReference(fieldName, fieldId) { + return ["field-id", fieldId]; + } + _expressionReference(fieldName) { + return ["expression", fieldName]; + } + _unknownField(fieldName) { + throw new Error("Unknown field \"" + fieldName + "\""); + } + _unknownMetric(metricName) { + throw new Error("Unknown metric \"" + metricName + "\""); + } + + _identifier(identifier) { + return identifier.image; + } + _stringLiteral(stringLiteral) { + return JSON.parse(stringLiteral.image); + } + _numberLiteral(numberLiteral) { + return parseFloat(numberLiteral.image); + } + _parens(lParen, expValue, rParen) { + return expValue; + } + _toString(x) { + return x; + } +} + +const syntax = (type, ...children) => ({ + type: type, + children: children +}) +const token = (token) => ({ + type: "token", + text: token.image, + start: token.startOffset, + end: token.endOffset, +}) + +class ExpressionsParserSyntax extends ExpressionsParser { + _math(initial, operations) { + return syntax("math", ...[initial].concat(...operations.map(([op, arg]) => [token(op), arg]))); + } + _aggregation(aggregation, lParen, args, rParen) { + let argsAndCommas = []; + for (var i = 0; i < args.values.length; i++) { + argsAndCommas.push(args.values[i]); + if (i < args.separators.length) { + argsAndCommas.push(args.separators[i]); + } + } + return syntax("aggregation", token(aggregation), token(lParen), ...argsAndCommas, token(rParen)); + } + _metricReference(metricName, metricId) { + return syntax("metric", metricName); + } + _fieldReference(fieldName, fieldId) { + return syntax("field", fieldName); + } + _expressionReference(fieldName) { + return syntax("expression-reference", token(fieldName)); + } + _unknownField(fieldName) { + return syntax("unknown", fieldName); + } + _unknownMetric(metricName) { + return syntax("unknown", metricName); + } + + _identifier(identifier) { + return syntax("identifier", token(identifier)); + } + _stringLiteral(stringLiteral) { + return syntax("string", token(stringLiteral)); + } + _numberLiteral(numberLiteral) { + return syntax("number", token(numberLiteral)); + } + _parens(lParen, expValue, rParen) { + return syntax("group", token(lParen), expValue, token(rParen)); + } + _toString(x) { + if (typeof x === "string") { + return x; + } else if (x.type === "string") { + return JSON.parse(x.children[0].text); + } else if (x.type === "identifier") { + return x.children[0].text; + } + } +} + function getSubTokenTypes(TokenClass) { return TokenClass.extendingTokenTypes.map(tokenType => _.findWhere(allTokens, { tokenType })); } @@ -194,12 +302,12 @@ function getTokenSource(TokenClass) { return TokenClass.PATTERN.source.replace(/^\\/, ""); } -export function compile(source, options = {}) { +function run(Parser, source, options) { if (!source) { return []; } const { startRule } = options; - const parser = new ExpressionsParser(ExpressionsLexer.tokenize(source).tokens, options); + const parser = new Parser(ExpressionsLexer.tokenize(source).tokens, options); const expression = parser[startRule](); if (parser.errors.length > 0) { throw parser.errors; @@ -207,6 +315,14 @@ export function compile(source, options = {}) { return expression; } +export function compile(source, options = {}) { + return run(ExpressionsParserMBQL, source, options); +} + +export function parse(source, options = {}) { + return run(ExpressionsParserSyntax, source, options); +} + // No need for more than one instance. const parserInstance = new ExpressionsParser([]) export function suggest(source, { @@ -232,13 +348,13 @@ export function suggest(source, { partialSuggestionMode = true } - const syntacticSuggestions = parserInstance.computeContentAssist(startRule, assistanceTokenVector) let finalSuggestions = [] // TODO: is there a better way to figure out which aggregation we're inside of? const currentAggregationToken = _.find(assistanceTokenVector.slice().reverse(), (t) => t instanceof Aggregation); + const syntacticSuggestions = parserInstance.computeContentAssist(startRule, assistanceTokenVector) for (const suggestion of syntacticSuggestions) { const { nextTokenType, ruleStack } = suggestion; // no nesting of aggregations or field references outside of aggregations @@ -273,25 +389,29 @@ export function suggest(source, { postfixTrim: /^\s*\)?\s*/ }); } else if (nextTokenType === Identifier || nextTokenType === StringLiteral) { - if (!outsideAggregation && currentAggregationToken) { - let aggregationShort = aggregationsMap.get(getImage(currentAggregationToken)); - let aggregationOption = _.findWhere(tableMetadata.aggregation_options, { short: aggregationShort }); - if (aggregationOption && aggregationOption.fields.length > 0) { - finalSuggestions.push(...aggregationOption.fields[0].map(field => ({ - type: "fields", - name: field.display_name, - text: formatFieldName(field) + " ", - prefixTrim: /\w+$/, - postfixTrim: /^\w+\s*/ - }))) - finalSuggestions.push(...Object.keys(customFields || {}).map(expressionName => ({ - type: "fields", - name: expressionName, - text: formatExpressionName(expressionName) + " ", - prefixTrim: /\w+$/, - postfixTrim: /^\w+\s*/ - }))) + if (!outsideAggregation) { + let fields = []; + if (startRule === "aggregation" && currentAggregationToken) { + let aggregationShort = aggregationsMap.get(getImage(currentAggregationToken)); + let aggregationOption = _.findWhere(tableMetadata.aggregation_options, { short: aggregationShort }); + fields = aggregationOption && aggregationOption.fields && aggregationOption.fields[0] || [] + } else if (startRule === "expression") { + fields = tableMetadata.fields; } + finalSuggestions.push(...fields.map(field => ({ + type: "fields", + name: field.display_name, + text: formatFieldName(field) + " ", + prefixTrim: /\w+$/, + postfixTrim: /^\w+\s*/ + }))); + finalSuggestions.push(...Object.keys(customFields || {}).map(expressionName => ({ + type: "fields", + name: expressionName, + text: formatExpressionName(expressionName) + " ", + prefixTrim: /\w+$/, + postfixTrim: /^\w+\s*/ + }))); } } else if (nextTokenType === Aggregation) { if (outsideAggregation) { @@ -303,15 +423,15 @@ export function suggest(source, { text: formatAggregationName(aggregationOption) + "(" + (arity > 0 ? "" : ")"), postfixText: arity > 0 ? ")" : "", prefixTrim: /\w+$/, - postfixTrim: /^\w+\(\)?/ + postfixTrim: /^\w+(\(\)?|$)/ }; })); finalSuggestions.push(...tableMetadata.metrics.map(metric => ({ type: "metrics", name: metric.name, - text: formatMetricName(metric) + "() ", + text: formatMetricName(metric) + "()", prefixTrim: /\w+$/, - postfixTrim: /^\w+\(\)?/ + postfixTrim: /^\w+(\(\)?|$)/ }))) } } else if (nextTokenType === NumberLiteral) { diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx index 72fc3ccf5e0..f839d50d360 100644 --- a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx +++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx @@ -7,10 +7,12 @@ import cx from "classnames"; import { compile, suggest } from "metabase/lib/expressions/parser"; import { format } from "metabase/lib/expressions/formatter"; -import { setCaretPosition } from "metabase/lib/dom"; +import { setCaretPosition, getCaretPosition } from "metabase/lib/dom"; import Popover from "metabase/components/Popover.jsx"; +import TokenizedInput from "./TokenizedInput.jsx"; + import { isExpression } from "metabase/lib/expressions"; @@ -26,7 +28,7 @@ const KEYCODE_DOWN = 40; export default class ExpressionEditorTextfield extends Component { constructor(props, context) { super(props, context); - _.bindAll(this, 'onInputChange', 'onInputKeyDown', 'onInputBlur', 'onSuggestionAccepted', 'onSuggestionMouseDown'); + _.bindAll(this, '_triggerAutosuggest', 'onInputKeyDown', 'onInputBlur', 'onSuggestionAccepted', 'onSuggestionMouseDown'); } static propTypes = { @@ -81,12 +83,10 @@ export default class ExpressionEditorTextfield extends Component { } componentDidMount() { - // causes the autocomplete widget to open immediately - this.onInputChange(); + this._setCaretPosition(this.state.expressionString.length, this.state.expressionString.length === 0) } onSuggestionAccepted() { - let inputElement = ReactDOM.findDOMNode(this.refs.input); const { expressionString } = this.state; const suggestion = this.state.suggestions[this.state.highlightedSuggestion]; @@ -99,11 +99,12 @@ export default class ExpressionEditorTextfield extends Component { if (suggestion.postfixTrim) { postfix = postfix.replace(suggestion.postfixTrim, ""); } + if (!postfix && suggestion.postfixText) { + postfix = suggestion.postfixText; + } - inputElement.value = prefix + suggestion.text + postfix; - inputElement.focus(); - setCaretPosition(inputElement, (prefix + suggestion.text).length); - this.onInputChange(); + this.onExpressionChange(prefix + suggestion.text + postfix) + setTimeout(() => this._setCaretPosition((prefix + suggestion.text).length, true)) } this.setState({ @@ -122,7 +123,7 @@ export default class ExpressionEditorTextfield extends Component { const { suggestions, highlightedSuggestion } = this.state; if (event.keyCode === KEYCODE_LEFT || event.keyCode === KEYCODE_RIGHT) { - setTimeout(() => this.onInputChange()); + setTimeout(() => this._triggerAutosuggest()); return; } if (event.keyCode === KEYCODE_ESC) { @@ -170,15 +171,25 @@ export default class ExpressionEditorTextfield extends Component { } onInputClick = () => { - this.onInputChange(); + this._triggerAutosuggest(); + } + + _triggerAutosuggest = () => { + this.onExpressionChange(this.state.expressionString); + } + + _setCaretPosition = (position, autosuggest) => { + setCaretPosition(ReactDOM.findDOMNode(this.refs.input), position); + if (autosuggest) { + setTimeout(() => this._triggerAutosuggest()); + } } - onInputChange() { + onExpressionChange(expressionString) { let inputElement = ReactDOM.findDOMNode(this.refs.input); if (!inputElement) { return; } - let expressionString = inputElement.value; let expressionErrorMessage = null; let suggestions = []; @@ -199,7 +210,7 @@ export default class ExpressionEditorTextfield extends Component { tableMetadata: this.props.tableMetadata, customFields: this.props.customFields, startRule: this.props.startRule, - index: inputElement.selectionStart + index: getCaretPosition(inputElement) }) } catch (e) { console.error("suggest error:", e); @@ -222,16 +233,16 @@ export default class ExpressionEditorTextfield extends Component { return ( <div className={cx(S.editor, "relative")}> - <input + <TokenizedInput ref="input" className={cx(S.input, "my1 p1 input block full h4 text-dark", { "border-error": errorMessage })} type="text" placeholder={placeholder} value={this.state.expressionString} - onChange={this.onInputChange} + onChange={(e) => this.onExpressionChange(e.target.value)} onKeyDown={this.onInputKeyDown} onBlur={this.onInputBlur} - onFocus={this.onInputChange} + onFocus={(e) => this._triggerAutosuggest()} onClick={this.onInputClick} autoFocus /> diff --git a/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.css b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.css new file mode 100644 index 00000000000..92e73bcbd32 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.css @@ -0,0 +1,56 @@ + +/* +.TokenizedExpression .aggregation { border: 3px solid rgb(255,0,0) !important; } +.TokenizedExpression .group { border: 3px solid rgb(0,255,0) !important; } +.TokenizedExpression .identifier { border: 3px solid rgb(0,0,255) !important; } +.TokenizedExpression .string-literal { border: 3px solid rgb(0,255,255) !important; } +.TokenizedExpression .open-paren { border: 3px solid rgb(255,255,0) !important; } +.TokenizedExpression .close-paren { border: 3px solid rgb(0,255,255) !important; } /**/ + +.TokenizedExpression .aggregation, +.TokenizedExpression .group, +.TokenizedExpression .identifier, +.TokenizedExpression .open-paren, +.TokenizedExpression .close-paren, +.TokenizedExpression .string-literal { + display: inline-block; + border-radius: 4px; +/* display: flex; + align-items: center; */ +} + +.TokenizedExpression .string-literal > .open-quote, +.TokenizedExpression .string-literal > .close-quote { + /* don't use `display: none;` `visibility: hidden;` `width: 0;` or `height: 0;` as text won't appear when copied */ + /* display: inline-block; */ + opacity: 0.5; + /*width: 1px; + height: 1px; */ + /* overflow: hidden; */ +} + +.TokenizedExpression .identifier { + border: 1px solid #84BB4C; + background-color: #bfe49b; + padding: 0px 2px; + margin: 1px 1px; +} + +.TokenizedExpression .aggregation .identifier { + border: 1px solid #2D86D4; + background-color: #6ab9ff; +} + +.TokenizedExpression .aggregation { + border: 1px solid #84BB4C; + background-color: #bfe49b; + padding: 0px 2px; + /* height: 25px; */ +} + +.TokenizedExpression .aggregation > .identifier { + border: none; + background-color: transparent; + padding: 0; + margin: 0; +} diff --git a/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.jsx b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.jsx new file mode 100644 index 00000000000..406a9576469 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/expressions/TokenizedExpression.jsx @@ -0,0 +1,54 @@ +import React, { Component, PropTypes } from "react"; + +import "./TokenizedExpression.css"; + +function nextNonWhitespace(tokens, index) { + while (index < tokens.length && /^\s+$/.test(tokens[++index])) { + } + return tokens[index]; +} + +export default class TokenizedExpression extends React.Component { + render() { + let tokens = this.props.source.match(/[a-zA-Z]\w*|"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"|\(|\)|\d+|\s+|[*/+-]|.*/g); + let root = <span className="TokenizedExpression" children={[]} />; + let current = root; + const stack = []; + const push = (element) => { + current.props.children.push(element); + stack.push(current); + current = element; + } + const pop = () => { + if (stack.length === 0) { + return; + } + current = stack.pop(); + } + for (let i = 0; i < tokens.length; i++) { + let token = tokens[i]; + if (/^[a-zA-Z]\w*$/.test(token)) { + if (nextNonWhitespace(tokens, i) === "(") { + push(<span className="aggregation" children={[]} />); + } + current.props.children.push(<span className="identifier">{token}</span>); + } else if (/^"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"$/.test(token)) { + current.props.children.push( + <span className="string-literal"><span className="open-quote">"</span><span className="identifier">{JSON.parse(token)}</span><span className="close-quote">"</span></span> + ); + } else if (token === "(") { + push(<span className="group" children={[]} />) + current.props.children.push(<span className="open-paren">(</span>) + } else if (token === ")") { + current.props.children.push(<span className="close-paren">)</span>) + pop(); + if (current.props.className === "aggregation") { + pop(); + } + } else { + current.props.children.push(token); + } + } + return root; + } +} diff --git a/frontend/src/metabase/query_builder/components/expressions/TokenizedInput.jsx b/frontend/src/metabase/query_builder/components/expressions/TokenizedInput.jsx new file mode 100644 index 00000000000..fe1993633de --- /dev/null +++ b/frontend/src/metabase/query_builder/components/expressions/TokenizedInput.jsx @@ -0,0 +1,122 @@ +import React, { Component, PropTypes } from "react"; +import ReactDOM from "react-dom"; + +import TokenizedExpression from "./TokenizedExpression.jsx"; + +import { getCaretPosition, saveCaretPosition } from "metabase/lib/dom" + +export default class TokenizedInput extends Component { + constructor(props) { + super(props); + this.state = { + value: "" + } + } + + _getValue() { + if (this.props.value != undefined) { + return this.props.value; + } else { + return this.state.value; + } + } + _setValue(value) { + ReactDOM.findDOMNode(this).value = value; + if (typeof this.props.onChange === "function") { + this.props.onChange({ target: { value }}); + } else { + this.setState({ value }); + } + } + + componentDidMount() { + ReactDOM.findDOMNode(this).focus(); + this.componentDidUpdate() + + document.addEventListener("selectionchange", this.onSelectionChange, false); + } + componentWillUnmount() { + document.removeEventListener("selectionchange", this.onSelectionChange, false); + } + onSelectionChange = (e) => { + ReactDOM.findDOMNode(this).selectionStart = getCaretPosition(ReactDOM.findDOMNode(this)) + } + onInput = (e) => { + this._setValue(e.target.textContent); + } + onKeyDown = (e) => { + this.props.onKeyDown(e); + + // handle tokenized delete + if (e.keyCode !== 8) { + return; + } + const input = ReactDOM.findDOMNode(this); + + var selection = window.getSelection(); + var range = selection.getRangeAt(0); + + let isEndOfNode = range.endContainer.length === range.endOffset; + let hasSelection = range.startContainer !== range.endContainer || range.startOffset !== range.endOffset; + + let el = selection.focusNode; + let path = []; + while (el && el != input) { + path.unshift(el.className); + el = el.parentNode; + } + + /* + e.stopPropagation(); + e.preventDefault(); + return; + /**/ + + if (!isEndOfNode || hasSelection) { + return; + } + + let parent = selection.focusNode.parentNode; + if (parent.className === "close-paren" && parent.parentNode.className === "group" && parent.parentNode.parentNode.className === "aggregation") { + parent.parentNode.parentNode.parentNode.removeChild(parent.parentNode.parentNode); + e.stopPropagation(); + e.preventDefault(); + this._setValue(input.textContent); + } else if (parent.className === "identifier") { + if (parent.parentNode.className === "string-literal") { + parent.parentNode.parentNode.removeChild(parent.parentNode); + } else { + parent.parentNode.removeChild(parent); + } + e.stopPropagation(); + e.preventDefault(); + this._setValue(input.textContent); + } + } + + componentDidUpdate() { + const inputNode = ReactDOM.findDOMNode(this); + const restore = saveCaretPosition(inputNode); + + ReactDOM.unmountComponentAtNode(inputNode); + while (inputNode.firstChild) { + inputNode.removeChild(inputNode.firstChild); + } + ReactDOM.render(<TokenizedExpression source={this._getValue()} />, inputNode); + restore(); + } + render() { + const { className, onFocus, onBlur, onClick } = this.props; + return ( + <div + className={className} + contentEditable + onKeyDown={this.onKeyDown} + onInput={this.onInput} + onFocus={onFocus} + onBlur={onBlur} + onClick={onClick} + /> + ); + } +} diff --git a/frontend/test/unit/lib/expressions/formatter.spec.js b/frontend/test/unit/lib/expressions/formatter.spec.js index 00a65395f86..458cf75483c 100644 --- a/frontend/test/unit/lib/expressions/formatter.spec.js +++ b/frontend/test/unit/lib/expressions/formatter.spec.js @@ -14,7 +14,7 @@ const mockMetadata = { } } -fdescribe("lib/expressions/parser", () => { +describe("lib/expressions/parser", () => { describe("format", () => { it("can format simple expressions", () => { expect(format(["+", ["field-id", 1], ["field-id", 2]], mockMetadata)).toEqual("A + B"); diff --git a/frontend/test/unit/lib/expressions/parser.spec.js b/frontend/test/unit/lib/expressions/parser.spec.js index ca5ac4addc3..91138bae50b 100644 --- a/frontend/test/unit/lib/expressions/parser.spec.js +++ b/frontend/test/unit/lib/expressions/parser.spec.js @@ -1,12 +1,26 @@ -import { compile, suggest } from "metabase/lib/expressions/parser"; +import { compile, suggest, parse } from "metabase/lib/expressions/parser"; import _ from "underscore"; -const mockFields = [ - {id: 1, display_name: "A"}, - {id: 2, display_name: "B"}, - {id: 3, display_name: "C"}, - {id: 10, display_name: "Toucan Sam"} -]; +const mockMetadata = { + tableMetadata: { + fields: [ + {id: 1, display_name: "A"}, + {id: 2, display_name: "B"}, + {id: 3, display_name: "C"}, + {id: 10, display_name: "Toucan Sam"} + ], + metrics: [ + {id: 1, name: "foo bar"}, + ], + aggregation_options: [ + { short: "count", fields: [] }, + { short: "sum", fields: [[]] } + ] + } +} + +const expressionOpts = { ...mockMetadata, startRule: "expression" }; +const aggregationOpts = { ...mockMetadata, startRule: "aggregation" }; describe("lib/expressions/parser", () => { describe("compile()", () => { @@ -17,51 +31,55 @@ describe("lib/expressions/parser", () => { }); it("can parse simple expressions", () => { - expect(compile("A", { fields: mockFields })).toEqual(['field-id', 1]); - expect(compile("1", { fields: mockFields })).toEqual(1); - expect(compile("1.1", { fields: mockFields })).toEqual(1.1); + expect(compile("A", expressionOpts)).toEqual(['field-id', 1]); + expect(compile("1", expressionOpts)).toEqual(1); + expect(compile("1.1", expressionOpts)).toEqual(1.1); }); it("can parse single operator math", () => { - expect(compile("A-B", { fields: mockFields })).toEqual(["-", ['field-id', 1], ['field-id', 2]]); - expect(compile("A - B", { fields: mockFields })).toEqual(["-", ['field-id', 1], ['field-id', 2]]); - expect(compile("1 - B", { fields: mockFields })).toEqual(["-", 1, ['field-id', 2]]); - expect(compile("1 - 2", { fields: mockFields })).toEqual(["-", 1, 2]); + expect(compile("A-B", expressionOpts)).toEqual(["-", ['field-id', 1], ['field-id', 2]]); + expect(compile("A - B", expressionOpts)).toEqual(["-", ['field-id', 1], ['field-id', 2]]); + expect(compile("1 - B", expressionOpts)).toEqual(["-", 1, ['field-id', 2]]); + expect(compile("1 - 2", expressionOpts)).toEqual(["-", 1, 2]); }); it("can handle operator precedence", () => { - expect(compile("1 + 2 * 3", { fields: mockFields })).toEqual(["+", 1, ["*", 2, 3]]); - expect(compile("1 * 2 + 3", { fields: mockFields })).toEqual(["+", ["*", 1, 2], 3]); + expect(compile("1 + 2 * 3", expressionOpts)).toEqual(["+", 1, ["*", 2, 3]]); + expect(compile("1 * 2 + 3", expressionOpts)).toEqual(["+", ["*", 1, 2], 3]); + }); + + it("can collapse consecutive identical operators", () => { + expect(compile("1 + 2 + 3 * 4 * 5", expressionOpts)).toEqual(["+", 1, 2, ["*", 3, 4, 5]]); }); // quoted field name w/ a space in it it("can parse a field with quotes and spaces", () => { - expect(compile("\"Toucan Sam\" + B", { fields: mockFields })).toEqual(["+", ['field-id', 10], ['field-id', 2]]); + expect(compile("\"Toucan Sam\" + B", expressionOpts)).toEqual(["+", ['field-id', 10], ['field-id', 2]]); }); // parentheses / nested parens it("can parse expressions with parentheses", () => { - expect(compile("(1 + 2) * 3", { fields: mockFields })).toEqual(["*", ["+", 1, 2], 3]); - expect(compile("1 * (2 + 3)", { fields: mockFields })).toEqual(["*", 1, ["+", 2, 3]]); - expect(compile("\"Toucan Sam\" + (A * (B / C))", { fields: mockFields })).toEqual( + expect(compile("(1 + 2) * 3", expressionOpts)).toEqual(["*", ["+", 1, 2], 3]); + expect(compile("1 * (2 + 3)", expressionOpts)).toEqual(["*", 1, ["+", 2, 3]]); + expect(compile("\"Toucan Sam\" + (A * (B / C))", expressionOpts)).toEqual( ["+", ['field-id', 10], ["*", [ 'field-id', 1 ], ["/", [ 'field-id', 2 ], [ 'field-id', 3 ]]]] ); }); it("can parse aggregation with no arguments", () => { - expect(compile("Count()", { fields: mockFields })).toEqual(["count"]); + expect(compile("Count()", aggregationOpts)).toEqual(["count"]); }); it("can parse aggregation with argument", () => { - expect(compile("Sum(A)", { fields: mockFields })).toEqual(["sum", ["field-id", 1]]); + expect(compile("Sum(A)", aggregationOpts)).toEqual(["sum", ["field-id", 1]]); }); it("can parse complex aggregation", () => { - expect(compile("1 - Sum(A * 2) / Count()", { fields: mockFields })).toEqual(["-", 1, ["/", ["sum", ["*", ["field-id", 1], 2]], ["count"]]]); + expect(compile("1 - Sum(A * 2) / Count()", aggregationOpts)).toEqual(["-", 1, ["/", ["sum", ["*", ["field-id", 1], 2]], ["count"]]]); }); it("should throw exception on invalid input", () => { - expect(() => compile("1 + ", { fields: mockFields })).toThrow(); + expect(() => compile("1 + ", expressionOpts)).toThrow(); }); // fks @@ -69,24 +87,64 @@ describe("lib/expressions/parser", () => { }); describe("suggest()", () => { - it("should suggest things after an operator", () => { - expect(cleanSuggestions(suggest("1 + ", { fields: mockFields.slice(-2) }))).toEqual([ - { type: 'aggregation', text: 'Count(' }, - { type: 'aggregation', text: 'Sum(' }, - { type: 'field', text: '"Toucan Sam"' }, - { type: 'field', text: 'C' }, - { type: 'other', text: '(' }, + it("should suggest aggregations and metrics after an operator", () => { + expect(cleanSuggestions(suggest("1 + ", aggregationOpts))).toEqual([ + { type: 'aggregations', text: 'Count()' }, + { type: 'aggregations', text: 'Sum(' }, + { type: 'metrics', text: 'FooBar()' }, + { type: 'other', text: ' (' }, + ]); + }) + it("should suggest fields after an operator", () => { + expect(cleanSuggestions(suggest("1 + ", expressionOpts))).toEqual([ + { type: 'fields', text: '"Toucan Sam" ' }, + { type: 'fields', text: 'A ' }, + { type: 'fields', text: 'B ' }, + { type: 'fields', text: 'C ' }, + { type: 'other', text: ' (' }, + ]); + }) + it("should suggest partial matches in aggregation", () => { + expect(cleanSuggestions(suggest("1 + C", aggregationOpts))).toEqual([ + { type: 'aggregations', text: 'Count()' }, ]); }) - it("should suggest partial matches", () => { - expect(cleanSuggestions(suggest("1 + C", { fields: mockFields.slice(-2) }))).toEqual([ - { type: 'aggregation', text: 'Count(' }, - { type: 'field', text: 'C' } + it("should suggest partial matches in expression", () => { + expect(cleanSuggestions(suggest("1 + C", expressionOpts))).toEqual([ + { type: 'fields', text: 'C ' }, ]); }) }) + + describe("compile() in syntax mode", () => { + it ("should parse source without whitespace into a recoverable syntax tree", () => { + const source = "1-Sum(A*2+\"Toucan Sam\")/Count()"; + const tree = parse(source, aggregationOpts); + expect(serialize(tree)).toEqual(source) + }) + xit ("should parse source with metric into a recoverable syntax tree", () => { + // FIXME: not preserving parens + const source = "Count()+FooBar()"; + const tree = parse(source, aggregationOpts); + expect(serialize(tree)).toEqual(source) + }) + xit ("should parse source with whitespace into a recoverable syntax tree", () => { + // FIXME: not preserving whitespace + const source = "1 - Sum(A * 2 + \"Toucan Sam\") / Count()"; + const tree = parse(source, aggregationOpts); + expect(serialize(tree)).toEqual(source) + }) + }) }); +function serialize(tree) { + if (tree.type === "token") { + return tree.text; + } else { + return tree.children.map(serialize).join(""); + } +} + function cleanSuggestions(suggestions) { return _.chain(suggestions).map(s => _.pick(s, "type", "text")).sortBy("text").sortBy("type").value(); } diff --git a/frontend/test/unit/lib/query.spec.js b/frontend/test/unit/lib/query.spec.js index 76a6cc07187..2f9aade317f 100644 --- a/frontend/test/unit/lib/query.spec.js +++ b/frontend/test/unit/lib/query.spec.js @@ -189,7 +189,7 @@ describe('Query', () => { filter: [] }; Query.removeBreakout(query, 0); - expect(query.breakout.length).toBe(0); + expect(query.breakout).toBe(undefined); }); it('should remove sort clauses for the dimension that was removed', () => { let query = { diff --git a/package.json b/package.json index 02db1acd0e5..6dcc966c603 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "ace-builds": "^1.2.2", "babel-polyfill": "^6.6.1", - "chevrotain": "^0.19.0", + "chevrotain": "^0.20.0", "classnames": "^2.1.3", "color": "^0.11.1", "crossfilter": "^1.3.12", diff --git a/yarn.lock b/yarn.lock index 934c4aa8b03..3904facc61f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1444,9 +1444,9 @@ change-emitter@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.2.tgz#6b88ca4d5d864e516f913421b11899a860aee8db" -chevrotain@^0.19.0: - version "0.19.0" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-0.19.0.tgz#3319bedf0f9426831be870ffa2162be733f4369b" +chevrotain@^0.20.0: + version "0.20.0" + resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-0.20.0.tgz#b814daf9e15c134bf421ff54b429b818c6648a9e" chokidar@^1.0.0, chokidar@^1.4.1: version "1.6.0" -- GitLab