diff --git a/CHANGELOG.md b/CHANGELOG.md index 169571296..800f3f7f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ This project adheres to [Semantic Versioning](http://semver.org/). [#546](https://github.com/plotly/dash-table/issues/546) - New prop `export_columns` that takes values `all` or `visible` (default). This prop controls the columns used during export +[#597](https://github.com/plotly/dash-table/issues/597) +- Add `is blank` unary operator. Returns true for `undefined`, `null` and `''`. + +### Changed +[#598](https://github.com/plotly/dash-table/issues/598) +- Allow values with whitespaces in column filters + ### Fixed [#460](https://github.com/plotly/dash-table/issues/460) - The `datestartswith` relational operator now supports number comparison diff --git a/src/core/syntax-tree/index.ts b/src/core/syntax-tree/index.ts index bf3e3568f..c689ddc8a 100644 --- a/src/core/syntax-tree/index.ts +++ b/src/core/syntax-tree/index.ts @@ -79,7 +79,7 @@ export default class SyntaxTree { toQueryString() { return this.lexerResult.valid ? - R.map(l => l.value, this.lexerResult.lexemes).join(' ') : + R.map(l => l.lexeme.transform ? l.lexeme.transform(l.value) : l.value, this.lexerResult.lexemes).join(' ') : ''; } diff --git a/src/core/syntax-tree/lexicon.ts b/src/core/syntax-tree/lexicon.ts index f1bb0907c..e0eb5b473 100644 --- a/src/core/syntax-tree/lexicon.ts +++ b/src/core/syntax-tree/lexicon.ts @@ -21,6 +21,7 @@ export interface IUnboundedLexeme { regexp: RegExp; regexpMatch?: number; syntaxer?: (lexs: any[], pivot: any, pivotIndex: number) => any; + transform?: (value: any) => any; } export interface ILexeme extends IUnboundedLexeme { diff --git a/src/dash-table/syntax-tree/lexeme/expression.ts b/src/dash-table/syntax-tree/lexeme/expression.ts index fd7032ae4..3842f24bb 100644 --- a/src/dash-table/syntax-tree/lexeme/expression.ts +++ b/src/dash-table/syntax-tree/lexeme/expression.ts @@ -6,6 +6,7 @@ import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; const FIELD_REGEX = /^{(([^{}\\]|\\.)+)}/; const STRING_REGEX = /^(('([^'\\]|\\.)*')|("([^"\\]|\\.)*")|(`([^`\\]|\\.)*`))/; const VALUE_REGEX = /^(([^\s'"`{}()\\]|\\.)+)(?:[\s)]|$)/; +const PERMISSIVE_VALUE_REGEX = /^(([^'"`{}()\\]|\\.)+)$/; const getField = ( value: string @@ -31,16 +32,6 @@ const getString = ( value: string ) => value.slice(1, value.length - 1).replace(/\\(.)/g, '$1'); -const getValue = ( - value: string -) => { - value = (value.match(VALUE_REGEX) as any)[1]; - - return isNumeric(value) ? - +value : - value.replace(/\\(.)/g, '$1'); -}; - export const stringExpression: IUnboundedLexeme = { present: (tree: ISyntaxTree) => getString(tree.value), resolve: (_target: any, tree: ISyntaxTree) => { @@ -55,17 +46,41 @@ export const stringExpression: IUnboundedLexeme = { type: LexemeType.Expression }; -export const valueExpression: IUnboundedLexeme = { - present: (tree: ISyntaxTree) => getValue(tree.value), - resolve: (_target: any, tree: ISyntaxTree) => { - if (VALUE_REGEX.test(tree.value)) { - return getValue(tree.value); - } else { - throw new Error(); - } - }, - regexp: VALUE_REGEX, - regexpMatch: 1, - subType: 'value', - type: LexemeType.Expression -}; \ No newline at end of file +const getValueFactory = (regex: RegExp) => (value: string) => { + value = (value.match(regex) as any)[1]; + + return isNumeric(value) ? + +value : + value.replace(/\\(.)/g, '$1'); +}; + +const valueExpressionFactory = ( + regex: RegExp, + transform?: (v: any) => any +): IUnboundedLexeme => { + const getValue = getValueFactory(regex); + + return { + present: (tree: ISyntaxTree) => getValue(tree.value), + resolve: (_target: any, tree: ISyntaxTree) => { + if (regex.test(tree.value)) { + return getValue(tree.value); + } else { + throw new Error(); + } + }, + regexp: regex, + regexpMatch: 1, + subType: 'value', + transform, + type: LexemeType.Expression + }; +}; + +export const valueExpression = valueExpressionFactory(VALUE_REGEX); +export const permissiveValueExpression = valueExpressionFactory( + PERMISSIVE_VALUE_REGEX, + (v: any) => typeof v === 'string' && v.indexOf(' ') !== -1 ? + `"${v}"` : + v +); diff --git a/src/dash-table/syntax-tree/lexeme/unary.ts b/src/dash-table/syntax-tree/lexeme/unary.ts index aab1d5f52..67a969ecf 100644 --- a/src/dash-table/syntax-tree/lexeme/unary.ts +++ b/src/dash-table/syntax-tree/lexeme/unary.ts @@ -30,7 +30,7 @@ function relationalSyntaxer([left, lexeme]: any[]) { } function relationalEvaluator( - fn: (opValue: any[]) => boolean + fn: (opValue: any) => boolean ) { return (target: any, tree: ISyntaxTree) => fn(evaluator(target, tree)); } @@ -75,6 +75,11 @@ export const isEven: IUnboundedLexeme = R.merge({ regexp: /^(is even)/i }, LEXEME_BASE); +export const isBlank: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => opValue === undefined || opValue === null || opValue === ''), + regexp: /^(is blank)/i +}, LEXEME_BASE); + export const isNil: IUnboundedLexeme = R.merge({ evaluate: relationalEvaluator(opValue => opValue === undefined || opValue === null), regexp: /^(is nil)/i diff --git a/src/dash-table/syntax-tree/lexicon/column.ts b/src/dash-table/syntax-tree/lexicon/column.ts index b73c85231..e8bfaf08a 100644 --- a/src/dash-table/syntax-tree/lexicon/column.ts +++ b/src/dash-table/syntax-tree/lexicon/column.ts @@ -1,7 +1,7 @@ import { fieldExpression, stringExpression, - valueExpression + permissiveValueExpression } from '../lexeme/expression'; import { contains, @@ -14,6 +14,7 @@ import { notEqual } from '../lexeme/relational'; import { + isBlank, isBool, isEven, isNil, @@ -45,7 +46,8 @@ const lexicon: ILexeme[] = [ if: ifLeading, terminal: false })), - ...[isBool, + ...[isBlank, + isBool, isEven, isNil, isNum, @@ -60,8 +62,8 @@ const lexicon: ILexeme[] = [ })), ...[ fieldExpression, - stringExpression, - valueExpression + permissiveValueExpression, + stringExpression ].map(exp => ({ ...exp, if: ifExpression, diff --git a/src/dash-table/syntax-tree/lexicon/columnMulti.ts b/src/dash-table/syntax-tree/lexicon/columnMulti.ts index 43406f7df..f3e138fee 100644 --- a/src/dash-table/syntax-tree/lexicon/columnMulti.ts +++ b/src/dash-table/syntax-tree/lexicon/columnMulti.ts @@ -19,6 +19,7 @@ import { notEqual } from '../lexeme/relational'; import { + isBlank, isBool, isEven, isNil, @@ -56,7 +57,8 @@ const lexicon: ILexeme[] = [ if: ifRelationalOperator, terminal: false })), - ...[isBool, + ...[isBlank, + isBool, isEven, isNil, isNum, diff --git a/src/dash-table/syntax-tree/lexicon/query.ts b/src/dash-table/syntax-tree/lexicon/query.ts index 4dfe66c9a..a53698a65 100644 --- a/src/dash-table/syntax-tree/lexicon/query.ts +++ b/src/dash-table/syntax-tree/lexicon/query.ts @@ -27,6 +27,7 @@ import { notEqual } from '../lexeme/relational'; import { + isBlank, isBool, isEven, isNil, @@ -89,7 +90,8 @@ const lexicon: ILexeme[] = [ if: ifRelationalOperator, terminal: false })), - ...[isBool, + ...[isBlank, + isBool, isEven, isNil, isNum, diff --git a/tests/cypress/tests/standalone/filtering_test.ts b/tests/cypress/tests/standalone/filtering_test.ts index ba9dae5fb..6a23a6e62 100644 --- a/tests/cypress/tests/standalone/filtering_test.ts +++ b/tests/cypress/tests/standalone/filtering_test.ts @@ -74,19 +74,19 @@ describe('filter', () => { DashTable.getFilterById('ccc').click(); DOM.focused.type(`gt`); DashTable.getFilterById('ddd').click(); - DOM.focused.type('20 a000'); + DOM.focused.type('"20 a000'); DashTable.getFilterById('eee').click(); DOM.focused.type('is prime2'); DashTable.getFilterById('bbb').click(); - DOM.focused.type('! !'); + DOM.focused.type('! !"'); DashTable.getFilterById('ccc').click(); DashTable.getCellById(0, 'ccc').within(() => cy.get('.dash-cell-value').should('have.html', cell_0)); DashTable.getCellById(1, 'ccc').within(() => cy.get('.dash-cell-value').should('have.html', cell_1)); - DashTable.getFilterById('bbb').within(() => cy.get('input').should('have.value', '! !')); + DashTable.getFilterById('bbb').within(() => cy.get('input').should('have.value', '! !"')); DashTable.getFilterById('ccc').within(() => cy.get('input').should('have.value', 'gt')); - DashTable.getFilterById('ddd').within(() => cy.get('input').should('have.value', '20 a000')); + DashTable.getFilterById('ddd').within(() => cy.get('input').should('have.value', '"20 a000')); DashTable.getFilterById('eee').within(() => cy.get('input').should('have.value', 'is prime2')); DashTable.getFilterById('bbb').should('have.class', 'invalid'); diff --git a/tests/cypress/tests/unit/query_syntactic_tree_test.ts b/tests/cypress/tests/unit/query_syntactic_tree_test.ts index 9af69d3e4..f99010eea 100644 --- a/tests/cypress/tests/unit/query_syntactic_tree_test.ts +++ b/tests/cypress/tests/unit/query_syntactic_tree_test.ts @@ -338,6 +338,16 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); + it('can check blank', () => { + const tree = new QuerySyntaxTree('{d} is blank'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate(data0)).to.equal(true); + expect(tree.evaluate(data1)).to.equal(false); + expect(tree.evaluate(data2)).to.equal(true); + expect(tree.evaluate(data3)).to.equal(false); + }); + it('can invert check nil', () => { const tree = new QuerySyntaxTree('!({d} is nil)'); diff --git a/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts b/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts index 8582c1e10..5218ced39 100644 --- a/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts +++ b/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts @@ -97,6 +97,15 @@ describe('Single Column Syntax Tree', () => { expect(tree.toQueryString()).to.equal('{a} = 1'); }); + it.only('can be permissive value expression', () => { + const tree = new SingleColumnSyntaxTree('Hello world', COLUMN_TEXT); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 'Hello world' })).to.equal(true); + expect(tree.evaluate({ a: 'Helloworld' })).to.equal(false); + expect(tree.toQueryString()).to.equal('{a} contains "Hello world"'); + }); + it('`undefined` column type can use `contains`', () => { const tree = new SingleColumnSyntaxTree('contains 1', COLUMN_UNDEFINED);