diff --git a/.circleci/config.yml b/.circleci/config.yml index f10b22159..619292432 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2 jobs: - "test-v0": + "test": docker: - image: circleci/python:3.6.7-node-browsers - image: cypress/base:10 @@ -30,67 +30,7 @@ jobs: sudo pip install --upgrade virtualenv python -m venv venv || virtualenv venv . venv/bin/activate - pip install -r requirements-base.txt --quiet - - - run: - name: Install dependencies (dash) - command: | - git clone git@github.com:plotly/dash.git - git clone git@github.com:plotly/dash-renderer.git - git clone git@github.com:plotly/dash-core-components.git - git clone git@github.com:plotly/dash-html-components.git - . venv/bin/activate - pip install -e ./dash --quiet - cd dash-renderer && npm install --ignore-scripts && npm run build && pip install -e . && cd .. - cd dash-core-components && npm install --ignore-scripts && npm run build && pip install -e . && cd .. - cd dash-html-components && npm install --ignore-scripts && npm run build && pip install -e . && cd .. - - - run: - name: Build - command: | - . venv/bin/activate - npm run private::build:js-test - npm run private::build:py - pip install -e . - - - run: - name: Run tests - command: | - . venv/bin/activate - npm run test-v0 - - - "test-v1": - docker: - - image: circleci/python:3.6.7-node-browsers - - image: cypress/base:10 - - steps: - - checkout - - restore_cache: - key: deps1-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} - - run: - name: Install npm packages - command: npm install - - run: - name: Cypress Install - command: | - $(npm bin)/cypress install - - - save_cache: - key: deps1-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} - paths: - - node_modules - - /home/circleci/.cache/Cypress - - - run: - name: Install requirements - command: | - sudo pip install --upgrade virtualenv - python -m venv venv || virtualenv venv - . venv/bin/activate - pip install -r requirements-base.txt --quiet - pip install -r requirements-v1.txt --quiet + pip install -r requirements.txt --quiet - run: name: Build @@ -104,7 +44,7 @@ jobs: name: Run tests command: | . venv/bin/activate - npm run test-v1 + npm run test "visual-test": @@ -172,7 +112,7 @@ jobs: when: always - "python-3.6-v0": + "python-3.6": docker: - image: circleci/python:3.6.7-stretch-node-browsers @@ -224,53 +164,11 @@ jobs: python -m unittest tests.dash.test_integration - "python-3.6-v1": - docker: - - image: circleci/python:3.6.7-stretch-node-browsers - - environment: - PERCY_ENABLED: True - PERCY_PROJECT: plotly/dash-table-python-v1 - - steps: - - checkout - - - run: - name: Inject Percy Environment variables - command: | - echo 'export PERCY_TOKEN="$PERCY_PYTHON_TOKEN_V1"' >> $BASH_ENV - - - run: - name: Install requirements - command: | - sudo pip install --upgrade virtualenv - python -m venv venv || virtualenv venv - . venv/bin/activate - pip install -r requirements-base.txt --quiet - pip install -r requirements-v1.txt --quiet - npm install - - - run: - name: Install test requirements - command: | - . venv/bin/activate - npm run build - pip install -e . - - - run: - name: Run integration tests - command: | - . venv/bin/activate - python -m unittest tests.dash.test_integration - - workflows: version: 2 build: jobs: - - "python-3.6-v0" - - "python-3.6-v1" + - "python-3.6" - "node" - - "test-v0" - - "test-v1" + - "test" - "visual-test" diff --git a/.config/webpack/base.js b/.config/webpack/base.js index 32c6d61f0..362c631f9 100644 --- a/.config/webpack/base.js +++ b/.config/webpack/base.js @@ -43,6 +43,10 @@ module.exports = (preprocessor = {}, mode = 'development') => { test: /demo[/\\]index.html?$/, loader: 'file-loader?name=index.[ext]' }, + { + test: /\.csv$/, + loader: 'raw-loader' + }, { test: /\.ts(x?)$/, exclude: /node_modules/, diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a4885e8..4cf9c7646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] +### Added +[#397](https://github.com/plotly/dash-table/pull/397) +- Improve filtering syntax and capabilities + - new field syntax `{myField}` + - short form by-column filter + - implicit column and `eq` operator (e.g `"value"`) + - implicit column (e.g `ne "value"`) + - explicit form (e.g `{field} ne "value"`) + - new `contains` relational operator for strings + - new readonly `derived_filter_structure` prop exposing the query structure in a programmatically friendlier way + +### Changed +[#397](https://github.com/plotly/dash-table/pull/397) +- Rename `filtering_settings` to `filter` + ## [3.6.0] - 2019-03-04 ### Fixed [#189](https://github.com/plotly/dash-table/issues/189) diff --git a/demo/App.js b/demo/App.js index ce51c534d..07dc6acc2 100644 --- a/demo/App.js +++ b/demo/App.js @@ -14,6 +14,7 @@ class App extends Component { super(); this.state = AppState; + this.state.temp_filtering = ''; const setProps = memoizeOne(() => { return newProps => { @@ -33,15 +34,30 @@ class App extends Component { const mode = Environment.searchParams.get('mode'); if (mode === AppMode.Filtering) { - return (); + return ( +
+ + this.setState({ temp_filtering: e.target.value }) + } + onBlur={e => { + const tableProps = R.clone(this.state.tableProps); + tableProps.filter = e.target.value; + + this.setState({ tableProps }); + }} /> +
); } } diff --git a/demo/AppMode.ts b/demo/AppMode.ts index 631c922a4..277419d9a 100644 --- a/demo/AppMode.ts +++ b/demo/AppMode.ts @@ -65,7 +65,7 @@ function getBaseTableProps(mock: IDataMock) { max_width: '1000px', width: '1000px' }, - style_data_conditional: [ + style_cell_conditional: [ { max_width: 150, min_width: 150, width: 150 }, { if: { column_id: 'rows' }, max_width: 60, min_width: 60, width: 60 }, { if: { column_id: 'bbb' }, max_width: 200, min_width: 200, width: 200 }, @@ -150,7 +150,7 @@ function getTooltipsState() { state.tableProps.column_conditional_tooltips = [{ if: { column_id: 'aaa-readonly', - filter: `aaa is prime` + filter: `{aaa} is prime` }, type: TooltipSyntax.Markdown, value: `### Go Proverbs\nCapture three to get an eye` diff --git a/package.json b/package.json index 5f3905aeb..93c1f639b 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,11 @@ "private::runtests:unit": "cypress run --browser chrome --spec 'tests/cypress/tests/unit/**/*'", "private::runtests:standalone": "cypress run --browser chrome --spec 'tests/cypress/tests/standalone/**/*'", "private::runtests:server": "cypress run --browser chrome --spec 'tests/cypress/tests/server/**/*'", - "private::runtests-v0": "run-s private::runtests:server", - "private::runtests-v1": "run-s private::runtests:python private::runtests:unit private::runtests:standalone private::runtests:server", + "private::runtests": "run-s private::runtests:python private::runtests:unit private::runtests:standalone private::runtests:server", "build.watch": "webpack-dev-server --content-base dash_table --mode development", "build": "run-s private::build:js private::build:py", "lint": "run-s private::lint:*", - "test-v0": "run-p --race private::host* private::runtests-v0", - "test-v1": "run-p --race private::host* private::runtests-v1", + "test": "run-p --race private::host* private::runtests", "test.visual": "build-storybook && percy-storybook", "test.visual-local": "build-storybook", "test.watch": "run-p --race \"private::build:js-test-watch\" --race private::host* private::opentests" @@ -55,6 +53,7 @@ "@storybook/cli": "^5.0.5", "@storybook/react": "^5.0.5", "@types/d3-format": "^1.3.1", + "@types/papaparse": "^4.5.9", "@types/ramda": "^0.26.5", "@types/react": "^16.8.8", "@types/react-dom": "^16.8.3", @@ -71,7 +70,9 @@ "less-loader": "^4.1.0", "npm": "^6.9.0", "npm-run-all": "^4.1.5", + "papaparse": "^4.6.3", "ramda": "^0.26.1", + "raw-loader": "^2.0.0", "react": "16.8.5", "react-docgen": "^4.1.0", "react-dom": "16.8.5", diff --git a/requirements-v1.txt b/requirements-v1.txt deleted file mode 100644 index 5d3d4a3a7..000000000 --- a/requirements-v1.txt +++ /dev/null @@ -1,4 +0,0 @@ -git+git://github.com/plotly/dash@release-v1#egg=dash -git+git://github.com/plotly/dash-core-components@release-v1#egg=dash_core_components -git+git://github.com/plotly/dash-html-components@release-v1#egg=dash_html_components -git+git://github.com/plotly/dash-renderer@release-v1#egg=dash_renderer \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 690080fe8..5d17344a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -r requirements-base.txt --r requirements-v1.txt \ No newline at end of file +-r requirements-v0.txt \ No newline at end of file diff --git a/src/core/syntax-tree/index.ts b/src/core/syntax-tree/index.ts index 292d351d0..bf3e3568f 100644 --- a/src/core/syntax-tree/index.ts +++ b/src/core/syntax-tree/index.ts @@ -1,20 +1,63 @@ +import * as R from 'ramda'; + import Logger from 'core/Logger'; -import lexer from 'core/syntax-tree/lexer'; -import syntaxer, { ISyntaxerResult } from 'core/syntax-tree/syntaxer'; +import lexer, { ILexerResult } from 'core/syntax-tree/lexer'; +import syntaxer, { ISyntaxerResult, ISyntaxTree } from 'core/syntax-tree/syntaxer'; +import { Lexicon } from './lexicon'; + +interface IStructure { + subType?: string; + type: string; + value: any; + + block?: IStructure; + left?: IStructure; + right?: IStructure; +} + +function toStructure(tree: ISyntaxTree): IStructure { + const { block, left, lexeme, right, value } = tree; + + const res: IStructure = { + subType: lexeme.subType, + type: lexeme.type, + value: lexeme.present ? lexeme.present(tree) : value + }; + + if (block) { + res.block = toStructure(block); + } + + if (left) { + res.left = toStructure(left); + } + + if (right) { + res.right = toStructure(right); + } + + return res; +} export default class SyntaxTree { - private result: ISyntaxerResult; + protected lexerResult: ILexerResult; + protected syntaxerResult: ISyntaxerResult; get isValid() { - return this.result.valid; + return this.syntaxerResult.valid; } private get tree() { - return this.result.tree; + return this.syntaxerResult.tree; } - constructor(private readonly query: string) { - this.result = syntaxer(lexer(this.query)); + constructor( + public readonly lexicon: Lexicon, + public readonly query: string, + postProcessor: (res: ILexerResult) => ILexerResult = res => res + ) { + this.lexerResult = postProcessor(lexer(this.lexicon, this.query)); + this.syntaxerResult = syntaxer(this.lexerResult); } evaluate = (target: any) => { @@ -33,4 +76,18 @@ export default class SyntaxTree { filter = (targets: any[]) => { return targets.filter(this.evaluate); } + + toQueryString() { + return this.lexerResult.valid ? + R.map(l => l.value, this.lexerResult.lexemes).join(' ') : + ''; + } + + toStructure() { + if (!this.isValid || !this.syntaxerResult.tree) { + return null; + } + + return toStructure(this.syntaxerResult.tree); + } } \ No newline at end of file diff --git a/src/core/syntax-tree/lexer.ts b/src/core/syntax-tree/lexer.ts index 6daeca311..c0fa303f5 100644 --- a/src/core/syntax-tree/lexer.ts +++ b/src/core/syntax-tree/lexer.ts @@ -1,4 +1,6 @@ -import Lexicon, { ILexeme } from 'core/syntax-tree/lexicon'; +import * as R from 'ramda'; + +import { ILexeme, Lexicon } from 'core/syntax-tree/lexicon'; export interface ILexerResult { lexemes: ILexemeResult[]; @@ -11,32 +13,43 @@ export interface ILexemeResult { value?: string; } -export default function lexer(query: string): ILexerResult { - let lexeme: ILexeme | null = null; - +export default function lexer(lexicon: Lexicon, query: string): ILexerResult { let result: ILexemeResult[] = []; + while (query.length) { query = query.replace(/^\s+/, ''); - let lexemes: ILexeme[] = Lexicon.filter(_lexeme => - lexeme && - _lexeme.when && - _lexeme.when.indexOf(lexeme.name) !== -1); + const previous = result.slice(-1)[0]; + const previousLexeme = previous ? previous.lexeme : null; - if (!lexemes.length) { - lexemes = Lexicon; - } + let lexemes: ILexeme[] = lexicon.filter(lexeme => + lexeme.if && + (!Array.isArray(lexeme.if) ? + lexeme.if(result, previous) : + (previousLexeme ? + lexeme.if && lexeme.if.indexOf(previousLexeme.type) !== -1 : + lexeme.if && lexeme.if.indexOf(undefined) !== -1)) + ); - lexeme = lexemes.find(_lexeme => _lexeme.regexp.test(query)) || null; - if (!lexeme) { + const next = R.find(lexeme => lexeme.regexp.test(query), lexemes); + if (!next) { return { lexemes: result, valid: false, error: query }; } - const value = (query.match(lexeme.regexp) || [])[0]; - result.push({ lexeme, value }); + const value = (query.match(next.regexp) || [])[next.regexpMatch || 0]; + result.push({ lexeme: next, value }); query = query.substring(value.length); } - return { lexemes: result, valid: true }; + const last = result.slice(-1)[0]; + + const terminal: boolean = last && (typeof last.lexeme.terminal === 'function' ? + last.lexeme.terminal(result, last) : + last.lexeme.terminal); + + return { + lexemes: result, + valid: !last || terminal + }; } \ No newline at end of file diff --git a/src/core/syntax-tree/lexicon.ts b/src/core/syntax-tree/lexicon.ts index 3be0a5912..6a26e1b77 100644 --- a/src/core/syntax-tree/lexicon.ts +++ b/src/core/syntax-tree/lexicon.ts @@ -1,246 +1,36 @@ -import Logger from 'core/Logger'; -import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; +import { ILexemeResult } from './lexer'; +import { ISyntaxTree } from './syntaxer'; export enum LexemeType { - And = 'and', BlockClose = 'close-block', BlockOpen = 'open-block', - BinaryOperator = 'logical-binary-operator', + LogicalOperator = 'logical-operator', + RelationalOperator = 'relational-operator', + UnaryOperator = 'unary-operator', Expression = 'expression', - Or = 'or', - Operand = 'operand', - UnaryNot = 'unary-not', - UnaryOperator = 'logical-unary-operator' + Operand = 'operand' } -export interface ILexeme { +export interface IUnboundedLexeme { evaluate?: (target: any, tree: ISyntaxTree) => boolean; + present?: (tree: ISyntaxTree) => any; resolve?: (target: any, tree: ISyntaxTree) => any; - name: string; + subType?: string; + type: string; nesting?: number; priority?: number; regexp: RegExp; + regexpMatch?: number; syntaxer?: (lexs: any[], pivot: any, pivotIndex: number) => any; - when?: string[]; } -const isPrime = (c: number) => { - if (c === 2) { return true; } - if (c < 2 || c % 2 === 0) { return false; } - for (let n = 3; n * n <= c; n += 2) { if (c % n === 0) { return false; } } - return true; -}; - -const operand = { - resolve: (target: any, tree: ISyntaxTree) => { - if (/^(('([^'\\]|\\.)+')|("([^"\\]|\\.")+")|(`([^`\\]|\\.)+`))$/.test(tree.value)) { - return target[ - tree.value.slice(1, tree.value.length - 1) - ]; - } else if (/^(\w|[:.\-+])+$/.test(tree.value)) { - return target[tree.value]; - } - }, - regexp: /^(('([^'\\]|\\.)+')|("([^"\\]|\\.)+")|(`([^`\\]|\\.)+`)|(\w|[:.\-+])+)/ -}; - -const expression = { - resolve: (target: any, tree: ISyntaxTree) => { - if (/^(('([^'\\]|\\.)+')|("([^"\\]|\\.)+")|(`([^`\\]|\\.)+`))$/.test(tree.value)) { - return tree.value.slice(1, tree.value.length - 1); - } else if (/^(num|str)\(.*\)$/.test(tree.value)) { - const res = tree.value.match(/^(\w+)\((.*)\)$/); - if (res) { - const [, op, value] = res; - - switch (op) { - case 'num': - return parseFloat(value); - case 'str': - default: - return value; - } - } else { - throw Error(); - } - } else { - return target[tree.value]; - } - }, - regexp: /^(((num|str)\([^()]*\))|('([^'\\]|\\.)+')|("([^"\\]|\\.)+")|(`([^`\\]|\\.)+`)|(\w|[:.\-+])+)/ -}; - -const lexicon: ILexeme[] = [ - { - evaluate: (target, tree) => { - Logger.trace('evalute -> &&', target, tree); - - const t = tree as any; - const lv = t.left.lexeme.evaluate(target, t.left); - const rv = t.right.lexeme.evaluate(target, t.right); - return lv && rv; - }, - name: LexemeType.And, - priority: 2, - regexp: /^(and\s|&&)/i, - syntaxer: (lexs: any[], pivot: any, pivotIndex: number) => { - return Object.assign({ - left: lexs.slice(0, pivotIndex), - right: lexs.slice(pivotIndex + 1) - }, pivot); - } - }, - { - evaluate: (target, tree) => { - Logger.trace('evalute -> ||', target, tree); - - const t = tree as any; - - return t.left.lexeme.evaluate(target, t.left) || - t.right.lexeme.evaluate(target, t.right); - }, - name: LexemeType.Or, - priority: 3, - regexp: /^(or\s|\|\|)/i, - syntaxer: (lexs: any[], pivot: any, pivotIndex: number) => { - return Object.assign({ - left: lexs.slice(0, pivotIndex), - right: lexs.slice(pivotIndex + 1) - }, pivot); - } - }, - { - name: LexemeType.BlockClose, - nesting: -1, - regexp: /^\)/ - }, - { - evaluate: (target, tree) => { - Logger.trace('evaluate -> ()', target, tree); - - const t = tree as any; - - return t.block.lexeme.evaluate(target, t.block); - }, - name: LexemeType.BlockOpen, - nesting: 1, - priority: 1, - regexp: /^\(/, - syntaxer: (lexs: any[]) => { - return Object.assign({ - block: lexs.slice(1, lexs.length - 1) - }, lexs[0]); - }, - when: [LexemeType.UnaryNot] - }, - { - ...operand, - name: LexemeType.Operand - }, - { - evaluate: (target, tree) => { - Logger.trace('evaluate -> binary', target, tree); - - const t = tree as any; - - const opValue = t.left.lexeme.resolve(target, t.left); - const expValue = t.right.lexeme.resolve(target, t.right); - Logger.trace(`opValue: ${opValue}, expValue: ${expValue}`); - - switch (tree.value.toLowerCase()) { - case 'eq': - case '=': - return opValue === expValue; - case 'gt': - case '>': - return opValue > expValue; - case 'ge': - case '>=': - return opValue >= expValue; - case 'lt': - case '<': - return opValue < expValue; - case 'le': - case '<=': - return opValue <= expValue; - case 'ne': - case '!=': - return opValue !== expValue; - default: - throw new Error(); - } - }, - name: LexemeType.BinaryOperator, - priority: 0, - regexp: /^(>=|<=|>|<|!=|=|ge|le|gt|lt|eq|ne)/i, - syntaxer: (lexs: any[]) => { - let [left, lexeme, right] = lexs; - - return Object.assign({ left, right }, lexeme); - }, - when: [LexemeType.Operand] - }, - { - evaluate: (target, tree) => { - Logger.trace('evaluate -> unary', target, tree); - - const t = tree as any; - const opValue = t.block.lexeme.resolve(target, t.block); - - switch (tree.value.toLowerCase()) { - case 'is even': - return typeof opValue === 'number' && opValue % 2 === 0; - case 'is nil': - return opValue === undefined || opValue === null; - case 'is bool': - return typeof opValue === 'boolean'; - case 'is odd': - return typeof opValue === 'number' && opValue % 2 === 1; - case 'is num': - return typeof opValue === 'number'; - case 'is object': - return opValue !== null && typeof opValue === 'object'; - case 'is str': - return typeof opValue === 'string'; - case 'is prime': - return typeof opValue === 'number' && isPrime(opValue); - default: - throw new Error(); - } - }, - name: LexemeType.UnaryOperator, - priority: 0, - regexp: /^((is nil)|(is odd)|(is even)|(is bool)|(is num)|(is object)|(is str)|(is prime))/i, - syntaxer: (lexs: any[]) => { - let [block, lexeme] = lexs; - - return Object.assign({ block }, lexeme); - }, - when: [LexemeType.Operand] - }, - { - evaluate: (target, tree) => { - Logger.trace('evaluate -> unary not', target, tree); - - const t = tree as any; +export interface ILexeme extends IUnboundedLexeme { + terminal: boolean | ((lexemes: ILexemeResult[], previous: ILexemeResult) => boolean); + if: (string | undefined)[] | ((lexemes: ILexemeResult[], previous: ILexemeResult) => boolean); +} - return !t.block.lexeme.evaluate(target, t.block); - }, - name: LexemeType.UnaryNot, - priority: 1.5, - regexp: /^!/, - syntaxer: (lexs: any[]) => { - return Object.assign({ - block: lexs.slice(1, lexs.length) - }, lexs[0]); - }, - when: [LexemeType.UnaryNot] - }, - { - ...expression, - name: LexemeType.Expression, - when: [LexemeType.BinaryOperator] - } -]; +export function boundLexeme(lexeme: IUnboundedLexeme) { + return { ...lexeme, if: () => false, terminal: false }; +} -export default lexicon; \ No newline at end of file +export type Lexicon = ILexeme[]; \ No newline at end of file diff --git a/src/core/syntax-tree/syntaxer.ts b/src/core/syntax-tree/syntaxer.ts index 8729ed1df..d420747e0 100644 --- a/src/core/syntax-tree/syntaxer.ts +++ b/src/core/syntax-tree/syntaxer.ts @@ -53,7 +53,7 @@ const parser = (lexs: ILexemeResult[]): ISyntaxTree => { return tree; } else { - throw new Error(pivot.lexeme.name); + throw new Error(pivot.lexeme.type); } }; diff --git a/src/dash-table/components/ControlledTable/index.tsx b/src/dash-table/components/ControlledTable/index.tsx index 763eda7c3..a9d8dc57e 100644 --- a/src/dash-table/components/ControlledTable/index.tsx +++ b/src/dash-table/components/ControlledTable/index.tsx @@ -29,6 +29,8 @@ import { derivedTableStyle } from 'dash-table/derived/style'; import { IStyle } from 'dash-table/derived/style/props'; import TableTooltip from './fragments/TableTooltip'; +import queryLexicon from 'dash-table/syntax-tree/lexicon/query'; + const sortNumerical = R.sort((a, b) => a - b); const DEFAULT_STYLE = { @@ -50,12 +52,12 @@ export default class ControlledTable extends PureComponent this.updateStylesheet(); } - getLexerResult = memoizeOne(lexer); + getLexerResult = memoizeOne(lexer.bind(undefined, queryLexicon)); get lexerResult() { - const { filtering_settings } = this.props; + const { filter } = this.props; - return this.getLexerResult(filtering_settings); + return this.getLexerResult(filter); } private updateStylesheet() { @@ -533,7 +535,7 @@ export default class ControlledTable extends PureComponent columns, data, editable, - filtering_settings, + filter, setProps, sorting_settings, viewport @@ -550,7 +552,7 @@ export default class ControlledTable extends PureComponent columns, data, true, - !sorting_settings.length || !filtering_settings.length + !sorting_settings.length || !filter.length ); if (result) { diff --git a/src/dash-table/components/Filter/Column.tsx b/src/dash-table/components/Filter/Column.tsx index 5f1aad2ce..1c32da746 100644 --- a/src/dash-table/components/Filter/Column.tsx +++ b/src/dash-table/components/Filter/Column.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { CSSProperties, PureComponent } from 'react'; import IsolatedInput from 'core/components/IsolatedInput'; @@ -11,6 +11,7 @@ interface IColumnFilterProps { columnId: ColumnId; isValid: boolean; setFilter: SetFilter; + style?: CSSProperties; value?: string; } @@ -36,12 +37,14 @@ export default class ColumnFilter extends PureComponent { classes, columnId, isValid, + style, value } = this.props; return ( void; +type SetFilter = (filter: string, rawFilter: string) => void; export interface IFilterOptions { columns: VisibleColumns; - fillerColumns: number; + filter: string; filtering: Filtering; - filtering_settings: string; filtering_type: FilteringType; id: string; + rawFilterQuery: string; + row_deletable: boolean; + row_selectable: RowSelection; setFilter: SetFilter; style_cell: Style; style_cell_conditional: Cells; @@ -31,9 +34,11 @@ export interface IFilterOptions { export default class FilterFactory { private readonly handlers = new Map(); - private readonly ops = new Map(); private readonly filterStyles = derivedFilterStyles(); private readonly relevantStyles = derivedRelevantFilterStyles(); + private readonly headerOperations = derivedHeaderOperations(); + + private ops = new Map(); private get props() { return this.propsFn(); @@ -43,139 +48,90 @@ export default class FilterFactory { } - private onChange = (columnId: ColumnId, ops: Map, setFilter: SetFilter, ev: any) => { + private onChange = (columnId: ColumnId, setFilter: SetFilter, ev: any) => { Logger.debug('Filter -- onChange', columnId, ev.target.value && ev.target.value.trim()); const value = ev.target.value.trim(); + const safeColumnId = columnId.toString(); if (value && value.length) { - ops.set(columnId.toString(), value); + this.ops.set(safeColumnId, new SingleColumnSyntaxTree(safeColumnId, value)); } else { - ops.delete(columnId.toString()); + this.ops.delete(safeColumnId); } - setFilter(R.map( - ([cId, filter]) => `"${cId}" ${filter}`, - R.filter( - ([cId]) => this.isFragmentValid(cId), - Array.from(ops.entries()) - ) - ).join(' && ')); + const asts = Array.from(this.ops.values()); + const globalFilter = getMultiColumnQueryString(asts); + + const rawGlobalFilter = R.map( + ast => ast.query || '', + R.filter(ast => Boolean(ast), asts) + ).join(' && '); + + setFilter(globalFilter, rawGlobalFilter); } - private getEventHandler = (fn: Function, columnId: ColumnId, ops: Map, setFilter: SetFilter): any => { + private getEventHandler = (fn: Function, columnId: ColumnId, setFilter: SetFilter): any => { const fnHandler = (this.handlers.get(fn) || this.handlers.set(fn, new Map()).get(fn)); const columnIdHandler = (fnHandler.get(columnId) || fnHandler.set(columnId, new Map()).get(columnId)); return ( columnIdHandler.get(setFilter) || - (columnIdHandler.set(setFilter, fn.bind(this, columnId, ops, setFilter)).get(setFilter)) + (columnIdHandler.set(setFilter, fn.bind(this, columnId, setFilter)).get(setFilter)) ); } - private respectsBasicSyntax(lexemes: ILexemeResult[], allowMultiple: boolean = true) { - const allowedLexemeTypes = [ - LexemeType.BinaryOperator, - LexemeType.Expression, - LexemeType.Operand, - LexemeType.UnaryOperator - ]; - - if (allowMultiple) { - allowedLexemeTypes.push(LexemeType.And); - } - - const allAllowed = R.all( - item => R.contains(item.lexeme.name, allowedLexemeTypes), - lexemes - ); - - if (!allAllowed) { - return false; - } - - const fields = R.map( - item => item.value, - R.filter( - i => i.lexeme.name === LexemeType.Operand, - lexemes - ) - ); - - const uniqueFields = R.uniq(fields); - - if (fields.length !== uniqueFields.length) { - return false; - } - - return true; - } + private updateOps = memoizeOne((query: string) => { + const multiQuery = new MultiColumnsSyntaxTree(query); - private isBasicFilter( - lexerResult: ILexerResult, - syntaxerResult: ISyntaxerResult, - allowMultiple: boolean = true - ) { - return lexerResult.valid && - syntaxerResult.valid && - this.respectsBasicSyntax(lexerResult.lexemes, allowMultiple); - } - - private updateOps(query: string) { - const lexerResult = lexer(query); - const syntaxerResult = syntaxer(lexerResult); - - if (!this.isBasicFilter(lexerResult, syntaxerResult)) { + const newOps = getSingleColumnMap(multiQuery); + if (!newOps) { return; } - const { tree } = syntaxerResult; - if (!tree) { - this.ops.clear(); - return; - } - - const toCheck: (ISyntaxTree | undefined)[] = [tree]; - while (toCheck.length) { - const item = toCheck.pop(); - if (!item) { - continue; - } - - if (item.lexeme.name === LexemeType.UnaryOperator && item.block) { - this.ops.set(item.block.value, item.value); - } else if (item.lexeme.name === LexemeType.BinaryOperator && item.left && item.right) { - this.ops.set(item.left.value, `${item.value} ${item.right.value}`); - } else { - toCheck.push(item.left); - toCheck.push(item.block); - toCheck.push(item.right); + /* Mapping multi-column to single column queries will expand + * compressed forms. If the new ast query is equal to the + * old one, keep the old one instead. + * + * If the value was changed by the user, the current ast will + * have been modified already and the UI experience will also + * be consistent in that case. + */ + R.forEach(([key, ast]) => { + const newAst = newOps.get(key); + + if (newAst && newAst.toQueryString() === ast.toQueryString()) { + newOps.set(key, ast); } - } - } - - private isFragmentValidOrNull(columnId: ColumnId) { - const op = this.ops.get(columnId.toString()); - - return !op || !op.trim().length || this.isFragmentValid(columnId); - } - - private isFragmentValid(columnId: ColumnId) { - const op = this.ops.get(columnId.toString()); - - const lexerResult = lexer(`"${columnId}" ${op}`); - const syntaxerResult = syntaxer(lexerResult); - - return syntaxerResult.valid && this.isBasicFilter(lexerResult, syntaxerResult, false); - } + }, Array.from(this.ops.entries())); + + this.ops = newOps; + }); + + private filter = memoizerCache<[ColumnId, number]>()(( + column: ColumnId, + index: number, + ast: SingleColumnSyntaxTree | undefined, + setFilter: SetFilter + ) => { + return (); + }); public createFilters() { const { columns, - fillerColumns, + filter, filtering, - filtering_settings, filtering_type, + row_deletable, + row_selectable, setFilter, style_cell, style_cell_conditional, @@ -187,7 +143,7 @@ export default class FilterFactory { return []; } - this.updateOps(filtering_settings); + this.updateOps(filter); if (filtering_type === FilteringType.Basic) { const filterStyles = this.relevantStyles( @@ -203,14 +159,12 @@ export default class FilterFactory { ); const filters = R.addIndex(R.map)((column, index) => { - return (); + return this.filter.get(column.id, index)( + column.id, + index, + this.ops.get(column.id.toString()), + setFilter + ); }, columns); const styledFilters = arrayMap( @@ -218,9 +172,13 @@ export default class FilterFactory { wrapperStyles, (f, s) => React.cloneElement(f, { style: s })); - const offsets = R.range(0, fillerColumns).map(i => ()); + const operations = this.headerOperations( + 1, + row_selectable, + row_deletable + )[0]; - return [offsets.concat(styledFilters)]; + return [operations.concat(styledFilters)]; } else { return [[]]; } diff --git a/src/dash-table/components/Table/index.tsx b/src/dash-table/components/Table/index.tsx index db9eadacb..1ce9b95a2 100644 --- a/src/dash-table/components/Table/index.tsx +++ b/src/dash-table/components/Table/index.tsx @@ -12,6 +12,8 @@ import derivedVirtualData from 'dash-table/derived/data/virtual'; import derivedVirtualizedData from 'dash-table/derived/data/virtualized'; import derivedVisibleColumns from 'dash-table/derived/column/visible'; +import QuerySyntaxTree from 'dash-table/syntax-tree/QuerySyntaxTree'; + import { ControlledTableProps, PropsWithDefaultsAndDerived, @@ -37,6 +39,7 @@ export default class Table extends Component = {}; + if (!derivedStructureCache.cached) { + newProps.derived_filter_structure = derivedStructureCache.result; + } + if (!virtualCached) { newProps.derived_virtual_data = virtual.data; newProps.derived_virtual_indices = virtual.indices; @@ -260,4 +269,7 @@ export default class Table extends Component viewport); private readonly virtualCache = memoizeOneWithFlag(virtual => virtual); private readonly virtualSelectedRowsCache = memoizeOneWithFlag(virtual => virtual); + private readonly structuredQueryCache = memoizeOneWithFlag( + (query: string) => new QuerySyntaxTree(query).toStructure() + ); } \ No newline at end of file diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index d8b42501a..1a8f1e64d 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -232,6 +232,7 @@ export interface IUSerInterfaceTooltip { export interface IState { forcedResizeOnly: boolean; + rawFilterQuery: string; scrollbarWidth: number; tooltip?: IUSerInterfaceTooltip; uiViewport?: IUserInterfaceViewport; @@ -265,8 +266,8 @@ interface IProps { data?: Data; dropdown_properties: any; // legacy editable?: boolean; + filter?: string; filtering?: Filtering; - filtering_settings?: string; filtering_type?: FilteringType; filtering_types?: FilteringType[]; locale_format: INumberLocale; @@ -308,8 +309,8 @@ interface IDefaultProps { css: IStylesheetRule[]; data: Data; editable: boolean; + filter: string; filtering: Filtering; - filtering_settings: string; filtering_type: FilteringType; filtering_types: FilteringType[]; merge_duplicate_headers: boolean; @@ -344,6 +345,7 @@ interface IDefaultProps { } interface IDerivedProps { + derived_filter_structure: object | null; derived_viewport_data: Data; derived_viewport_indices: Indices; derived_viewport_selected_rows: Indices; diff --git a/src/dash-table/conditional/index.ts b/src/dash-table/conditional/index.ts index b8173235c..f575435e4 100644 --- a/src/dash-table/conditional/index.ts +++ b/src/dash-table/conditional/index.ts @@ -1,5 +1,5 @@ import { ColumnId, Datum, ColumnType } from 'dash-table/components/Table/props'; -import SyntaxTree from 'core/syntax-tree'; +import { QuerySyntaxTree } from 'dash-table/syntax-tree'; export interface IConditionalElement { filter?: string; @@ -26,7 +26,7 @@ export type ConditionalDataCell = IConditionalElement & IIndexedRowElement & INa export type ConditionalCell = INamedElement & ITypedElement; export type ConditionalHeader = IIndexedHeaderElement & INamedElement & ITypedElement; -function ifAstFilter(ast: SyntaxTree, datum: Datum) { +function ifAstFilter(ast: QuerySyntaxTree, datum: Datum) { return ast.isValid && ast.evaluate(datum); } @@ -69,5 +69,5 @@ export function ifHeaderIndex(condition: IIndexedHeaderElement | undefined, head export function ifFilter(condition: IConditionalElement | undefined, datum: Datum) { return !condition || condition.filter === undefined || - ifAstFilter(new SyntaxTree(condition.filter), datum); + ifAstFilter(new QuerySyntaxTree(condition.filter), datum); } \ No newline at end of file diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 5be2d8c53..b0ab6b87b 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -40,8 +40,8 @@ export const defaultProps = { content_style: 'grow', css: [], + filter: '', filtering: false, - filtering_settings: '', filtering_type: 'basic', filtering_types: ['basic'], sorting: false, @@ -771,6 +771,16 @@ export const propTypes = { */ tooltip_duration: PropTypes.number, + /** + * If `filtering` is enabled, then the current filtering + * string is represented in this `filter` + * property. + * NOTE: The shape and structure of this property will + * likely change in the future. + * Stay tuned in [https://github.com/plotly/dash-table/issues/169](https://github.com/plotly/dash-table/issues/169) + */ + filter: PropTypes.string, + /** * The `filtering` property controls the behavior of the `filtering` UI. * If `False`, then the filtering UI is not displayed @@ -779,7 +789,7 @@ export const propTypes = { * that exists in the `data` property. * If `be`, then the filtering UI is displayed but it is the * responsibility of the developer to program the filtering - * through a callback (where `filtering_settings` would be the input + * through a callback (where `filter` would be the input * and `data` would be the output). * * NOTE - Several aspects of filtering may change in the future, @@ -788,16 +798,6 @@ export const propTypes = { */ filtering: PropTypes.oneOf(['fe', 'be', true, false]), - /** - * If `filtering` is enabled, then the current filtering - * string is represented in this `filtering_settings` - * property. - * NOTE: The shape and structure of this property will - * likely change in the future. - * Stay tuned in [https://github.com/plotly/dash-table/issues/169](https://github.com/plotly/dash-table/issues/169) - */ - filtering_settings: PropTypes.string, - /** * UNSTABLE * In the future, there may be several modes of the @@ -982,6 +982,38 @@ export const propTypes = { */ virtualization: PropTypes.bool, + /** + * This property represents the current structure of + * `filter` as a tree structure. Each node of the + * query structure have: + * - type (string; required) + * - 'open-block' + * - 'logical-operator' + * - 'relational-operator' + * - 'unary-operator' + * - 'expression' + * - 'operand' + * - subType (string; optional) + * - 'open-block': '()' + * - 'logical-operator': '&&', '||' + * - 'relational-operator': '=', '>=', '>', '<=', '<', '!=', 'contains' + * - 'unary-operator': '!', 'is bool', 'is even', 'is nil', 'is num', 'is object', 'is odd', 'is prime', 'is str' + * - 'expression': 'value', 'field' + * - 'operand': 'field' + * - value (any) + * - 'expression, value': passed value + * - 'expression, field': the field/prop name + * - 'operand, field': the field/prop name + * + * - block (nested query structure; optional) + * - left (nested query structure; optional) + * - right (nested query structure; optional) + * + * If the query is invalid or empty, the `derived_filter_structure` will + * be null. + */ + derived_filter_structure: PropTypes.object, + /** * This property represents the current state of `data` * on the current page. This property will be updated diff --git a/src/dash-table/derived/cell/dropdowns.ts b/src/dash-table/derived/cell/dropdowns.ts index 06884658e..2f8e81ef1 100644 --- a/src/dash-table/derived/cell/dropdowns.ts +++ b/src/dash-table/derived/cell/dropdowns.ts @@ -2,7 +2,6 @@ import * as R from 'ramda'; import { memoizeOne } from 'core/memoizer'; import memoizerCache from 'core/cache/memoizer'; -import SyntaxTree from 'core/syntax-tree'; import { IConditionalDropdown @@ -18,6 +17,7 @@ import { IBaseVisibleColumn, IVisibleColumn } from 'dash-table/components/Table/props'; +import { QuerySyntaxTree } from 'dash-table/syntax-tree'; const mapData = R.addIndex(R.map); @@ -116,13 +116,13 @@ class Dropdowns { */ private readonly ast = memoizerCache<[ColumnId, number]>()(( query: string - ) => new SyntaxTree(query)); + ) => new QuerySyntaxTree(query)); /** * Evaluate if the query matches the cell's data. */ private readonly evaluation = memoizerCache<[ColumnId, number]>()(( - ast: SyntaxTree, + ast: QuerySyntaxTree, datum: Datum ) => ast.evaluate(datum)); } \ No newline at end of file diff --git a/src/dash-table/derived/data/virtual.ts b/src/dash-table/derived/data/virtual.ts index cebccb1b4..f46b597f1 100644 --- a/src/dash-table/derived/data/virtual.ts +++ b/src/dash-table/derived/data/virtual.ts @@ -2,7 +2,6 @@ import * as R from 'ramda'; import { memoizeOneFactory } from 'core/memoizer'; import sort, { defaultIsNully, SortSettings } from 'core/sorting'; -import SyntaxTree from 'core/syntax-tree'; import { Data, Datum, @@ -10,11 +9,12 @@ import { IDerivedData, Sorting } from 'dash-table/components/Table/props'; +import { QuerySyntaxTree } from 'dash-table/syntax-tree'; const getter = ( data: Data, filtering: Filtering, - filtering_settings: string, + filter: string, sorting: Sorting, sorting_settings: SortSettings = [], sorting_treat_empty_string_as_none: boolean @@ -25,7 +25,7 @@ const getter = ( }, data); if (filtering === 'fe' || filtering === true) { - const tree = new SyntaxTree(filtering_settings); + const tree = new QuerySyntaxTree(filter); data = tree.isValid ? tree.filter(data) : diff --git a/src/dash-table/derived/style/index.ts b/src/dash-table/derived/style/index.ts index d73ad6008..641671bea 100644 --- a/src/dash-table/derived/style/index.ts +++ b/src/dash-table/derived/style/index.ts @@ -1,7 +1,6 @@ import * as R from 'ramda'; import { CSSProperties } from 'react'; -import SyntaxTree from 'core/syntax-tree'; import { memoizeOneFactory } from 'core/memoizer'; import { Datum, IVisibleColumn } from 'dash-table/components/Table/props'; @@ -25,6 +24,7 @@ import { ifColumnId, ifColumnType } from 'dash-table/conditional'; +import { QuerySyntaxTree } from 'dash-table/syntax-tree'; export interface IConvertedStyle { style: CSSProperties; @@ -38,7 +38,7 @@ type GenericStyle = Style & Partial<{ if: GenericIf }>; function convertElement(style: GenericStyle) { const indexFilter = style.if && (style.if.header_index || style.if.row_index); - let ast: SyntaxTree; + let ast: QuerySyntaxTree; return { matchesColumn: (column: IVisibleColumn) => @@ -55,7 +55,7 @@ function convertElement(style: GenericStyle) { matchesFilter: (datum: Datum) => !style.if || style.if.filter === undefined || - (ast = ast || new SyntaxTree(style.if.filter)).evaluate(datum), + (ast = ast || new QuerySyntaxTree(style.if.filter)).evaluate(datum), style: convertStyle(style) }; } diff --git a/src/dash-table/derived/table/index.tsx b/src/dash-table/derived/table/index.tsx index 3e00f8045..2aa87abf2 100644 --- a/src/dash-table/derived/table/index.tsx +++ b/src/dash-table/derived/table/index.tsx @@ -1,43 +1,21 @@ +import * as R from 'ramda'; + +import { memoizeOne } from 'core/memoizer'; + import CellFactory from 'dash-table/components/CellFactory'; import FilterFactory from 'dash-table/components/FilterFactory'; import HeaderFactory from 'dash-table/components/HeaderFactory'; -import { ControlledTableProps, SetProps } from 'dash-table/components/Table/props'; - -const handleSetFilter = (setProps: SetProps, filtering_settings: string) => setProps({ filtering_settings }); +import { ControlledTableProps, SetProps, SetState } from 'dash-table/components/Table/props'; -function filterPropsFn(propsFn: () => ControlledTableProps) { - const { - columns, - filtering, - filtering_settings, - filtering_type, - id, - row_deletable, - row_selectable, - setProps, - style_cell, - style_cell_conditional, - style_filter, - style_filter_conditional - } = propsFn(); +const handleSetFilter = (setProps: SetProps, setState: SetState, filter: string, rawFilterQuery: string) => { + setProps({ filter }); + setState({ rawFilterQuery }); +}; - const fillerColumns = - (row_deletable ? 1 : 0) + - (row_selectable ? 1 : 0); +function filterPropsFn(propsFn: () => ControlledTableProps, setFilter: any) { + const props = propsFn(); - return { - columns, - fillerColumns, - filtering, - filtering_settings, - filtering_type, - id, - setFilter: handleSetFilter.bind(undefined, setProps), - style_cell, - style_cell_conditional, - style_filter, - style_filter_conditional - }; + return R.merge(props, { setFilter }); } function getter( @@ -59,8 +37,17 @@ function getter( } export default (propsFn: () => ControlledTableProps) => { + const setFilter = memoizeOne(( + setProps: SetProps, + setState: SetState + ) => handleSetFilter.bind(undefined, setProps, setState)); + const cellFactory = new CellFactory(propsFn); - const filterFactory = new FilterFactory(() => filterPropsFn(propsFn)); + const filterFactory = new FilterFactory(() => { + const props = propsFn(); + + return filterPropsFn(propsFn, setFilter(props.setProps, props.setState)); + }); const headerFactory = new HeaderFactory(propsFn); return getter.bind(undefined, cellFactory, filterFactory, headerFactory); diff --git a/src/dash-table/syntax-tree/MultiColumnsSyntaxTree.ts b/src/dash-table/syntax-tree/MultiColumnsSyntaxTree.ts new file mode 100644 index 000000000..e492e1576 --- /dev/null +++ b/src/dash-table/syntax-tree/MultiColumnsSyntaxTree.ts @@ -0,0 +1,46 @@ +import * as R from 'ramda'; + +import SyntaxTree from 'core/syntax-tree'; +import { LexemeType } from 'core/syntax-tree/lexicon'; +import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; + +import columnMultiLexicon from './lexicon/columnMulti'; + +export default class MultiColumnsSyntaxTree extends SyntaxTree { + constructor(query: string) { + super(columnMultiLexicon, query); + } + get isValid() { + return super.isValid && + this.respectsBasicSyntax(); + } + get statements() { + if (!this.syntaxerResult.tree) { + return; + } + const statements: ISyntaxTree[] = []; + const toCheck: ISyntaxTree[] = [this.syntaxerResult.tree]; + while (toCheck.length) { + const item = toCheck.pop(); + if (!item) { + continue; + } + statements.push(item); + if (item.left) { + toCheck.push(item.left); + } + if (item.block) { + toCheck.push(item.block); + } + if (item.right) { + toCheck.push(item.right); + } + } + return statements; + } + private respectsBasicSyntax() { + const fields = R.map(item => item.value, R.filter(i => i.lexeme.type === LexemeType.Operand, this.lexerResult.lexemes)); + const uniqueFields = R.uniq(fields); + return fields.length === uniqueFields.length; + } +} \ No newline at end of file diff --git a/src/dash-table/syntax-tree/QuerySyntaxTree.ts b/src/dash-table/syntax-tree/QuerySyntaxTree.ts new file mode 100644 index 000000000..a309b9d2d --- /dev/null +++ b/src/dash-table/syntax-tree/QuerySyntaxTree.ts @@ -0,0 +1,9 @@ +import SyntaxTree from 'core/syntax-tree'; + +import queryLexicon from './lexicon/query'; + +export default class QuerySyntaxTree extends SyntaxTree { + constructor(query: string) { + super(queryLexicon, query); + } +} \ No newline at end of file diff --git a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts new file mode 100644 index 000000000..a2d2b7076 --- /dev/null +++ b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts @@ -0,0 +1,51 @@ +import SyntaxTree from 'core/syntax-tree'; +import { ILexemeResult, ILexerResult } from 'core/syntax-tree/lexer'; +import { LexemeType, boundLexeme } from 'core/syntax-tree/lexicon'; + +import { ColumnId } from 'dash-table/components/Table/props'; + +import operand from './lexeme/operand'; +import { equal } from './lexeme/relational'; + +import columnLexicon from './lexicon/column'; + +function isBinary(lexemes: ILexemeResult[]) { + return lexemes.length === 2; +} + +function isExpression(lexemes: ILexemeResult[]) { + return lexemes.length === 1 && + lexemes[0].lexeme.type === LexemeType.Expression; +} + +function isUnary(lexemes: ILexemeResult[]) { + return lexemes.length === 1 && + lexemes[0].lexeme.type === LexemeType.UnaryOperator; +} + +export function modifyLex(key: ColumnId, res: ILexerResult) { + if (!res.valid) { + return res; + } + + if (isBinary(res.lexemes) || isUnary(res.lexemes)) { + res.lexemes = [ + { lexeme: boundLexeme(operand), value: `{${key}}` }, + ...res.lexemes + ]; + } else if (isExpression(res.lexemes)) { + res.lexemes = [ + { lexeme: boundLexeme(operand), value: `{${key}}` }, + { lexeme: boundLexeme(equal), value: 'eq' }, + ...res.lexemes + ]; + } + + return res; +} + +export default class SingleColumnSyntaxTree extends SyntaxTree { + constructor(key: ColumnId, query: string) { + super(columnLexicon, query, modifyLex.bind(undefined, key)); + } +} \ No newline at end of file diff --git a/src/dash-table/syntax-tree/index.ts b/src/dash-table/syntax-tree/index.ts new file mode 100644 index 000000000..a651fbef0 --- /dev/null +++ b/src/dash-table/syntax-tree/index.ts @@ -0,0 +1,47 @@ +import * as R from 'ramda'; + +import { LexemeType } from 'core/syntax-tree/lexicon'; + +import MultiColumnsSyntaxTree from './MultiColumnsSyntaxTree'; +import QuerySyntaxTree from './QuerySyntaxTree'; +import SingleColumnSyntaxTree from './SingleColumnSyntaxTree'; +import { RelationalOperator } from './lexeme/relational'; + +export const getMultiColumnQueryString = ( + asts: SingleColumnSyntaxTree[] +) => R.map( + ast => ast.toQueryString(), + R.filter(ast => ast && ast.isValid, asts) + ).join(' && '); + +export const getSingleColumnMap = (ast: MultiColumnsSyntaxTree) => { + if (!ast.isValid) { + return; + } + + const map = new Map(); + + const statements = ast.statements; + if (!statements) { + return map; + } + + R.forEach(s => { + if (s.lexeme.type === LexemeType.UnaryOperator && s.left) { + const sanitizedColumnId = s.left.lexeme.present ? s.left.lexeme.present(s.left) : s.left.value; + map.set(sanitizedColumnId, new SingleColumnSyntaxTree(sanitizedColumnId, s.value)); + } else if (s.lexeme.type === LexemeType.RelationalOperator && s.left && s.right) { + const sanitizedColumnId = s.left.lexeme.present ? s.left.lexeme.present(s.left) : s.left.value; + + if (s.lexeme.present && s.lexeme.present(s) === RelationalOperator.Equal) { + map.set(sanitizedColumnId, new SingleColumnSyntaxTree(sanitizedColumnId, `${s.right.value}`)); + } else { + map.set(sanitizedColumnId, new SingleColumnSyntaxTree(sanitizedColumnId, `${s.value} ${s.right.value}`)); + } + } + }, statements); + + return map; +}; + +export { MultiColumnsSyntaxTree, QuerySyntaxTree, SingleColumnSyntaxTree }; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexeme/block.ts b/src/dash-table/syntax-tree/lexeme/block.ts new file mode 100644 index 000000000..3e804ba87 --- /dev/null +++ b/src/dash-table/syntax-tree/lexeme/block.ts @@ -0,0 +1,28 @@ +import Logger from 'core/Logger'; +import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; + +export const blockClose: IUnboundedLexeme = { + nesting: -1, + regexp: /^\)/, + type: LexemeType.BlockClose +}; + +export const blockOpen: IUnboundedLexeme = { + evaluate: (target, tree) => { + Logger.trace('evaluate -> ()', target, tree); + + const t = tree as any; + + return t.block.lexeme.evaluate(target, t.block); + }, + type: LexemeType.BlockOpen, + nesting: 1, + subType: '()', + priority: 1, + regexp: /^\(/, + syntaxer: (lexs: any[]) => { + return Object.assign({ + block: lexs.slice(1, lexs.length - 1) + }, lexs[0]); + } +}; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexeme/expression.ts b/src/dash-table/syntax-tree/lexeme/expression.ts new file mode 100644 index 000000000..8dad633ee --- /dev/null +++ b/src/dash-table/syntax-tree/lexeme/expression.ts @@ -0,0 +1,59 @@ +import isNumeric from 'fast-isnumeric'; +import * as R from 'ramda'; + +import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; +import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; +import operand from './operand'; + +const STRING_REGEX = /^(('([^'\\]|\\.)+')|("([^"\\]|\\.)+")|(`([^`\\]|\\.)+`))/; +const VALUE_REGEX = /^(([^\s'"`{}()\\]|\\.)+)(?:[\s)]|$)/; + +export const fieldExpression: IUnboundedLexeme = R.merge( + operand, { + subType: 'field', + type: LexemeType.Expression + } +); + +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) => { + if (STRING_REGEX.test(tree.value)) { + return getString(tree.value); + } else { + throw new Error(); + } + }, + regexp: STRING_REGEX, + subType: 'value', + 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 diff --git a/src/dash-table/syntax-tree/lexeme/logical.ts b/src/dash-table/syntax-tree/lexeme/logical.ts new file mode 100644 index 000000000..3fb4a0df3 --- /dev/null +++ b/src/dash-table/syntax-tree/lexeme/logical.ts @@ -0,0 +1,49 @@ +import Logger from 'core/Logger'; +import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; + +enum LogicalOperator { + And = '&&', + Or = '||' +} + +export const and: IUnboundedLexeme = { + evaluate: (target, tree) => { + Logger.trace('evaluate -> &&', target, tree); + + const t = tree as any; + const lv = t.left.lexeme.evaluate(target, t.left); + const rv = t.right.lexeme.evaluate(target, t.right); + return lv && rv; + }, + type: LexemeType.LogicalOperator, + priority: 2, + regexp: /^(and\s|&&)/i, + subType: LogicalOperator.And, + syntaxer: (lexs: any[], pivot: any, pivotIndex: number) => { + return Object.assign({ + left: lexs.slice(0, pivotIndex), + right: lexs.slice(pivotIndex + 1) + }, pivot); + } +}; + +export const or: IUnboundedLexeme = { + evaluate: (target, tree) => { + Logger.trace('evaluate -> ||', target, tree); + + const t = tree as any; + + return t.left.lexeme.evaluate(target, t.left) || + t.right.lexeme.evaluate(target, t.right); + }, + type: LexemeType.LogicalOperator, + subType: LogicalOperator.Or, + priority: 3, + regexp: /^(or\s|\|\|)/i, + syntaxer: (lexs: any[], pivot: any, pivotIndex: number) => { + return Object.assign({ + left: lexs.slice(0, pivotIndex), + right: lexs.slice(pivotIndex + 1) + }, pivot); + } +}; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexeme/operand.ts b/src/dash-table/syntax-tree/lexeme/operand.ts new file mode 100644 index 000000000..5fe72feea --- /dev/null +++ b/src/dash-table/syntax-tree/lexeme/operand.ts @@ -0,0 +1,26 @@ +import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; +import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; + +const FIELD_REGEX = /^{(([^{}\\]|\\.)+)}/; + +const getField = ( + value: string +) => value + .slice(1, value.length - 1) + .replace(/\\(.)/g, '$1'); + +const operand: IUnboundedLexeme = { + present: (tree: ISyntaxTree) => getField(tree.value), + resolve: (target: any, tree: ISyntaxTree) => { + if (FIELD_REGEX.test(tree.value)) { + return target[getField(tree.value)]; + } else { + throw new Error(); + } + }, + regexp: FIELD_REGEX, + subType: 'field', + type: LexemeType.Operand +}; + +export default operand; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexeme/relational.ts b/src/dash-table/syntax-tree/lexeme/relational.ts new file mode 100644 index 000000000..df6fb9a78 --- /dev/null +++ b/src/dash-table/syntax-tree/lexeme/relational.ts @@ -0,0 +1,92 @@ +import * as R from 'ramda'; + +import Logger from 'core/Logger'; +import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; +import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; + +function evaluator( + target: any, + tree: ISyntaxTree +): [any, any] { + Logger.trace('evaluate -> relational', target, tree); + + const t = tree as any; + + const opValue = t.left.lexeme.resolve(target, t.left); + const expValue = t.right.lexeme.resolve(target, t.right); + Logger.trace(`opValue: ${opValue}, expValue: ${expValue}`); + + return [opValue, expValue]; +} + +function relationalSyntaxer([left, lexeme, right]: any[]) { + return Object.assign({ left, right }, lexeme); +} + +function relationalEvaluator( + fn: ([opValue, expValue]: any[]) => boolean +) { + return (target: any, tree: ISyntaxTree) => fn(evaluator(target, tree)); +} + +export enum RelationalOperator { + Contains = 'contains', + Equal = '=', + GreaterOrEqual = '>=', + GreaterThan = '>', + LessOrEqual = '<=', + LessThan = '<', + NotEqual = '!=' +} + +const LEXEME_BASE = { + priority: 0, + syntaxer: relationalSyntaxer, + type: LexemeType.RelationalOperator +}; + +export const contains: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => + typeof op === 'string' && + typeof exp === 'string' && + op.indexOf(exp) !== -1 + ), + subType: RelationalOperator.Contains, + regexp: /^(contains)/i +}, LEXEME_BASE); + +export const equal: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => op === exp), + subType: RelationalOperator.Equal, + regexp: /^(=|eq)/i +}, LEXEME_BASE); + +export const greaterOrEqual: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => op >= exp), + subType: RelationalOperator.GreaterOrEqual, + regexp: /^(>=|ge)/i +}, LEXEME_BASE); + +export const greaterThan: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => op > exp), + subType: RelationalOperator.GreaterThan, + regexp: /^(>|gt)/i +}, LEXEME_BASE); + +export const lessOrEqual: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => op <= exp), + subType: RelationalOperator.LessOrEqual, + regexp: /^(<=|le)/i +}, LEXEME_BASE); + +export const lessThan: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => op < exp), + subType: RelationalOperator.LessThan, + regexp: /^(<|lt)/i +}, LEXEME_BASE); + +export const notEqual: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => op !== exp), + subType: RelationalOperator.NotEqual, + regexp: /^(!=|ne)/i +}, LEXEME_BASE); \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexeme/unary.ts b/src/dash-table/syntax-tree/lexeme/unary.ts new file mode 100644 index 000000000..aab1d5f52 --- /dev/null +++ b/src/dash-table/syntax-tree/lexeme/unary.ts @@ -0,0 +1,106 @@ +import * as R from 'ramda'; + +import Logger from 'core/Logger'; +import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; +import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; + +const checkPrimality = (c: number) => { + if (c === 2) { return true; } + if (c < 2 || c % 2 === 0) { return false; } + for (let n = 3; n * n <= c; n += 2) { if (c % n === 0) { return false; } } + return true; +}; + +function evaluator( + target: any, + tree: ISyntaxTree +): any { + Logger.trace('evaluate -> unary', target, tree); + + Logger.trace('evaluate -> unary', target, tree); + + const t = tree as any; + const opValue = t.left.lexeme.resolve(target, t.left); + + return opValue; +} + +function relationalSyntaxer([left, lexeme]: any[]) { + return Object.assign({ left }, lexeme); +} + +function relationalEvaluator( + fn: (opValue: any[]) => boolean +) { + return (target: any, tree: ISyntaxTree) => fn(evaluator(target, tree)); +} + +enum UnaryOperator { + Not = '!' +} + +const LEXEME_BASE = { + present: (tree: ISyntaxTree) => tree.value, + priority: 0, + syntaxer: relationalSyntaxer, + type: LexemeType.UnaryOperator +}; + +export const not: IUnboundedLexeme = { + evaluate: (target, tree) => { + Logger.trace('evaluate -> unary not', target, tree); + + const t = tree as any; + + return !t.right.lexeme.evaluate(target, t.right); + }, + type: LexemeType.UnaryOperator, + subType: UnaryOperator.Not, + priority: 1.5, + regexp: /^!/, + syntaxer: (lexs: any[]) => { + return Object.assign({ + right: lexs.slice(1, lexs.length) + }, lexs[0]); + } +}; + +export const isBool: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => typeof opValue === 'boolean'), + regexp: /^(is bool)/i +}, LEXEME_BASE); + +export const isEven: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => typeof opValue === 'number' && opValue % 2 === 0), + regexp: /^(is even)/i +}, LEXEME_BASE); + +export const isNil: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => opValue === undefined || opValue === null), + regexp: /^(is nil)/i +}, LEXEME_BASE); + +export const isNum: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => typeof opValue === 'number'), + regexp: /^(is num)/i +}, LEXEME_BASE); + +export const isObject: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => opValue !== null && typeof opValue === 'object'), + regexp: /^(is object)/i +}, LEXEME_BASE); + +export const isOdd: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => typeof opValue === 'number' && opValue % 2 === 1), + regexp: /^(is odd)/i +}, LEXEME_BASE); + +export const isPrime: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => typeof opValue === 'number' && checkPrimality(opValue)), + regexp: /^(is prime)/i +}, LEXEME_BASE); + +export const isStr: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(opValue => typeof opValue === 'string'), + regexp: /^(is str)/i +}, LEXEME_BASE); \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexicon/column.ts b/src/dash-table/syntax-tree/lexicon/column.ts new file mode 100644 index 000000000..b6e42ec6d --- /dev/null +++ b/src/dash-table/syntax-tree/lexicon/column.ts @@ -0,0 +1,72 @@ +import * as R from 'ramda'; + +import { + fieldExpression, + stringExpression, + valueExpression +} from '../lexeme/expression'; +import { + contains, + equal, + greaterOrEqual, + greaterThan, + lessOrEqual, + lessThan, + notEqual +} from '../lexeme/relational'; +import { + isBool, + isEven, + isNil, + isNum, + isObject, + isOdd, + isPrime, + isStr +} from '../lexeme/unary'; + +import { LexemeType, ILexeme } from 'core/syntax-tree/lexicon'; +import { ILexemeResult } from 'core/syntax-tree/lexer'; + +const lexicon: ILexeme[] = [ + ...[contains, + equal, + greaterOrEqual, + greaterThan, + lessOrEqual, + lessThan, + notEqual + ].map(op => ({ + ...op, + terminal: false, + if: (_lexs: ILexemeResult[], previous: ILexemeResult) => !previous + })), + ...[isBool, + isEven, + isNil, + isNum, + isObject, + isOdd, + isPrime, + isStr + ].map(op => ({ + ...op, + if: (_lexs: ILexemeResult[], previous: ILexemeResult) => !previous, + terminal: true + })), + ...[ + fieldExpression, + stringExpression, + valueExpression + ].map(exp => ({ + ...exp, + if: (_lexs: ILexemeResult[], previous: ILexemeResult) => + !previous || R.contains( + previous.lexeme.type, + [LexemeType.RelationalOperator] + ), + terminal: true + })) +]; + +export default lexicon; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexicon/columnMulti.ts b/src/dash-table/syntax-tree/lexicon/columnMulti.ts new file mode 100644 index 000000000..a8542b5bc --- /dev/null +++ b/src/dash-table/syntax-tree/lexicon/columnMulti.ts @@ -0,0 +1,105 @@ +import * as R from 'ramda'; + +import { + fieldExpression, + stringExpression, + valueExpression +} from '../lexeme/expression'; +import { + and +} from '../lexeme/logical'; +import operand from '../lexeme/operand'; +import { + contains, + equal, + greaterOrEqual, + greaterThan, + lessOrEqual, + lessThan, + notEqual +} from '../lexeme/relational'; +import { + isBool, + isEven, + isNil, + isNum, + isObject, + isOdd, + isPrime, + isStr +} from '../lexeme/unary'; + +import { ILexeme, LexemeType } from 'core/syntax-tree/lexicon'; +import { ILexemeResult } from 'core/syntax-tree/lexer'; + +const lexicon: ILexeme[] = [ + { + ...and, + if: (_: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [ + LexemeType.Expression, + LexemeType.UnaryOperator + ] + ), + terminal: false + }, + { + ...operand, + if: (_: ILexemeResult[], previous: ILexemeResult) => + !previous || R.contains( + previous.lexeme.type, + [LexemeType.LogicalOperator] + ), + terminal: false + }, + ...[contains, + equal, + greaterOrEqual, + greaterThan, + lessOrEqual, + lessThan, + notEqual + ].map(op => ({ + ...op, + if: (_: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [LexemeType.Operand] + ), + terminal: false + })), + ...[isBool, + isEven, + isNil, + isNum, + isObject, + isOdd, + isPrime, + isStr + ].map(op => ({ + ...op, + if: (_: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [LexemeType.Operand] + ), + terminal: true + })), + ...[ + fieldExpression, + stringExpression, + valueExpression + ].map(exp => ({ + ...exp, + if: (_: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [LexemeType.RelationalOperator] + ), + terminal: true + })) +]; + +export default lexicon; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexicon/query.ts b/src/dash-table/syntax-tree/lexicon/query.ts new file mode 100644 index 000000000..8b23c1b63 --- /dev/null +++ b/src/dash-table/syntax-tree/lexicon/query.ts @@ -0,0 +1,168 @@ +import * as R from 'ramda'; + +import { + blockClose, + blockOpen +} from '../lexeme/block'; +import { + fieldExpression, + stringExpression, + valueExpression +} from '../lexeme/expression'; +import { + and, + or +} from '../lexeme/logical'; +import operand from '../lexeme/operand'; +import { + contains, + equal, + greaterOrEqual, + greaterThan, + lessOrEqual, + lessThan, + notEqual +} from '../lexeme/relational'; +import { + isBool, + isEven, + isNil, + isNum, + isObject, + isOdd, + isPrime, + isStr, + not +} from '../lexeme/unary'; + +import { ILexemeResult } from 'core/syntax-tree/lexer'; +import { LexemeType, ILexeme } from 'core/syntax-tree/lexicon'; + +const nestingReducer = R.reduce( + (nesting, l) => nesting + (l.lexeme.nesting || 0) +); + +const isTerminal = (lexemes: ILexemeResult[], previous: ILexemeResult) => + previous && nestingReducer(0, lexemes) === 0; + +const ifExpression = (_: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [LexemeType.RelationalOperator] + ); + +const ifLogicalOperator = (_: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [ + LexemeType.BlockClose, + LexemeType.Expression, + LexemeType.UnaryOperator + ] + ); + +const ifOperator = (_: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [LexemeType.Operand] + ); + +const lexicon: ILexeme[] = [ + { + ...and, + if: ifLogicalOperator, + terminal: false + }, + { + ...or, + if: ifLogicalOperator, + terminal: false + }, + { + ...blockClose, + if: (lexemes: ILexemeResult[], previous: ILexemeResult) => + previous && R.contains( + previous.lexeme.type, + [ + LexemeType.BlockClose, + LexemeType.BlockOpen, + LexemeType.Expression, + LexemeType.UnaryOperator + ] + ) && nestingReducer(0, lexemes) > 0, + terminal: isTerminal + }, + { + ...blockOpen, + if: (_: ILexemeResult[], previous: ILexemeResult) => + !previous || R.contains( + previous.lexeme.type, + [ + LexemeType.BlockOpen, + LexemeType.LogicalOperator, + LexemeType.UnaryOperator + ] + ), + terminal: false + }, + { + ...operand, + if: (_: ILexemeResult[], previous: ILexemeResult) => + !previous || R.contains( + previous.lexeme.type, + [ + LexemeType.BlockOpen, + LexemeType.LogicalOperator + ] + ), + terminal: false + }, + ...[contains, + equal, + greaterOrEqual, + greaterThan, + lessOrEqual, + lessThan, + notEqual + ].map(op => ({ + ...op, + if: ifOperator, + terminal: false + })), + ...[isBool, + isEven, + isNil, + isNum, + isObject, + isOdd, + isPrime, + isStr + ].map(op => ({ + ...op, + if: ifOperator, + terminal: isTerminal + })), + { + ...not, + if: (_: ILexemeResult[], previous: ILexemeResult) => + !previous || R.contains( + previous.lexeme.type, + [ + LexemeType.LogicalOperator, + LexemeType.UnaryOperator + ] + ), + terminal: false + }, + ...[ + fieldExpression, + stringExpression, + valueExpression + ].map(exp => ({ + ...exp, + if: ifExpression, + terminal: isTerminal + })) +]; + +export default lexicon; \ No newline at end of file diff --git a/tests/cypress/tests/standalone/filtering_test.ts b/tests/cypress/tests/standalone/filtering_test.ts index 7c8094004..fe2b2c856 100644 --- a/tests/cypress/tests/standalone/filtering_test.ts +++ b/tests/cypress/tests/standalone/filtering_test.ts @@ -4,77 +4,135 @@ import Key from 'cypress/Key'; import { AppMode } from 'demo/AppMode'; -describe(`filter`, () => { - describe(`special characters`, () => { - beforeEach(() => { - cy.visit(`http://localhost:8080?mode=${AppMode.ColumnsInSpace}`); - DashTable.toggleScroll(false); - }); +describe(`filter special characters`, () => { + beforeEach(() => { + cy.visit(`http://localhost:8080?mode=${AppMode.ColumnsInSpace}`); + DashTable.toggleScroll(false); + }); + + it('can filter on special column id', () => { + DashTable.getFilterById('b+bb').click(); + DOM.focused.type(`Wet${Key.Enter}`); - it('can filter on special column id', () => { - DashTable.getFilterById('c cc').click(); - DOM.focused.type(`gt num(90)${Key.Enter}`); + DashTable.getFilterById('c cc').click(); + DOM.focused.type(`gt 90${Key.Enter}`); - DashTable.getFilterById('d:dd').click(); - DOM.focused.type(`lt num(12500)${Key.Enter}`); + DashTable.getFilterById('d:dd').click(); + DOM.focused.type(`lt 12500${Key.Enter}`); - DashTable.getFilterById('e-ee').click(); - DOM.focused.type(`is prime${Key.Enter}`); + DashTable.getFilterById('e-ee').click(); + DOM.focused.type(`is prime${Key.Enter}`); - DashTable.getFilterById('f_ff').click(); - DOM.focused.type(`le num(106)${Key.Enter}`); + DashTable.getFilterById('f_ff').click(); + DOM.focused.type(`le 106${Key.Enter}`); - DashTable.getFilterById('g.gg').click(); - DOM.focused.type(`gt num(1000)${Key.Enter}`); + DashTable.getFilterById('g.gg').click(); + DOM.focused.type(`gt 1000${Key.Enter}`); + DashTable.getFilterById('b+bb').click(); - DashTable.getFilterById('b+bb').click(); - DOM.focused.type(`eq "Wet"${Key.Enter}`); + DashTable.getCellById(0, 'rows').within(() => cy.get('.dash-cell-value').should('have.html', '101')); + DashTable.getCellById(1, 'rows').should('not.exist'); + DashTable.getFilterById('b+bb').within(() => cy.get('input').should('have.value', 'Wet')); + DashTable.getFilterById('c cc').within(() => cy.get('input').should('have.value', 'gt 90')); + DashTable.getFilterById('d:dd').within(() => cy.get('input').should('have.value', 'lt 12500')); + DashTable.getFilterById('e-ee').within(() => cy.get('input').should('have.value', 'is prime')); + DashTable.getFilterById('f_ff').within(() => cy.get('input').should('have.value', 'le 106')); + DashTable.getFilterById('g.gg').within(() => cy.get('input').should('have.value', 'gt 1000')); + }); +}); + +describe('filter', () => { + beforeEach(() => { + cy.visit(`http://localhost:8080?mode=${AppMode.Filtering}`); + DashTable.toggleScroll(false); + }); + + it('handles hovering onto other filtering cells', () => { + DashTable.getFilterById('ccc').click(); + DOM.focused.type(`gt 100`); + DashTable.getFilterById('ddd').click(); + DOM.focused.type('lt 20000'); + + DashTable.getCellById(0, 'eee').trigger('mouseover'); + + DashTable.getFilterById('ccc').within(() => cy.get('input').should('have.value', 'gt 100')); + DashTable.getFilterById('ddd').within(() => cy.get('input').should('have.value', 'lt 20000')); + }); - DashTable.getCellById(0, 'rows').within(() => cy.get('.dash-cell-value').should('have.html', '101')); - DashTable.getCellById(1, 'rows').should('not.exist'); - }); + it('handles invalid queries', () => { + let cell_0; + let cell_1; + + DashTable.getCellById(0, 'ccc') + .within(() => cy.get('.dash-cell-value') + .then($el => cell_0 = $el[0].innerHTML)); + + DashTable.getCellById(1, 'ccc') + .within(() => cy.get('.dash-cell-value') + .then($el => cell_1 = $el[0].innerHTML)); + + DashTable.getFilterById('ccc').click(); + DOM.focused.type(`gt`); + DashTable.getFilterById('ddd').click(); + DOM.focused.type('20 a000'); + DashTable.getFilterById('eee').click(); + DOM.focused.type('is prime2'); + DashTable.getFilterById('bbb').click(); + 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('ccc').within(() => cy.get('input').should('have.value', 'gt')); + 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'); + DashTable.getFilterById('ccc').should('have.class', 'invalid'); + DashTable.getFilterById('ddd').should('have.class', 'invalid'); + DashTable.getFilterById('eee').should('have.class', 'invalid'); }); - describe('reset', () => { - beforeEach(() => { - cy.visit(`http://localhost:8080?mode=${AppMode.Filtering}`); - DashTable.toggleScroll(false); - }); - - it('updates results and filter fields', () => { - let cell_0; - let cell_1; - - DashTable.getCellById(0, 'ccc') - .within(() => cy.get('.dash-cell-value') - .then($el => cell_0 = $el[0].innerHTML)); - - DashTable.getCellById(1, 'ccc') - .within(() => cy.get('.dash-cell-value') - .then($el => cell_1 = $el[0].innerHTML)); - - DashTable.getFilterById('ccc').click(); - DOM.focused.type(`gt num(100)`); - DashTable.getFilterById('ddd').click(); - DOM.focused.type('lt num(20000)'); - DashTable.getFilterById('eee').click(); - DOM.focused.type('is prime'); - DashTable.getFilterById('bbb').click(); - DOM.focused.type(`eq "Wet"`); - DashTable.getFilterById('ccc').click(); - - DashTable.getCellById(0, 'ccc').within(() => cy.get('.dash-cell-value').should('have.html', '101')); - DashTable.getCellById(1, 'ccc').within(() => cy.get('.dash-cell-value').should('have.html', '109')); - - cy.get('.clear-filters').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('ccc').within(() => cy.get('input').should('have.value', '')); - DashTable.getFilterById('ddd').within(() => cy.get('input').should('have.value', '')); - DashTable.getFilterById('eee').within(() => cy.get('input').should('have.value', '')); - }); + it('reset updates results and filter fields', () => { + let cell_0; + let cell_1; + + DashTable.getCellById(0, 'ccc') + .within(() => cy.get('.dash-cell-value') + .then($el => cell_0 = $el[0].innerHTML)); + + DashTable.getCellById(1, 'ccc') + .within(() => cy.get('.dash-cell-value') + .then($el => cell_1 = $el[0].innerHTML)); + + DashTable.getFilterById('ccc').click(); + DOM.focused.type(`gt 100`); + DashTable.getFilterById('ddd').click(); + DOM.focused.type('lt 20000'); + DashTable.getFilterById('eee').click(); + DOM.focused.type('is prime'); + DashTable.getFilterById('bbb').click(); + DOM.focused.type(`Wet`); + DashTable.getFilterById('ccc').click(); + + DashTable.getCellById(0, 'ccc').within(() => cy.get('.dash-cell-value').should('have.html', '101')); + DashTable.getCellById(1, 'ccc').within(() => cy.get('.dash-cell-value').should('have.html', '109')); + + DashTable.getFilterById('bbb').within(() => cy.get('input').should('have.value', 'Wet')); + DashTable.getFilterById('ccc').within(() => cy.get('input').should('have.value', 'gt 100')); + DashTable.getFilterById('ddd').within(() => cy.get('input').should('have.value', 'lt 20000')); + DashTable.getFilterById('eee').within(() => cy.get('input').should('have.value', 'is prime')); + + cy.get('.clear-filters').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('ccc').within(() => cy.get('input').should('have.value', '')); + DashTable.getFilterById('ddd').within(() => cy.get('input').should('have.value', '')); + DashTable.getFilterById('eee').within(() => cy.get('input').should('have.value', '')); }); }); \ No newline at end of file diff --git a/tests/cypress/tests/unit/lexeme_test.ts b/tests/cypress/tests/unit/lexeme_test.ts new file mode 100644 index 000000000..5c878e82c --- /dev/null +++ b/tests/cypress/tests/unit/lexeme_test.ts @@ -0,0 +1,117 @@ +import { + fieldExpression, + stringExpression, + valueExpression +} from 'dash-table/syntax-tree/lexeme/expression'; +import operand from 'dash-table/syntax-tree/lexeme/operand'; +import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; + +describe('expression', () => { + it('resolves field expression', () => { + expect(!!fieldExpression.resolve).to.equal(true); + expect(typeof fieldExpression.resolve).to.equal('function'); + + if (fieldExpression.resolve) { + expect(fieldExpression.resolve({ abc: 3 }, { value: '{abc}' } as ISyntaxTree)).to.equal(3); + expect(fieldExpression.resolve({ 'a bc': 3 }, { value: '{a bc}' } as ISyntaxTree)).to.equal(3); + expect(fieldExpression.resolve({ '{abc}': 3 }, { value: '{\\{abc\\}}' } as ISyntaxTree)).to.equal(3); + expect(fieldExpression.resolve({ '"abc"': 3 }, { value: '{"abc"}' } as ISyntaxTree)).to.equal(3); + + expect(fieldExpression.resolve({ abc: 3 }, { value: '{def}' } as ISyntaxTree) === undefined).to.equal(true); + expect(fieldExpression.resolve({ abc: 3 }, { value: '{a bc}' } as ISyntaxTree) === undefined).to.equal(true); + expect(fieldExpression.resolve({ abc: 3 }, { value: '{"abc"}' } as ISyntaxTree) === undefined).to.equal(true); + + expect(fieldExpression.resolve.bind(undefined, {}, { value: '3' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: 'abc' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: '{abc' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: 'abc}' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: '}abc{' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: '{{abc}}' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: '"' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: '`' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: `'` } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: '{' } as ISyntaxTree)).to.throw(Error); + expect(fieldExpression.resolve.bind(undefined, {}, { value: '}' } as ISyntaxTree)).to.throw(Error); + } + }); + + it('resolves string expressions', () => { + expect(!!stringExpression.resolve).to.equal(true); + expect(typeof stringExpression.resolve).to.equal('function'); + + if (stringExpression.resolve) { + expect(stringExpression.resolve(undefined, { value: '\'abc\'' } as ISyntaxTree)).to.equal('abc'); + expect(stringExpression.resolve(undefined, { value: '"abc"' } as ISyntaxTree)).to.equal('abc'); + expect(stringExpression.resolve(undefined, { value: '`abc`' } as ISyntaxTree)).to.equal('abc'); + expect(stringExpression.resolve(undefined, { value: '"\\""' } as ISyntaxTree)).to.equal('"'); + expect(stringExpression.resolve(undefined, { value: `'\\''` } as ISyntaxTree)).to.equal(`'`); + expect(stringExpression.resolve(undefined, { value: '`\\``' } as ISyntaxTree)).to.equal('`'); + expect(stringExpression.resolve(undefined, { value: '\'\\\\\'' } as ISyntaxTree)).to.equal('\\'); + + expect(stringExpression.resolve.bind(undefined, {}, { value: '3' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: 'abc' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: '{abc' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: 'abc}' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: '}abc{' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: '{{abc}}' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: '"' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: '`' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: `'` } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: '{' } as ISyntaxTree)).to.throw(Error); + expect(stringExpression.resolve.bind(undefined, {}, { value: '}' } as ISyntaxTree)).to.throw(Error); + } + }); + + it('resolves value expressions', () => { + expect(!!valueExpression.resolve).to.equal(true); + expect(typeof valueExpression.resolve).to.equal('function'); + + if (valueExpression.resolve) { + expect(valueExpression.resolve(undefined, { value: 'abc' } as ISyntaxTree)).to.equal('abc'); + expect(valueExpression.resolve(undefined, { value: 'abc ' } as ISyntaxTree)).to.equal('abc'); + expect(valueExpression.resolve(undefined, { value: 'abc\\ \\ \\ ' } as ISyntaxTree)).to.equal('abc '); + expect(valueExpression.resolve(undefined, { value: '\\ \\ \\ abc' } as ISyntaxTree)).to.equal(' abc'); + expect(valueExpression.resolve(undefined, { value: 'a\\ bc' } as ISyntaxTree)).to.equal('a bc'); + expect(valueExpression.resolve(undefined, { value: '\\\\' } as ISyntaxTree)).to.equal('\\'); + expect(valueExpression.resolve(undefined, { value: 'abc\\\\' } as ISyntaxTree)).to.equal('abc\\'); + expect(valueExpression.resolve(undefined, { value: '123' } as ISyntaxTree)).to.equal(123); + expect(valueExpression.resolve(undefined, { value: '123.45' } as ISyntaxTree)).to.equal(123.45); + expect(valueExpression.resolve(undefined, { value: '1E6' } as ISyntaxTree)).to.equal(1000000); + expect(valueExpression.resolve(undefined, { value: '0x100' } as ISyntaxTree)).to.equal(256); + expect(valueExpression.resolve(undefined, { value: '\\{abc' } as ISyntaxTree)).to.equal('{abc'); + expect(valueExpression.resolve(undefined, { value: 'abc\\}' } as ISyntaxTree)).to.equal('abc}'); + expect(valueExpression.resolve(undefined, { value: '\\}abc\\{' } as ISyntaxTree)).to.equal('}abc{'); + expect(valueExpression.resolve(undefined, { value: '\\{\\{abc\\}\\}' } as ISyntaxTree)).to.equal('{{abc}}'); + + expect(valueExpression.resolve.bind(undefined, {}, { value: '{abc' } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: 'abc}' } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: '}abc{' } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: '{{abc}}' } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: '"' } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: '`' } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: `'` } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: '{' } as ISyntaxTree)).to.throw(Error); + expect(valueExpression.resolve.bind(undefined, {}, { value: '}' } as ISyntaxTree)).to.throw(Error); + } + }); +}); + +describe('operand', () => { + it('resolves values', () => { + expect(!!operand.resolve).to.equal(true); + expect(typeof operand.resolve).to.equal('function'); + + if (operand.resolve) { + expect(operand.resolve.bind(undefined, {}, { value: 'abc' } as ISyntaxTree)).to.throw(Error); + expect(operand.resolve.bind(undefined, {}, { value: '123' } as ISyntaxTree)).to.throw(Error); + expect(operand.resolve.bind(undefined, {}, { value: '{abc' } as ISyntaxTree)).to.throw(Error); + expect(operand.resolve.bind(undefined, {}, { value: 'abc}' } as ISyntaxTree)).to.throw(Error); + expect(operand.resolve.bind(undefined, {}, { value: '{{abc}}' } as ISyntaxTree)).to.throw(Error); + + expect(operand.resolve({ abc: 3 }, { value: '{abc}' } as ISyntaxTree)).to.equal(3); + expect(operand.resolve({ ['a bc']: 3 }, { value: '{a bc}' } as ISyntaxTree)).to.equal(3); + expect(operand.resolve({ ['{abc}']: 3 }, { value: '{\\{abc\\}}' } as ISyntaxTree)).to.equal(3); + expect(operand.resolve({ ['"abc"']: 3 }, { value: '{"abc"}' } as ISyntaxTree)).to.equal(3); + } + }); +}); \ No newline at end of file diff --git a/tests/cypress/tests/unit/multi_columns_syntactic_tree.ts b/tests/cypress/tests/unit/multi_columns_syntactic_tree.ts new file mode 100644 index 000000000..1dd31c181 --- /dev/null +++ b/tests/cypress/tests/unit/multi_columns_syntactic_tree.ts @@ -0,0 +1,15 @@ +import { MultiColumnsSyntaxTree } from 'dash-table/syntax-tree'; + +describe('Multi Columns Syntax Tree', () => { + it('can do single', () => { + const tree = new MultiColumnsSyntaxTree('{a} >= 3'); + + expect(tree.isValid).to.equal(true); + }); + + it('can "and"', () => { + const tree = new MultiColumnsSyntaxTree('{a} >= 3 && {b} is even'); + + expect(tree.isValid).to.equal(true); + }); +}); \ No newline at end of file diff --git a/tests/cypress/tests/unit/syntactic_tree_test.ts b/tests/cypress/tests/unit/query_syntactic_tree_test.ts similarity index 66% rename from tests/cypress/tests/unit/syntactic_tree_test.ts rename to tests/cypress/tests/unit/query_syntactic_tree_test.ts index 59640a040..7fb011b0e 100644 --- a/tests/cypress/tests/unit/syntactic_tree_test.ts +++ b/tests/cypress/tests/unit/query_syntactic_tree_test.ts @@ -1,38 +1,65 @@ -import SyntaxTree from 'core/syntax-tree'; - -describe('Syntax Tree', () => { - const data0 = { a: '0', b: '0', c: 0, d: null, 'a.dot': '0.dot', 'a-dot': '0-dot', a_dot: '0_dot', 'a+dot': '0+dot', 'a dot': '0 dot', 'a:dot': '0:dot', '_-6.:+** *@$': '0*dot', '\'""\'': '0\'"dot' }; - const data1 = { a: '1', b: '0', c: 1, d: 0, 'a.dot': '1.dot', 'a-dot': '1-dot', a_dot: '1_dot', 'a+dot': '1+dot', 'a dot': '1 dot', 'a:dot': '1:dot', '_-6.:+** *@$': '1*dot', '\'""\'': '1\'"dot' }; - const data2 = { a: '2', b: '1', c: 2, d: '', 'a.dot': '2.dot', 'a-dot': '2-dot', a_dot: '2_dot', 'a+dot': '2+dot', 'a dot': '2 dot', 'a:dot': '2:dot', '_-6.:+** *@$': '2*dot', '\'""\'': '2\'"dot' }; - const data3 = { a: '3', b: '1', c: 3, d: false, 'a.dot': '3.dot', 'a-dot': '3-dot', a_dot: '3_dot', 'a+dot': '3+dot', 'a dot': '3 dot', 'a:dot': '3:dot', '_-6.:+** *@$': '3*dot', '\'""\'': '3\'"dot' }; +import * as R from 'ramda'; + +import { QuerySyntaxTree } from 'dash-table/syntax-tree'; + +describe('Query Syntax Tree', () => { + const data0 = { a: '0', b: '0', c: 0, d: null, '\\{': 0, 'a.dot': '0.dot', 'a-dot': '0-dot', a_dot: '0_dot', 'a+dot': '0+dot', 'a dot': '0 dot', 'a:dot': '0:dot', '_-6.:+** *@$': '0*dot', '{a:dot}': '0*dot*', '\'""\'': '0\'"dot' }; + const data1 = { a: '1', b: '0', c: 1, d: 0, '\\{': 1, 'a.dot': '1.dot', 'a-dot': '1-dot', a_dot: '1_dot', 'a+dot': '1+dot', 'a dot': '1 dot', 'a:dot': '1:dot', '_-6.:+** *@$': '1*dot', '{a:dot}': '1*dot*', '\'""\'': '1\'"dot' }; + const data2 = { a: '2', b: '1', c: 2, d: '', '\\{': 2, 'a.dot': '2.dot', 'a-dot': '2-dot', a_dot: '2_dot', 'a+dot': '2+dot', 'a dot': '2 dot', 'a:dot': '2:dot', '_-6.:+** *@$': '2*dot', '{a:dot}': '2*dot*', '\'""\'': '2\'"dot' }; + const data3 = { a: '3', b: '1', c: 3, d: false, '\\{': 3, 'a.dot': '3.dot', 'a-dot': '3-dot', a_dot: '3_dot', 'a+dot': '3+dot', 'a dot': '3 dot', 'a:dot': '3:dot', '_-6.:+** *@$': '3*dot', '{a:dot}': '3*dot*', '\'""\'': '3\'"dot' }; + + describe('special whitespace characters are valid', () => { + const cases = [ + { name: 'suports new line', query: '{a}\neq\n"0"' }, + { name: 'suports carriage return', query: '{a}\req\r"0"' }, + { name: 'suports new line ad carriage return combination', query: '{a}\r\neq\r\n"0"' }, + { name: 'supports tab', query: '{a}\teq\t"0"' }, + // some random non-standard whitespace character from https://en.wikipedia.org/wiki/Whitespace_character + { name: 'supports ogham space mark', query: '{a}\u1680eq\u1680"0"' }, + { name: 'supports all', query: '{a}\r\n\t\u1680eq\r\n\t\u1680"0"' } + ]; + + R.forEach(c => { + it(c.name, () => { + const tree = new QuerySyntaxTree(c.query); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate(data0)).to.equal(true); + }); + }, cases); + }); describe('operands', () => { it('does not support badly formed operands', () => { - expect(new SyntaxTree(`'myField' eq num(0)`).isValid).to.equal(true); - expect(new SyntaxTree(`"myField" eq num(0)`).isValid).to.equal(true); - expect(new SyntaxTree('`myField` eq num(0)').isValid).to.equal(true); - expect(new SyntaxTree(`'myField\\' eq num(0)`).isValid).to.equal(false); - expect(new SyntaxTree(`"myField\\" eq num(0)`).isValid).to.equal(false); - expect(new SyntaxTree('`myField\\` eq num(0)').isValid).to.equal(false); - expect(new SyntaxTree(`\\'myField' eq num(0)`).isValid).to.equal(false); - expect(new SyntaxTree(`\\"myField" eq num(0)`).isValid).to.equal(false); - expect(new SyntaxTree('\\`myField` eq num(0)').isValid).to.equal(false); + expect(new QuerySyntaxTree(`{myField} eq 0`).isValid).to.equal(true); + expect(new QuerySyntaxTree(`{'myField'} eq 0`).isValid).to.equal(true); + expect(new QuerySyntaxTree(`{"myField"} eq 0`).isValid).to.equal(true); + expect(new QuerySyntaxTree('{`myField`} eq 0').isValid).to.equal(true); + expect(new QuerySyntaxTree('{\\{myField\\}} eq 0').isValid).to.equal(true); + expect(new QuerySyntaxTree('{{myField}} eq 0').isValid).to.equal(false); + expect(new QuerySyntaxTree(`{myField eq 0`).isValid).to.equal(false); + expect(new QuerySyntaxTree(`myField} eq 0`).isValid).to.equal(false); + expect(new QuerySyntaxTree(`myField eq 0`).isValid).to.equal(false); + expect(new QuerySyntaxTree('{\\\\{myField\\\\}} eq 0').isValid).to.equal(false); }); it('does not support badly formed expression', () => { - expect(new SyntaxTree(`myField eq 'value'`).isValid).to.equal(true); - expect(new SyntaxTree(`myField eq "value"`).isValid).to.equal(true); - expect(new SyntaxTree('myField eq `value`').isValid).to.equal(true); - expect(new SyntaxTree(`myField eq 'value\\'`).isValid).to.equal(false); - expect(new SyntaxTree(`myField eq "value\\"`).isValid).to.equal(false); - expect(new SyntaxTree('myField eq `value\\`').isValid).to.equal(false); - expect(new SyntaxTree(`myField eq \\'value'`).isValid).to.equal(false); - expect(new SyntaxTree(`myField eq \\"value"`).isValid).to.equal(false); - expect(new SyntaxTree('myField eq \\`value`').isValid).to.equal(false); + expect(new QuerySyntaxTree(`{myField} eq 'value'`).isValid).to.equal(true); + expect(new QuerySyntaxTree(`{myField} eq "value"`).isValid).to.equal(true); + expect(new QuerySyntaxTree('{myField} eq `value`').isValid).to.equal(true); + expect(new QuerySyntaxTree(`{myField} eq 'value\\\\'`).isValid).to.equal(true); + expect(new QuerySyntaxTree(`{myField} eq 'value\\''`).isValid).to.equal(true); + expect(new QuerySyntaxTree(`{myField} eq "value\\\\"`).isValid).to.equal(true); + expect(new QuerySyntaxTree('{myField} eq `value\\\\`').isValid).to.equal(true); + expect(new QuerySyntaxTree(`{myField} eq \\\\'value'`).isValid).to.equal(false); + expect(new QuerySyntaxTree(`{myField} eq \\\\"value"`).isValid).to.equal(false); + expect(new QuerySyntaxTree(`{myField} eq 'value\\'`).isValid).to.equal(false); + expect(new QuerySyntaxTree(`{myField} eq "value\\"`).isValid).to.equal(false); + expect(new QuerySyntaxTree('{myField} eq `value\\`').isValid).to.equal(false); }); it('support arbitrary quoted column name', () => { - const tree = new SyntaxTree(`'_-6.:+** *@$' eq "1*dot" || '_-6.:+** *@$' eq "2*dot"`); + const tree = new QuerySyntaxTree(`{_-6.:+** *@$} eq "1*dot" || {_-6.:+** *@$} eq "2*dot"`); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -42,7 +69,7 @@ describe('Syntax Tree', () => { }); it('support column name with "."', () => { - const tree = new SyntaxTree('a.dot eq "1.dot" || a.dot eq "2.dot"'); + const tree = new QuerySyntaxTree('{a.dot} eq "1.dot" || {a.dot} eq "2.dot"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -52,7 +79,7 @@ describe('Syntax Tree', () => { }); it('support column name with "-"', () => { - const tree = new SyntaxTree('a-dot eq "1-dot" || a-dot eq "2-dot"'); + const tree = new QuerySyntaxTree('{a-dot} eq "1-dot" || {a-dot} eq "2-dot"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -62,7 +89,7 @@ describe('Syntax Tree', () => { }); it('support column name with "_"', () => { - const tree = new SyntaxTree('a_dot eq "1_dot" || a_dot eq "2_dot"'); + const tree = new QuerySyntaxTree('{a_dot} eq "1_dot" || {a_dot} eq "2_dot"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -72,7 +99,7 @@ describe('Syntax Tree', () => { }); it('support column name with "+"', () => { - const tree = new SyntaxTree('a+dot eq "1+dot" || a+dot eq "2+dot"'); + const tree = new QuerySyntaxTree('{a+dot} eq "1+dot" || {a+dot} eq "2+dot"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -82,7 +109,17 @@ describe('Syntax Tree', () => { }); it('support column name with ":"', () => { - const tree = new SyntaxTree('a:dot eq "1:dot" || a:dot eq "2:dot"'); + const tree = new QuerySyntaxTree('{a:dot} eq "1:dot" || {a:dot} eq "2:dot"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate(data0)).to.equal(false); + expect(tree.evaluate(data1)).to.equal(true); + expect(tree.evaluate(data2)).to.equal(true); + expect(tree.evaluate(data3)).to.equal(false); + }); + + it('support column name with " " (space)', () => { + const tree = new QuerySyntaxTree('{a dot} eq "1 dot" || {a dot} eq "2 dot"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -91,8 +128,8 @@ describe('Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); - it('support double quoted column name with " " (space)', () => { - const tree = new SyntaxTree('"a dot" eq "1 dot" || "a dot" eq "2 dot"'); + it('support column name with "{}"', () => { + const tree = new QuerySyntaxTree('{\\{a:dot\\}} eq "1*dot*" || {\\{a:dot\\}} eq "2*dot*"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -101,8 +138,8 @@ describe('Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); - it('support single quoted column name with " " (space)', () => { - const tree = new SyntaxTree('\'a dot\' eq "1 dot" || \'a dot\' eq "2 dot"'); + it('support column name with "\\"', () => { + const tree = new QuerySyntaxTree('{\\\\\\{} eq 1 || {\\\\\\{} eq 2'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -112,7 +149,7 @@ describe('Syntax Tree', () => { }); it('support nesting in quotes', () => { - const tree = new SyntaxTree(`\`'""'\` eq \`1'"dot\` || \`'""'\` eq \`2'"dot\``); + const tree = new QuerySyntaxTree(`{'""'} eq '1\\'"dot' || {'""'} eq '2\\'"dot'`); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -124,7 +161,7 @@ describe('Syntax Tree', () => { describe('&& and ||', () => { it('can || two conditions', () => { - const tree = new SyntaxTree('a eq "1" || a eq "2"'); + const tree = new QuerySyntaxTree('{a} eq "1" || {a} eq "2"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -134,7 +171,7 @@ describe('Syntax Tree', () => { }); it('can "or" two conditions', () => { - const tree = new SyntaxTree('a eq "1" or a eq "2"'); + const tree = new QuerySyntaxTree('{a} eq "1" or {a} eq "2"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -144,7 +181,7 @@ describe('Syntax Tree', () => { }); it('can && two conditions', () => { - const tree = new SyntaxTree('a eq "1" && b eq "0"'); + const tree = new QuerySyntaxTree('{a} eq "1" && {b} eq "0"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -154,7 +191,7 @@ describe('Syntax Tree', () => { }); it('can "and" two conditions', () => { - const tree = new SyntaxTree('a eq "1" and b eq "0"'); + const tree = new QuerySyntaxTree('{a} eq "1" and {b} eq "0"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -164,7 +201,7 @@ describe('Syntax Tree', () => { }); it('gives priority to && over ||', () => { - const tree = new SyntaxTree('a eq "1" && a eq "0" || b eq "1"'); + const tree = new QuerySyntaxTree('{a} eq "1" && {a} eq "0" || {b} eq "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -174,7 +211,7 @@ describe('Syntax Tree', () => { }); it('gives priority to "and" over "or"', () => { - const tree = new SyntaxTree('a eq "1" and a eq "0" or b eq "1"'); + const tree = new QuerySyntaxTree('{a} eq "1" and {a} eq "0" or {b} eq "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -186,7 +223,7 @@ describe('Syntax Tree', () => { describe('data types', () => { it('can compare numbers', () => { - const tree = new SyntaxTree('c eq num(1)'); + const tree = new QuerySyntaxTree('{c} eq 1'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -196,7 +233,7 @@ describe('Syntax Tree', () => { }); it('can compare floats', () => { - const tree = new SyntaxTree('field ge num(1.5)'); + const tree = new QuerySyntaxTree('{field} ge 1.5'); expect(tree.isValid).to.equal(true); expect(tree.evaluate({ field: -1.501 })).to.equal(false); @@ -208,7 +245,7 @@ describe('Syntax Tree', () => { }); it('can compare string to number and return false', () => { - const tree = new SyntaxTree('a eq num(1)'); + const tree = new QuerySyntaxTree('{a} eq 1'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -218,7 +255,7 @@ describe('Syntax Tree', () => { }); it('can compare strings', () => { - const tree = new SyntaxTree('a eq str(1)'); + const tree = new QuerySyntaxTree('{a} eq "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -228,7 +265,7 @@ describe('Syntax Tree', () => { }); it('can compare string to number and return false', () => { - const tree = new SyntaxTree('c eq str(1)'); + const tree = new QuerySyntaxTree('{c} eq "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -240,7 +277,7 @@ describe('Syntax Tree', () => { describe('block', () => { it('has priority over && and ||', () => { - const tree = new SyntaxTree('a eq "1" && (a eq "0" || b eq "1")'); + const tree = new QuerySyntaxTree('{a} eq "1" && ({a} eq "0" || {b} eq "1")'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -250,7 +287,7 @@ describe('Syntax Tree', () => { }); it('gives priority over "and" and "or"', () => { - const tree = new SyntaxTree('a eq "1" and (a eq "0" or b eq "1")'); + const tree = new QuerySyntaxTree('{a} eq "1" and ({a} eq "0" or {b} eq "1")'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -260,7 +297,7 @@ describe('Syntax Tree', () => { }); it('can be uselessly nested', () => { - const tree = new SyntaxTree('((a eq "1" and (((a eq "0" or b eq "1")))))'); + const tree = new QuerySyntaxTree('(({a} eq "1" and ((({a} eq "0" or {b} eq "1")))))'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -270,7 +307,7 @@ describe('Syntax Tree', () => { }); it('can be nested', () => { - const tree = new SyntaxTree('a eq "1" and (a eq "0" or (b eq "1" or b eq "0"))'); + const tree = new QuerySyntaxTree('{a} eq "1" and ({a} eq "0" or ({b} eq "1" or {b} eq "0"))'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -278,11 +315,21 @@ describe('Syntax Tree', () => { expect(tree.evaluate(data2)).to.equal(false); expect(tree.evaluate(data3)).to.equal(false); }); + + it('behave correctly with value expressions', () => { + const tree = new QuerySyntaxTree('{a} eq 1 and ({a} eq 0 or ({b} eq 1 or {b} eq 0))'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 0, b: 0 })).to.equal(false); + expect(tree.evaluate({ a: 0, b: 1 })).to.equal(false); + expect(tree.evaluate({ a: 1, b: 0 })).to.equal(true); + expect(tree.evaluate({ a: 1, b: 1 })).to.equal(true); + }); }); describe('unary operators', () => { it('can check nil', () => { - const tree = new SyntaxTree('d is nil'); + const tree = new QuerySyntaxTree('{d} is nil'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -292,7 +339,7 @@ describe('Syntax Tree', () => { }); it('can invert check nil', () => { - const tree = new SyntaxTree('!(d is nil)'); + const tree = new QuerySyntaxTree('!({d} is nil)'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -302,7 +349,7 @@ describe('Syntax Tree', () => { }); it('can check odd', () => { - const tree = new SyntaxTree('c is odd'); + const tree = new QuerySyntaxTree('{c} is odd'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -312,7 +359,7 @@ describe('Syntax Tree', () => { }); it('can check odd on string and return false', () => { - const tree = new SyntaxTree('a is odd'); + const tree = new QuerySyntaxTree('{a} is odd'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -322,7 +369,7 @@ describe('Syntax Tree', () => { }); it('can check even', () => { - const tree = new SyntaxTree('c is even'); + const tree = new QuerySyntaxTree('{c} is even'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -332,7 +379,7 @@ describe('Syntax Tree', () => { }); it('can check even on string and return false', () => { - const tree = new SyntaxTree('a is even'); + const tree = new QuerySyntaxTree('{a} is even'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -342,7 +389,7 @@ describe('Syntax Tree', () => { }); it('can check if string', () => { - const tree = new SyntaxTree('d is str'); + const tree = new QuerySyntaxTree('{d} is str'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -352,7 +399,7 @@ describe('Syntax Tree', () => { }); it('can check if number', () => { - const tree = new SyntaxTree('d is num'); + const tree = new QuerySyntaxTree('{d} is num'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -362,7 +409,7 @@ describe('Syntax Tree', () => { }); it('can check if bool', () => { - const tree = new SyntaxTree('d is bool'); + const tree = new QuerySyntaxTree('{d} is bool'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -372,7 +419,7 @@ describe('Syntax Tree', () => { }); it('can check if object', () => { - const tree = new SyntaxTree('d is object'); + const tree = new QuerySyntaxTree('{d} is object'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -384,7 +431,7 @@ describe('Syntax Tree', () => { describe('unary not', () => { it('can invert block', () => { - const tree = new SyntaxTree('!(a eq "1")'); + const tree = new QuerySyntaxTree('!({a} eq "1")'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -394,7 +441,7 @@ describe('Syntax Tree', () => { }); it('can invert block multiple times', () => { - const tree = new SyntaxTree('!!(a eq "1")'); + const tree = new QuerySyntaxTree('!!({a} eq "1")'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -406,7 +453,7 @@ describe('Syntax Tree', () => { describe('logical binary operators', () => { it('can do equality (eq) test', () => { - const tree = new SyntaxTree('a eq "1"'); + const tree = new QuerySyntaxTree('{a} eq "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -416,7 +463,7 @@ describe('Syntax Tree', () => { }); it('can do equality (=) test', () => { - const tree = new SyntaxTree('a = "1"'); + const tree = new QuerySyntaxTree('{a} = "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -426,7 +473,7 @@ describe('Syntax Tree', () => { }); it('can do difference (ne) test', () => { - const tree = new SyntaxTree('a ne "1"'); + const tree = new QuerySyntaxTree('{a} ne "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -436,7 +483,7 @@ describe('Syntax Tree', () => { }); it('can do difference (!=) test', () => { - const tree = new SyntaxTree('a != "1"'); + const tree = new QuerySyntaxTree('{a} != "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -446,7 +493,7 @@ describe('Syntax Tree', () => { }); it('can do greater than (gt) test', () => { - const tree = new SyntaxTree('a gt "1"'); + const tree = new QuerySyntaxTree('{a} gt "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -456,7 +503,7 @@ describe('Syntax Tree', () => { }); it('can do greater than (>) test', () => { - const tree = new SyntaxTree('a > "1"'); + const tree = new QuerySyntaxTree('{a} > "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -466,7 +513,7 @@ describe('Syntax Tree', () => { }); it('can do less than (lt) test', () => { - const tree = new SyntaxTree('a lt "1"'); + const tree = new QuerySyntaxTree('{a} lt "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -476,7 +523,7 @@ describe('Syntax Tree', () => { }); it('can do less than (<) test', () => { - const tree = new SyntaxTree('a < "1"'); + const tree = new QuerySyntaxTree('{a} < "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -486,7 +533,7 @@ describe('Syntax Tree', () => { }); it('can do greater or equal to (ge) test', () => { - const tree = new SyntaxTree('a ge "1"'); + const tree = new QuerySyntaxTree('{a} ge "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -496,7 +543,7 @@ describe('Syntax Tree', () => { }); it('can do greater or equal to (>=) test', () => { - const tree = new SyntaxTree('a >= "1"'); + const tree = new QuerySyntaxTree('{a} >= "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); @@ -506,7 +553,7 @@ describe('Syntax Tree', () => { }); it('can do less or equal to (le) test', () => { - const tree = new SyntaxTree('a le "1"'); + const tree = new QuerySyntaxTree('{a} le "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -516,7 +563,7 @@ describe('Syntax Tree', () => { }); it('can do less or equal to (<=) test', () => { - const tree = new SyntaxTree('a <= "1"'); + const tree = new QuerySyntaxTree('{a} <= "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(true); @@ -524,5 +571,13 @@ describe('Syntax Tree', () => { expect(tree.evaluate(data2)).to.equal(false); expect(tree.evaluate(data3)).to.equal(false); }); + + it('can do contains (contains) test', () => { + const tree = new QuerySyntaxTree('{a} contains v'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 'abc v' })).to.equal(true); + expect(tree.evaluate({ a: 'abc w' })).to.equal(false); + }); }); }); \ No newline at end of file diff --git a/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts b/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts new file mode 100644 index 000000000..dacdcc140 --- /dev/null +++ b/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts @@ -0,0 +1,58 @@ +import { SingleColumnSyntaxTree } from 'dash-table/syntax-tree'; + +describe('Single Column Syntax Tree', () => { + it('cannot have operand', () => { + const tree = new SingleColumnSyntaxTree('a', '{a} <= 1'); + + expect(tree.isValid).to.equal(false); + }); + + it('cannot have binary dangle', () => { + const tree = new SingleColumnSyntaxTree('a', '<='); + + expect(tree.isValid).to.equal(false); + }); + + it('cannot be unary + expression', () => { + const tree = new SingleColumnSyntaxTree('a', 'is prime "a"'); + + expect(tree.isValid).to.equal(false); + }); + + it('can be empty', () => { + const tree = new SingleColumnSyntaxTree('a', ''); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 0 })).to.equal(true); + }); + + it('can be binary + expression', () => { + const tree = new SingleColumnSyntaxTree('a', '<= 1'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 0 })).to.equal(true); + expect(tree.evaluate({ a: 2 })).to.equal(false); + + expect(tree.toQueryString()).to.equal('{a} <= 1'); + }); + + it('can be unary', () => { + const tree = new SingleColumnSyntaxTree('a', 'is prime'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 5 })).to.equal(true); + expect(tree.evaluate({ a: 6 })).to.equal(false); + + expect(tree.toQueryString()).to.equal('{a} is prime'); + }); + + it('can be expression', () => { + const tree = new SingleColumnSyntaxTree('a', '1'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 1 })).to.equal(true); + expect(tree.evaluate({ a: 2 })).to.equal(false); + + expect(tree.toQueryString()).to.equal('{a} eq 1'); + }); +}); \ No newline at end of file diff --git a/tests/dash/app_dataframe_backend_paging.py b/tests/dash/app_dataframe_backend_paging.py index f9ffe0973..93fb53bc2 100644 --- a/tests/dash/app_dataframe_backend_paging.py +++ b/tests/dash/app_dataframe_backend_paging.py @@ -141,7 +141,7 @@ def layout(): pagination_mode='be', filtering='be', - filtering_settings='' + filter='' ), section_title('Backend Paging with Filtering and Multi-Column Sorting'), @@ -158,7 +158,7 @@ def layout(): pagination_mode='be', filtering='be', - filtering_settings='', + filter='', sorting='be', sorting_type='multi', @@ -188,7 +188,7 @@ def layout(): pagination_mode='be', filtering='be', - filtering_settings='', + filter='', sorting='be', sorting_type='multi', @@ -269,10 +269,10 @@ def update_graph(pagination_settings, sorting_settings): @app.callback( Output(IDS["table-filtering"], "data"), [Input(IDS["table-filtering"], "pagination_settings"), - Input(IDS["table-filtering"], "filtering_settings")]) -def update_graph(pagination_settings, filtering_settings): - print(filtering_settings) - filtering_expressions = filtering_settings.split(' && ') + Input(IDS["table-filtering"], "filter")]) +def update_graph(pagination_settings, filter): + print(filter) + filtering_expressions = filter.split(' && ') dff = df for filter in filtering_expressions: if ' eq ' in filter: @@ -298,9 +298,9 @@ def update_graph(pagination_settings, filtering_settings): Output(IDS["table-sorting-filtering"], "data"), [Input(IDS["table-sorting-filtering"], "pagination_settings"), Input(IDS["table-sorting-filtering"], "sorting_settings"), - Input(IDS["table-sorting-filtering"], "filtering_settings")]) -def update_graph(pagination_settings, sorting_settings, filtering_settings): - filtering_expressions = filtering_settings.split(' && ') + Input(IDS["table-sorting-filtering"], "filter")]) +def update_graph(pagination_settings, sorting_settings, filter): + filtering_expressions = filter.split(' && ') dff = df for filter in filtering_expressions: if ' eq ' in filter: @@ -336,9 +336,9 @@ def update_graph(pagination_settings, sorting_settings, filtering_settings): Output(IDS["table-paging-with-graph"], "data"), [Input(IDS["table-paging-with-graph"], "pagination_settings"), Input(IDS["table-paging-with-graph"], "sorting_settings"), - Input(IDS["table-paging-with-graph"], "filtering_settings")]) -def update_table(pagination_settings, sorting_settings, filtering_settings): - filtering_expressions = filtering_settings.split(' && ') + Input(IDS["table-paging-with-graph"], "filter")]) +def update_table(pagination_settings, sorting_settings, filter): + filtering_expressions = filter.split(' && ') dff = df for filter in filtering_expressions: if ' eq ' in filter: diff --git a/tests/dash/app_dataframe_updating_graph_fe.py b/tests/dash/app_dataframe_updating_graph_fe.py index d2188bd19..beb8de26c 100644 --- a/tests/dash/app_dataframe_updating_graph_fe.py +++ b/tests/dash/app_dataframe_updating_graph_fe.py @@ -53,7 +53,7 @@ def layout(): > A quick note on filtering. We have defined our own > syntax for performing filtering operations. Here are some > examples for this particular dataset: - > - `lt num(50)` in the `lifeExp` column + > - `lt 50` in the `lifeExp` column > - `eq "Canada"` in the `country` column By default, these transformations are done clientside. diff --git a/tests/dash/app_dropdown.py b/tests/dash/app_dropdown.py deleted file mode 100644 index c047a1315..000000000 --- a/tests/dash/app_dropdown.py +++ /dev/null @@ -1,190 +0,0 @@ -from collections import OrderedDict -from dash.dependencies import Input, Output -import dash_core_components as dcc -import dash_html_components as html -import pandas as pd -from textwrap import dedent - -import dash_table -from index import app -from .utils import section_title - - -ID_PREFIX = "app_dropdown" -IDS = { - "dropdown": ID_PREFIX, - "dropdown-by-cell": '{}-row-by-cell'.format(ID_PREFIX), - "dropdown-by-cell-deprecated": '{}-deprecated-row-by-cell'.format(ID_PREFIX) -} - - -df = pd.DataFrame(OrderedDict([ - ('climate', - ['Sunny', 'Snowy', 'Sunny', 'Rainy']), - ('temperature', - [13, 43, 50, 30]), - ('city', - ['NYC', 'Montreal', 'Miami', 'NYC']) -])) - -df_per_row_dropdown = pd.DataFrame(OrderedDict([ - ('City', - ['NYC', 'Montreal', 'Los Angeles']), - ('Neighborhood', - ['Brooklyn', 'Mile End', 'Venice']), - ('Temperature (F)', - [70, 60, 90]), -])) - - -def layout(): - return html.Div([ - - dcc.Markdown(dedent(''' - The Dash table includes support for per-column and - per-cell dropdowns. In future releases, this will - be tightly integrated with a more formal typing system. - - For now, use the dropdown renderer as a way to limit the - options available when editing the values with an editable table. - - ''')), - - section_title('Dash Table with Per-Column Dropdowns'), - - dash_table.DataTable( - id=IDS['dropdown'], - data=df.to_dict('rows'), - columns=[ - {'id': 'climate', 'name': 'climate'}, - {'id': 'temperature', 'name': 'temperature'}, - {'id': 'city', 'name': 'city'}, - ], - - editable=True, - column_static_dropdown=[ - { - 'id': 'climate', - 'dropdown': [ - {'label': i, 'value': i} - for i in df['climate'].unique() - ] - }, - { - 'id': 'city', - 'dropdown': [ - {'label': i, 'value': i} - for i in df['city'].unique() - ] - }, - ] - ), - - section_title('Dash Table with Per-Cell Dropdowns via Filtering UI'), - - dash_table.DataTable( - id=IDS['dropdown-by-cell'], - data=df_per_row_dropdown.to_dict('rows'), - columns=[ - {'id': c, 'name': c} - for c in df_per_row_dropdown.columns - ], - - editable=True, - column_conditional_dropdowns=[ - { - 'id': 'Neighborhood', - 'dropdowns': [ - - { - 'condition': 'City eq "NYC"', - 'dropdown': [ - {'label': i, 'value': i} - for i in [ - 'Brooklyn', - 'Queens', - 'Staten Island' - ] - ] - }, - - { - 'condition': 'City eq "Montreal"', - 'dropdown': [ - {'label': i, 'value': i} - for i in [ - 'Mile End', - 'Plateau', - 'Hochelaga' - ] - ] - }, - - { - 'condition': 'City eq "Los Angeles"', - 'dropdown': [ - {'label': i, 'value': i} - for i in [ - 'Venice', - 'Hollywood', - 'Los Feliz' - ] - ] - } - - ] - } - ] - ), - - section_title('Dash Table with Per-Cell Dropdowns'), - - html.Div('This example uses a deprecated API, `dropdown_properties`.'), - - dash_table.DataTable( - id=IDS['dropdown-by-cell-deprecated'], - data=df_per_row_dropdown.to_dict('rows'), - columns=[ - {'id': c, 'name': c} - for c in df_per_row_dropdown.columns - ], - - editable=True, - dropdown_properties=[ - { - 'options': [ - {'label': i, 'value': i} - for i in [ - 'Brooklyn', - 'Queens', - 'Staten Island' - ] - ] - }, - - { - 'options': [ - {'label': i, 'value': i} - for i in [ - 'Mile End', - 'Plateau', - 'Hochelaga' - ] - ] - }, - - { - 'options': [ - {'label': i, 'value': i} - for i in [ - 'Venice', - 'Hollywood', - 'Los Feliz' - ] - ] - }, - ] - - ), - - ]) diff --git a/tests/dash/app_multi_header.py b/tests/dash/app_multi_header.py deleted file mode 100644 index bfa719584..000000000 --- a/tests/dash/app_multi_header.py +++ /dev/null @@ -1,32 +0,0 @@ -import dash_table -import pandas as pd - -sanitized_name = __name__.replace(".", "-") - -def layout(): - df = pd.read_csv("datasets/gapminder.csv") - return dash_table.DataTable( - id=sanitized_name, - columns=[ - {"name": ["Year", ""], "id": "year"}, - {"name": ["City", "Montreal"], "id": "montreal"}, - {"name": ["City", "Toronto"], "id": "toronto"}, - {"name": ["City", "Ottawa"], "id": "ottawa", "hidden": True}, - {"name": ["City", "Vancouver"], "id": "vancouver"}, - {"name": ["Climate", "Temperature"], "id": "temp"}, - {"name": ["Climate", "Humidity"], "id": "humidity"}, - ], - data=[ - { - "year": i, - "montreal": i * 10, - "toronto": i * 100, - "ottawa": i * -1, - "vancouver": i * -10, - "temp": i * -100, - "humidity": i * 0.1, - } - for i in range(100) - ], - merge_duplicate_headers=True, - ) diff --git a/tests/dash/app_simple.py b/tests/dash/app_simple.py deleted file mode 100644 index 134895151..000000000 --- a/tests/dash/app_simple.py +++ /dev/null @@ -1,12 +0,0 @@ -import dash_table -import pandas as pd - -sanitized_name = __name__.replace(".", "-") - -def layout(): - df = pd.read_csv("./datasets/gapminder.csv") - return dash_table.DataTable( - id=sanitized_name, - columns=[{"name": i, "id": i} for i in df.columns], - data=df.to_dict("rows"), - ) diff --git a/tests/dash/app_sizing.py b/tests/dash/app_sizing.py deleted file mode 100644 index 1657a2e10..000000000 --- a/tests/dash/app_sizing.py +++ /dev/null @@ -1,432 +0,0 @@ -from collections import OrderedDict -from dash.dependencies import Input, Output -import dash_core_components as dcc -import dash_html_components as html -import pandas as pd -from textwrap import dedent - -import dash_table -from index import app -from .utils import section_title, html_table - - -def layout(): - data = OrderedDict( - [ - ( - "Date", - [ - "July 12th, 2013 - July 25th, 2013", - "July 12th, 2013 - August 25th, 2013", - "July 12th, 2014 - August 25th, 2014", - ], - ), - ( - "Election Polling Organization", - ["The New York Times", "Pew Research", "The Washington Post"], - ), - ("Rep", [1, -20, 3.512]), - ("Dem", [10, 20, 30]), - ("Ind", [2, 10924, 3912]), - ( - "Region", - [ - "Northern New York State to the Southern Appalachian Mountains", - "Canada", - "Southern Vermont", - ], - ), - ] - ) - - df = pd.DataFrame(data) - df_long = pd.DataFrame( - OrderedDict([(name, col_data * 10) for (name, col_data) in data.items()]) - ) - - return [ - html.H1("Sizing Guide"), - html.Div( - style={ - "marginLeft": "auto", - "marginRight": "auto", - "width": "80%", - "borderLeft": "thin hotpink solid", - "borderRight": "thin hotpink solid", - }, - children=[ - html.H1("Background - HTML Tables"), - section_title("HTML Table - Default Styles"), - html.Div("By default, HTML tables expand to their contents"), - html_table(df, table_style={}, base_column_style={}), - section_title("HTML Table - Padding"), - html.Div( - """ - Since the table content is packed so tightly, - it's usually a good idea to place some left - on the columns. - """ - ), - html_table(df, table_style={}, cell_style={"paddingLeft": 10}), - section_title("HTML Table - Responsive Table"), - html.Div( - """ - With 100% width, the tables will expand to their - container. When the table gets small, the text will break into - multiple lines. - """ - ), - html_table(df, table_style={"width": "100%"}, base_column_style={}), - section_title("HTML Table - All Column Widths defined by Percent"), - html.Div( - """ - The column widths can be definied by percents rather than pixels. - """ - ), - html_table( - df, - table_style={"width": "100%"}, - column_style={ - "Date": {"width": "30%"}, - "Election Polling Organization": {"width": "25%"}, - "Dem": {"width": "5%"}, - "Rep": {"width": "5%"}, - "Ind": {"width": "5%"}, - "Region": {"width": "30%"}, - }, - ), - section_title("HTML Table - Single Column Width Defined by Percent"), - html.Div( - """ - The width of one column (Region=50%) can be definied by percent. - """ - ), - html_table( - df, - table_style={"width": "100%"}, - column_style={"Region": {"width": "50%"}}, - ), - section_title("HTML Table - Columns with min-width"), - html.Div( - "Here, the min-width for the first column is 130px, or about the width of this line: " - ), - html.Div( - style={"width": 130, "height": 10, "backgroundColor": "hotpink"} - ), - html_table( - df, - table_style={"width": "100%"}, - column_style={"Date": {"minWidth": "130"}}, - ), - section_title("HTML Table - Underspecified Widths"), - html.Div( - """ - The widths can be under-specified. Here, we're only setting the width for the three - columns in the middle, the rest of the columns are automatically sized to fit the rest of the container. - The columns have a width of 50px, or the width of this line: - """ - ), - html.Div( - style={"width": 50, "height": 10, "backgroundColor": "hotpink"} - ), - html_table( - df, - table_style={"width": "100%"}, - column_style={ - "Dem": {"width": 50}, - "Rep": {"width": 50}, - "Ind": {"width": 50}, - }, - ), - section_title("HTML Table - Widths that are smaller than the content"), - html.Div( - """ - In this case, we're setting the width to 20px, which is smaller - than the "10924" number in the "Ind" column. - The table does not allow it. - """ - ), - html.Div( - style={"width": 20, "height": 10, "backgroundColor": "hotpink"} - ), - html_table( - df, - table_style={"width": "100%"}, - column_style={ - "Dem": {"width": 20}, - "Rep": {"width": 20}, - "Ind": {"width": 20}, - }, - ), - section_title("HTML Table - Content with Ellipses"), - html.Div( - """ - With `max-width`, the content can collapse into - ellipses once the content doesn't fit. - - Here, `max-width` is set to 0. It could be any number, the only - important thing is that it is supplied. The behaviour will be - the same whether it is 0 or 50. - """ - ), - html_table( - df, - table_style={"width": "100%"}, - cell_style={ - "whiteSpace": "nowrap", - "overflow": "hidden", - "textOverflow": "ellipsis", - "maxWidth": 0, - }, - ), - section_title("HTML Table - Vertical Scrolling"), - html.Div( - """ - By supplying a max-height of the Table container and supplying - `overflow-y: scroll`, the table will become scrollable if the - table's contents are larger than the container. - """ - ), - html.Div( - style={"maxHeight": 300, "overflowY": "scroll"}, - children=html_table(df_long, table_style={"width": "100%"}), - ), - section_title("HTML Table - Vertical Scrolling with Max Height"), - html.Div( - """ - With `max-height`, if the table's contents are shorter than the - `max-height`, then the container will be shorter. - If you want a container with a constant height no matter the - contents, then use `height`. - - Here, we're setting max-height to 300, or the height of this line: - """ - ), - html.Div( - style={"width": 5, "height": 300, "backgroundColor": "hotpink"} - ), - html.Div( - style={"maxHeight": 300, "overflowY": "scroll"}, - children=html_table(df, table_style={"width": "100%"}), - ), - section_title("HTML Table - Vertical Scrolling with Height"), - html.Div("and here is `height` with the same content"), - html.Div( - style={"height": 300, "overflowY": "scroll"}, - children=html_table(df, table_style={"width": "100%"}), - ), - section_title("HTML Table - Horizontal Scrolling"), - html.Div( - """ - With HTML tables, we can set `min-width` to be 100%. - If the content is small, then the columns will have some extra - space. - But if the content of any of the cells is really large, then the - cells will expand beyond the container and a scrollbar will appear. - - In this way, `min-width` and `overflow-x: scroll` is an alternative - to `text-overflow: ellipses`. With scroll, the content that can't - fit in the container will get pushed out into a scrollable zone. - With text-overflow: ellipses, the content will get truncated by - ellipses. Both strategies work with or without line breaks on the - white spaces (`white-space: normal` or `white-space: nowrap`). - - These next two examples have the same styles applied: - - `min-width: 100%` - - `white-space: nowrap` (to keep the content on a single line) - - A parent with `overflow-x: scroll` - - """ - ), - section_title("HTML Table - Two Columns, 100% Min-Width"), - html.Div( - html_table( - pd.DataFrame({"Column 1": [1, 2], "Column 2": [3, 3]}), - table_style={"minWidth": "100%"}, - cell_style={"whiteSpace": "nowrap"}, - ), - style={"overflowX": "scroll"}, - ), - section_title("HTML Table - Long Columns, 100% Min-Width"), - html.Div( - """ - Here is a table with several columns with long titles, - 100% min-width, and `'white-space': 'nowrap'` - (to keep the text on a single line) - """ - ), - html.Div( - html_table( - pd.DataFrame( - { - "This is Column {} Data".format(i): [1, 2] - for i in range(10) - } - ), - table_style={"minWidth": "100%", "overflowX": "scroll"}, - cell_style={"whiteSpace": "nowrap"}, - ), - style={"overflowX": "scroll"}, - ), - html.Hr(), - html.H3("Dash Interactive Table"), - html.Div("These same styles can be applied to the dash table"), - section_title("Dash Table - Default Styles"), - dash_table.DataTable( - id="sizing-1", - data=df.to_dict("rows"), - columns=[{"name": i, "id": i} for i in df.columns], - ), - section_title("Dash Table - Padding"), - # ... - section_title("Dash Table - All Column Widths by Percent"), - html.Div( - """ - Here is a table with all columns having width equal to 16.67%, - the Region column additionally wraps text. The table will try and respect - the width of each column while allowing for the content to be displayed. - - Changing the browser's viewport width will help understand how the table - allocates space. - """ - ), - dash_table.DataTable( - id="sizing-2", - data=df.to_dict("rows"), - content_style="grow", - columns=[ - {"name": i, "id": i} for i in df.columns - ], - css=[ - {"selector": ".dash-spreadsheet", "rule": "width: 100%"}, - { - "selector": ".dash-cell[data-dash-column=Region]", - "rule": "white-space: normal", - }, - ], - style_data_conditional=[{"width": "16.67%"}] - ), - section_title("Dash Table - Single Column Width by Percent"), - html.Div( - """ - Here is a table with all columns having default (auto) width excepts for the - the Region column that has 50% width and wraps text. The table will try and respect - the width of each column while allowing for the content to be displayed. - - Changing the browser's viewport width will help understand how the table - allocates space. - """ - ), - dash_table.DataTable( - id="sizing-3", - data=df.to_dict("rows"), - content_style="grow", - columns=[ - {"name": i, "id": i } - for i in df.columns - ], - css=[ - {"selector": ".dash-spreadsheet", "rule": "width: 100%"}, - { - "selector": ".dash-cell[data-dash-column=Region]", - "rule": "white-space: normal", - }, - ], - style_data_conditional=[{ "if": { "column_id": "Region" }, "width": "50%" }] - ), - section_title("Dash Table - Underspecified Widths"), - html.Div( - """ - The widths can be under-specified. Here, we're only setting the width for the three - columns in the middle, the rest of the columns are automatically sized to fit the rest of the container. - The columns have a width/minWidth/maxWidth of 100px. - """ - ), - dash_table.DataTable( - id="sizing-4", - data=df.to_dict("rows"), - columns=[ - { - "name": i, - "id": i, - } - for i in df.columns - ], - style_data_conditional=[ - { "if": { "column_id": "Dem" }, "width": "100px", "min_width": "100px", "max_width": "100px" }, - { "if": { "column_id": "Rep" }, "width": "100px", "min_width": "100px", "max_width": "100px" }, - { "if": { "column_id": "Ind" }, "width": "100px", "min_width": "100px", "max_width": "100px" } - ] - ), - section_title("Dash Table - Widths that are smaller than the content"), - html.Div( - """ - Width for all columns is set to 100px. Columns whose content is smaller than the defined size will respect it. - Columns whose content is bigger than defined will grow to accomodate content. Region column wraps to show behavior - in that case - """ - ), - dash_table.DataTable( - id="sizing-5", - data=df.to_dict("rows"), - columns=[ - {"name": i, "id": i } for i in df.columns - ], - css=[ - { - "selector": ".dash-cell[data-dash-column=Region]", - "rule": "white-space: normal", - } - ], - style_data_conditional=[{ "width": "100px" }] - ), - section_title( - "Dash Table - Widths that are smaller than the content (forced)" - ), - html.Div( - """ - Width/minWidth/maxWidth for all columns is set to 100px. Columns whose content is smaller than the defined size will respect it. - Columns whose content is bigger than defined will respect it too. Region column wraps to show behavior - in that case - """ - ), - dash_table.DataTable( - id="sizing-6", - data=df.to_dict("rows"), - columns=[ - { - "name": i, - "id": i, - } - for i in df.columns - ], - css=[ - { - "selector": ".dash-cell[data-dash-column=Region]", - "rule": "white-space: normal", - } - ], - content_style="fit", - style_data_conditional=[ - { "width": "100px", "min_width": "100px", "max_width": "100px" } - ] - ), - section_title("Dash Table - Content with Ellipses"), - # ... - section_title("Dash Table - Vertical Scrolling"), - # ... - section_title("Dash Table - Vertical Scrolling with Max Height"), - # ... - section_title("Dash Table - Vertical Scrolling with Height"), - # ... - section_title("Dash Table - Horizontal Scrolling"), - # ... - section_title("Dash Table - Two Columns, 100% Min-Width"), - # ... - section_title("Dash Table - Long Columns, 100% Min-Width"), - # ... - section_title("Dash Table - Alignment"), - # ... - ], - ), - ] diff --git a/tests/dash/app_styling.py b/tests/dash/app_styling.py deleted file mode 100644 index 9f9226817..000000000 --- a/tests/dash/app_styling.py +++ /dev/null @@ -1,454 +0,0 @@ -from collections import OrderedDict -from dash.dependencies import Input, Output -import dash_core_components as dcc -import dash_html_components as html -import pandas as pd -from textwrap import dedent - -import dash_table -from index import app -from .utils import html_table, section_title - - -def layout(): - data = OrderedDict( - [ - ("Date", ["2015-01-01", "2015-10-24", "2016-05-10"] * 2), - ("Region", ["Montreal", "Vermont", "New York City"] * 2), - ("Temperature", [1, -20, 3.512] * 2), - ("Humidity", [10, 20, 30] * 2), - ("Pressure", [2, 10924, 3912] * 2), - ] - ) - - df = pd.DataFrame(data) - - return html.Div( - style={"marginLeft": "auto", "marginRight": "auto", "width": "80%"}, - children=[ - html.H1("[WIP] - Styling the Table"), - - section_title("HTML Table - Gridded"), - - html.Div(""" - By default, the Dash table has grey headers and borders - around each cell. It resembles a spreadsheet with clearly defined - headers - """), - - html_table( - df, - cell_style={'border': 'thin lightgrey solid'}, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - header_style={'backgroundColor': 'rgb(235, 235, 235)'} - ), - - html.Hr(), - - section_title("HTML Table - Column Alignment and Column Fonts"), - dcc.Markdown(dedent( - """ - When displaying numerical data, it's a good practice to use - monospaced fonts, to right-align the data, and to provide the same - number of decimals throughout the column. - - Note that it's not possible to modify the number of decimal places - in css. `dash-table` will provide formatting options in the future, - until then you'll have to modify your data before displaying it. - - For textual data, left-aligning the data is usually easier to read. - - In both cases, the column headers should have the same alignment - as the cell content. - """ - )), - html_table( - df, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - cell_style={"paddingLeft": 5, "paddingRight": 5, 'border': 'thin lightgrey solid'}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - header_style={'backgroundColor': 'rgb(235, 235, 235)'} - ), - - html.Hr(), - - section_title('HTML Table - Styling the Table as a List'), - - dcc.Markdown(dedent(''' - The gridded view is a good default view for an editable table, like a spreadsheet. - If your table isn't editable, then in many cases it can look cleaner without the - horizontal or vertical grid lines. - ''')), - - html_table( - df, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - cell_style={"paddingLeft": 5, "paddingRight": 5}, - header_style={'backgroundColor': 'rgb(235, 235, 235)', 'borderTop': 'thin lightgrey solid', 'borderBottom': 'thin lightgrey solid'}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - ), - - html.Hr(), - - section_title('HTML Table - Row Padding'), - - dcc.Markdown(dedent(''' - By default, the gridded view is pretty tight. You can add some top and bottom row padding to - the rows to give your data a little bit more room to breathe. - ''')), - - html_table( - df, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - cell_style={"paddingLeft": 5, "paddingRight": 5}, - header_style={'backgroundColor': 'rgb(235, 235, 235)', 'borderTop': 'thin lightgrey solid', 'borderBottom': 'thin lightgrey solid'}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - row_style={'paddingTop': 10, 'paddingBottom': 10} - ), - - html.Hr(), - - section_title('HTML Table - List Style with Minimal Headers'), - - dcc.Markdown(dedent(''' - In some contexts, the grey background can look a little heavy. - You can lighten this up by giving it a white background and - a thicker bottom border. - ''')), - - html_table( - df, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - cell_style={"paddingLeft": 5, "paddingRight": 5}, - header_style={'borderBottom': '2px lightgrey solid'}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - row_style={'paddingTop': 10, 'paddingBottom': 10} - ), - - html.Hr(), - - section_title('HTML Table - List Style with Understated Headers'), - - dcc.Markdown(dedent(''' - When the data is obvious, sometimes you can de-emphasize the headers - as well, by giving them a lighter color than the cell text. - ''')), - - html_table( - df, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - cell_style={"paddingLeft": 5, "paddingRight": 5}, - header_style={'color': 'rgb(100, 100, 100)'}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - row_style={'paddingTop': 10, 'paddingBottom': 10} - ), - - html.Hr(), - - section_title('HTML Table - Striped Rows'), - - dcc.Markdown(dedent(''' - When you're viewing datasets where you need to compare values within individual rows, it - can sometimes be helpful to give the rows alternating background colors. - We recommend using colors that are faded so as to not attract too much attention to the stripes. - ''')), - - html_table( - df, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - cell_style={"paddingLeft": 5, "paddingRight": 5}, - header_style={'backgroundColor': 'rgb(235, 235, 235)', 'borderTop': 'thin lightgrey solid', 'borderBottom': 'thin lightgrey solid'}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - row_style={'paddingTop': 10, 'paddingBottom': 10}, - odd_row_style={'backgroundColor': 'rgb(248, 248, 248)'} - ), - - section_title('HTML Table - Dark Theme with Cells'), - - dcc.Markdown(dedent( - """ - You have full control over all of the elements in the table. - If you are viewing your table in an app with a dark background, - you can provide inverted background and font colors. - """ - )), - - html_table( - df, - table_style={ - 'backgroundColor': 'rgb(50, 50, 50)', - 'color': 'white', - 'width': '100%' - }, - cell_style={'border': 'thin white solid'}, - header_style={'backgroundColor': 'rgb(30, 30, 30)'}, - row_style={'padding': 10}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - ), - - section_title('HTML Table - Dark Theme with Rows'), - - html_table( - df, - table_style={ - 'backgroundColor': 'rgb(50, 50, 50)', - 'color': 'white', - 'width': '100%' - }, - row_style={ - 'borderTop': 'thin white solid', - 'borderBottom': 'thin white solid', - 'padding': 10 - }, - header_style={'backgroundColor': 'rgb(30, 30, 30)'}, - header_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - cell_style_by_column={ - "Temperature": {"textAlign": "right", "fontFamily": "monospaced"}, - "Humidity": {"textAlign": "right", "fontFamily": "monospaced"}, - "Pressure": {"textAlign": "right", "fontFamily": "monospaced"}, - }, - ), - - section_title('HTML Table - Highlighting Certain Rows'), - - dcc.Markdown(dedent(''' - You can draw attention to certain rows by providing a unique - background color, bold text, or colored text. - ''')), - - html_table( - df, - cell_style={'border': 'thin lightgrey solid', 'color': 'rgb(60, 60, 60)'}, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - header_style={'backgroundColor': 'rgb(235, 235, 235)'}, - row_style_by_index={ - 4: { - 'backgroundColor': 'yellow', - } - } - ), - - section_title('HTML Table - Highlighting Certain Columns'), - - dcc.Markdown(dedent(''' - Similarly, certain columns can be highlighted. - ''')), - - html_table( - df, - cell_style={'border': 'thin lightgrey solid', 'color': 'rgb(60, 60, 60)'}, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - header_style={'backgroundColor': 'rgb(235, 235, 235)'}, - cell_style_by_column={ - "Temperature": { - "backgroundColor": "yellow" - }, - } - ), - - section_title('HTML Table - Highlighting Certain Cells'), - - dcc.Markdown(dedent(''' - You can also highlight certain cells. For example, you may want to - highlight certain cells that exceed a threshold or that match - a filter elsewhere in the app. - ''')), - - html_table( - df, - cell_style={'border': 'thin lightgrey solid', 'color': 'rgb(60, 60, 60)'}, - table_style={'width': '100%'}, - column_style={'width': '20%', 'paddingLeft': 20}, - header_style={'backgroundColor': 'rgb(235, 235, 235)'}, - conditional_cell_style=lambda cell, column: ( - {'backgroundColor': 'yellow'} - if ( - (column == 'Region' and cell == 'Montreal') - or - (cell == 20) - ) else {} - ) - ), - - html.Hr(), - - section_title('Dash Table - Styling the Table as a List'), - # ... - - section_title('Dash Table - Row Padding'), - dash_table.DataTable( - id="styling-2", - data=df.to_dict("rows"), - columns=[ - {"name": i, "id": i} for i in df.columns - ], - style_data_conditional=[{ "padding_bottom": 5, "padding_top": 5}] - ), - - section_title('Dash Table - List Style with Minimal Headers'), - # ... - - section_title('Dash Table - List Style with Understated Headers'), - # ... - - section_title('Dash Table - Striped Rows'), - # ... - - section_title('Dash Table - Dark Theme with Cells'), - dash_table.DataTable( - id="styling-6", - data=df.to_dict("rows"), - columns=[ - {"name": i, "id": i} for i in df.columns - ], - content_style="grow", - style_table={ - "width": "100%" - }, - style_data_conditional=[{ - "background_color": "rgb(50, 50, 50)", - "color": "white", - "font_family": "arial" - }, { - "if": { "column_id": "Humidity" }, - "font_family": "monospace", - "padding_left": 20, - "text_align": "left" - }, { - "if": { "column_id": "Pressure" }, - "font_family": "monospace", - "padding_left": 20, - "text_align": "left" - }, { - "if": { "column_id": "Temperature" }, - "font_family": "monospace", - "padding_left": 20, - "text_align": "left" - }] - ), - - section_title('Dash Table - Dark Theme with Rows'), - # ... - - section_title('Dash Table - Highlighting Certain Rows'), - # ... - - section_title('Dash Table - Highlighting Certain Columns'), - dash_table.DataTable( - id="styling-9", - data=df.to_dict("rows"), - columns=[ - {"name": i, "id": i} for i in df.columns - ], - content_style="grow", - style_table={ - "width": "100%" - }, - style_data_conditional=[{ - "color": "rgb(60, 60, 60)", - "padding_left": 20, - "text-align": "left", - "width": "20%" - }, { - "if": { "column_id": "Temperature" }, - "background_color": "yellow" - }] - ), - - section_title('Dash Table - Highlighting Certain Cells'), - dash_table.DataTable( - id="styling-10", - data=df.to_dict("rows"), - columns=[ - {"name": i, "id": i} for i in df.columns - ], - content_style="grow", - style_table={ - "width": "100%" - }, - style_data_conditional=[{ - "if": { "column_id": "Region", "filter": "Region eq str(Montreal)" }, - "background_color": "yellow" - }, { - "if": { "column_id": "Humidity", "filter": "Humidity eq num(20)" }, - "background_color": "yellow" - }] - ) - ], - ) diff --git a/tests/dash/app_virtualization.py b/tests/dash/app_virtualization.py deleted file mode 100644 index bc05b70f4..000000000 --- a/tests/dash/app_virtualization.py +++ /dev/null @@ -1,27 +0,0 @@ -import dash_table -import pandas as pd - -# Subset of https://www.irs.gov/pub/irs-soi/16zpallagi.csv -df = pd.read_csv("./datasets/16zpallagi-25cols-100klines.csv") -data = df.to_dict("rows") - -def layout(): - return dash_table.DataTable( - columns=[{"name": i, "id": i} for i in df.columns], - data=data, - pagination_mode=False, - virtualization=True, - editable=True, - n_fixed_rows=1, - style_table={ - "height": 800, - "max_height": 800, - "width": 1300, - "max_width": 1300 - }, - style_data={ - "width": 50, - "max_width": 50, - "min_width": 50 - } - ) diff --git a/tests/dash/test_integration.py b/tests/dash/test_integration.py index a19546cbf..631b4b7d6 100644 --- a/tests/dash/test_integration.py +++ b/tests/dash/test_integration.py @@ -21,7 +21,7 @@ def visit_and_snapshot(href): WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.ID, "waitfor")) ) - time.sleep(2) + time.sleep(10) self.snapshot(href) self.driver.back() diff --git a/tests/visual/percy-storybook/@Types/modules.d.ts b/tests/visual/percy-storybook/@Types/modules.d.ts index 287edccf7..7f343088b 100644 --- a/tests/visual/percy-storybook/@Types/modules.d.ts +++ b/tests/visual/percy-storybook/@Types/modules.d.ts @@ -1,5 +1,11 @@ declare var module: any; +declare module '*.csv' { + const value: any; + + export default value; +} + declare module '@storybook/react' { export const storiesOf: any; export const addDecorator: any; diff --git a/tests/visual/percy-storybook/DashTable.percy.tsx b/tests/visual/percy-storybook/DashTable.percy.tsx index c8e6fa184..a2ac626c1 100644 --- a/tests/visual/percy-storybook/DashTable.percy.tsx +++ b/tests/visual/percy-storybook/DashTable.percy.tsx @@ -1,6 +1,8 @@ +import parser from 'papaparse'; import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; + import random from 'core/math/random'; import DataTable from 'dash-table/dash/DataTable'; import fixtures from './fixtures'; @@ -11,6 +13,8 @@ const setProps = () => { }; const fixtureStories = storiesOf('DashTable/Fixtures', module); fixtures.forEach(fixture => fixtureStories.add(fixture.name, () => ())); +import dataset from './../../../datasets/gapminder.csv'; + storiesOf('DashTable/Without Data', module) .add('with 1 column', () => ()); storiesOf('DashTable/With Data', module) + .add('simple', () => { + const result = parser.parse(dataset, { delimiter: ',', header: true }); + + return ( ({ name: i, id: i }), + result.meta.fields) + } + />); + }) .add('with 3 columns and 3 rows, not actionable', () => ( { }; + +const data = [ + { climate: 'Sunny', temperature: 13, city: 'NYC' }, + { climate: 'Snowy', temperature: 43, city: 'Montreal' }, + { climate: 'Sunny', temperature: 50, city: 'Miami' }, + { climate: 'Rainy', temperature: 30, city: 'NYC' } +]; + +const data2 = [ + { City: 'NYC', Neighborhood: 'Brooklyn', 'Temperature (F)': 70 }, + { City: 'Montreal', Neighborhood: 'Mile End', 'Temperature (F)': 60 }, + { City: 'Los Angeles', Neighborhood: 'Venice', 'Temperature (F)': 90 } +]; + +const columns = R.map( + i => ({ name: i, id: i, presentation: 'dropdown' }), + ['climate', 'temperature', 'city'] +); + +const columns2 = R.map( + i => ({ name: i, id: i, presentation: 'dropdown' }), + ['City', 'Neighborhood', 'Temperature (F)'] +); + +storiesOf('DashTable/Dropdown', module) + .add('dropdown by column', () => ( ({ label: i, value: i }), + ['Sunny', 'Snowy', 'Rainy'] + ) + }, { + id: 'city', + dropdown: R.map( + i => ({ label: i, value: i }), + ['NYC', 'Montreal', 'Miami'] + ) + }]} + />)) + .add('dropdown by filtering', () => ( ({ label: i, value: i }), + ['Brooklyn', 'Queens', 'Staten Island'] + ) + }, { + condition: '{City} eq "Montreal"', + dropdown: R.map( + i => ({ label: i, value: i }), + ['Mile End', 'Plateau', 'Hochelaga'] + ) + }, { + condition: '{City} eq "Los Angeles"', + dropdown: R.map( + i => ({ label: i, value: i }), + ['Venice', 'Hollywood', 'Los Feliz'] + ) + }] + }]} + />)).add('dropdown by cell (deprecated)', () => ( ({ label: i, value: i }), + ['Brooklyn', 'Queens', 'Staten Island'] + ) + }, { + options: R.map( + i => ({ label: i, value: i }), + ['Mile End', 'Plateau', 'Hochelaga'] + ) + }, { + options: R.map( + i => ({ label: i, value: i }), + ['Venice', 'Hollywood', 'Los Feliz'] + ) + }] + }} + />)); \ No newline at end of file diff --git a/tests/visual/percy-storybook/Header.percy.tsx b/tests/visual/percy-storybook/Header.percy.tsx new file mode 100644 index 000000000..e74780e43 --- /dev/null +++ b/tests/visual/percy-storybook/Header.percy.tsx @@ -0,0 +1,30 @@ +import * as R from 'ramda'; +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import DataTable from 'dash-table/dash/DataTable'; + +const setProps = () => { }; + +storiesOf('DashTable/Headers', module) + .add('multi header', () => ( ({ + year: i, + montreal: i * 10, + toronto: i * 100, + ottawa: i * -1, + vancouver: i * -10, + temp: i * -100, + humidity: i * 0.1 + }), R.range(0, 100))} + columns={[ + { name: ['Year', ''], id: 'year' }, + { name: ['City', 'Montreal'], id: 'montreal' }, + { name: ['City', 'Toronto'], id: 'toronto' }, + { name: ['City', 'Ottawa'], id: 'ottawa', hidden: true }, + { name: ['City', 'Vancouver'], id: 'vancouver' }, + { name: ['Climate', 'Temperature'], id: 'temp' }, + { name: ['Climate', 'Humidity'], id: 'humidity' } + ]} + />)); \ No newline at end of file diff --git a/tests/visual/percy-storybook/Sizing.percy.tsx b/tests/visual/percy-storybook/Sizing.percy.tsx new file mode 100644 index 000000000..338ced890 --- /dev/null +++ b/tests/visual/percy-storybook/Sizing.percy.tsx @@ -0,0 +1,83 @@ +import * as R from 'ramda'; +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import DataTable from 'dash-table/dash/DataTable'; + +const setProps = () => { }; + +const data = [ + { Date: 'July 12th, 2013 - July 25th, 2013', Rep: 1, Dem: 10, Ind: 2, Region: 'Northern New York State to the Southern Appalachian Mountains', 'Election Polling Organization': 'The New York Times' }, + { Date: 'July 12th, 2013 - August 25th, 2013', Rep: -20, Dem: 20, Ind: 10924, Region: 'Canada', 'Election Polling Organization': 'Pew Research' }, + { Date: 'July 12th, 2014 - August 25th, 2014', Rep: 3.512, Dem: 30, Ind: 3912, Region: 'Southern Vermont', 'Election Polling Organization': 'The Washington Post' } +]; + +const columns = R.map( + i => ({ name: i, id: i }), + ['Date', 'Rep', 'Dem', 'Ind', 'Region', 'Election Polling Organization'] +); + +const props = { + setProps, + id: 'table', + data, + columns +}; + +storiesOf('DashTable/Sizing', module) + .add('default styles', () => ()) + .add('padding', () => ()) + .add('single column width by percentage', () => ()) + .add('underspecified widths', () => ()) + .add('widths smaller than content', () => ()) + .add('widths smaller than content (forced)', () => ()); \ No newline at end of file diff --git a/tests/visual/percy-storybook/Style.percy.tsx b/tests/visual/percy-storybook/Style.percy.tsx index b79e9c7cf..527dd3abe 100644 --- a/tests/visual/percy-storybook/Style.percy.tsx +++ b/tests/visual/percy-storybook/Style.percy.tsx @@ -1,14 +1,27 @@ +import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import DataTable from 'dash-table/dash/DataTable'; -import fixtures from './fixtures'; import { ColumnType } from 'dash-table/components/Table/props'; const setProps = () => { }; -// Legacy: Tests previously run in Python -const fixtureStories = storiesOf('DashTable/Fixtures', module); -fixtures.forEach(fixture => fixtureStories.add(fixture.name, () => ())); +const date = ['2015-01-01', '2015-10-24', '2016-05-10']; +const region = ['Montreal', 'Vermont', 'New York City']; +const temperature = [1, -20, 3.512]; +const humidity = [10, 20, 30]; +const pressure = [2, 10924, 3912]; + +const data: any[] = []; +for (let i = 0; i < 6; ++i) { + data.push({ + Date: date[i % date.length], + Region: region[i % region.length], + Temperature: temperature[i % temperature.length], + Humidity: humidity[i % humidity.length], + Pressure: pressure[i % pressure.length] + }); +} storiesOf('DashTable/Style type condition', module) .add('with 1 column', () => ()) + .add('row padding', () => ( ({ name: i, id: i }), + R.keysIn(data[0])) + } + style_data_conditional={[{ + padding_bottom: 5, + padding_top: 5 + }]} + />)) + .add('dark theme with cells', () => ( ({ name: i, id: i }), + R.keysIn(data[0])) + } + content_style='grow' + style_table={{ + width: '100%' + }} + style_data_conditional={[{ + background_color: 'rgb(50, 50, 50)', + color: 'white', + font_family: 'arial' + }, { + if: { column_id: 'Humidity' }, + font_family: 'monospace', + padding_left: 20, + text_align: 'left' + }, { + if: { column_id: 'Pressure' }, + font_family: 'monospace', + padding_left: 20, + text_align: 'left' + }, { + if: { column_id: 'Temperature' }, + font_family: 'monospace', + padding_left: 20, + text_align: 'left' + }]} + />)) + .add('highlight columns', () => ( ({ name: i, id: i }), + R.keysIn(data[0])) + } + content_style='grow' + style_table={{ + width: '100%' + }} + style_data_conditional={[{ + color: 'rgb(60, 60, 60)', + padding_left: 20, + 'text-align': 'left', + width: '20%' + }, { + if: { column_id: 'Temperature' }, + background_color: 'yellow' + }]} + />)) + .add('highlight cells', () => ( ({ name: i, id: i }), + R.keysIn(data[0])) + } + content_style='grow' + style_table={{ + width: '100%' + }} + style_data_conditional={[{ + if: { column_id: 'Region', filter: '{Region} eq Montreal' }, + background_color: 'yellow' + }, { + if: { column_id: 'Humidity', filter: '{Humidity} eq 20' }, + background_color: 'yellow' + }]} + />)); \ No newline at end of file diff --git a/tests/visual/percy-storybook/Virtualization.percy.tsx b/tests/visual/percy-storybook/Virtualization.percy.tsx new file mode 100644 index 000000000..d197da826 --- /dev/null +++ b/tests/visual/percy-storybook/Virtualization.percy.tsx @@ -0,0 +1,36 @@ +import parser from 'papaparse'; +import * as R from 'ramda'; +import React from 'react'; +import { storiesOf } from '@storybook/react'; + +import dataset from './../../../datasets/16zpallagi-25cols-100klines.csv'; + +import DataTable from 'dash-table/dash/DataTable'; + +const setProps = () => { }; + +const { data, meta } = parser.parse(dataset, { delimiter: ',', header: true }); +const columns = R.map(i => ({ name: i, id: i }), meta.fields); + +storiesOf('DashTable/Virtualization', module) + .add('default', () => ()); \ No newline at end of file diff --git a/tests/visual/percy-storybook/Width.empty.percy.tsx b/tests/visual/percy-storybook/Width.empty.percy.tsx new file mode 100644 index 000000000..2b59a5fc5 --- /dev/null +++ b/tests/visual/percy-storybook/Width.empty.percy.tsx @@ -0,0 +1,61 @@ +import * as R from 'ramda'; +import React from 'react'; +import { storiesOf } from '@storybook/react'; + +import random from 'core/math/random'; +import DataTable from 'dash-table/dash/DataTable'; +import { FilteringType } from 'dash-table/components/Table/props'; + +const setProps = () => { }; + +const columns = ['a', 'b', 'c']; + +const data = (() => { + const r = random(1); + + return R.range(0, 5).map(() => ( + ['a', 'b', 'c'].reduce((obj: any, key) => { + obj[key] = Math.floor(r() * 1000); + return obj; + }, {}) + )); +})(); + +const baseProps = { + setProps, + id: 'table', + content_style: 'fit', + data, + filtering: 'fe', + filtering_type: FilteringType.Basic, + style_cell: { width: 100, max_width: 100, min_width: 100 } +}; + +const props = Object.assign({}, baseProps, { + columns: columns.map((id => ({ id: id, name: id.toUpperCase() }))) +}); + +storiesOf('DashTable/Empty', module) + .add('with column filters -- no query', () => ()) + .add('with column filters -- invalid query', () => ()) + .add('with column filters -- single query', () => ()) + .add('with column filters -- multi query', () => ()) + .add('with column filters -- multi query, no data', () => ()); \ No newline at end of file