From f5783de7b87ce8b24257c66d5d21c241b3bedffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Tue, 27 Apr 2021 14:43:11 -0400 Subject: [PATCH 01/17] sensitive/insensitive relational operators through flags --- .eslintrc => .eslintrc.js | 6 +- src/core/syntax-tree/lexer.ts | 7 +- src/core/syntax-tree/lexicon.ts | 1 + src/dash-table/components/Table/props.ts | 8 + src/dash-table/dash/DataTable.js | 22 ++ src/dash-table/dash/Sanitizer.ts | 10 +- src/dash-table/derived/cell/resolveFlag.ts | 2 +- .../syntax-tree/SingleColumnSyntaxTree.ts | 43 ++- .../syntax-tree/lexeme/relational.ts | 82 ++++-- tests/js-unit/dash_table_queries_test.ts | 59 ++++ tests/js-unit/query_syntactic_tree_test.ts | 274 ++++++++++++++++++ 11 files changed, 483 insertions(+), 31 deletions(-) rename .eslintrc => .eslintrc.js (94%) diff --git a/.eslintrc b/.eslintrc.js similarity index 94% rename from .eslintrc rename to .eslintrc.js index 684b718ae..0bdc4a10a 100644 --- a/.eslintrc +++ b/.eslintrc.js @@ -1,4 +1,6 @@ -{ +var path = require('path'); + +module.exports = { "extends": [ "plugin:@typescript-eslint/recommended", "prettier" @@ -7,7 +9,7 @@ "@typescript-eslint" ], "parserOptions": { - "project": "./tsconfig.lint.json" + "project": path.join(__dirname, "tsconfig.lint.json"), }, "rules": { "arrow-parens": [ diff --git a/src/core/syntax-tree/lexer.ts b/src/core/syntax-tree/lexer.ts index 56bcaa41a..5703edf75 100644 --- a/src/core/syntax-tree/lexer.ts +++ b/src/core/syntax-tree/lexer.ts @@ -10,6 +10,7 @@ export interface ILexerResult { export interface ILexemeResult { lexeme: ILexeme; + flags?: string; value?: string; } @@ -37,8 +38,10 @@ export default function lexer(lexicon: Lexicon, query: string): ILexerResult { return {lexemes: result, valid: false, error: query}; } - const value = (query.match(next.regexp) || [])[next.regexpMatch || 0]; - result.push({lexeme: next, value}); + const match = query.match(next.regexp) ?? []; + const value = match[next.regexpMatch || 0]; + const flags = match[next.regexpFlags || -1]; + result.push({lexeme: next, flags, value}); query = query.substring(value.length); } diff --git a/src/core/syntax-tree/lexicon.ts b/src/core/syntax-tree/lexicon.ts index b5e2c633e..4770efca8 100644 --- a/src/core/syntax-tree/lexicon.ts +++ b/src/core/syntax-tree/lexicon.ts @@ -19,6 +19,7 @@ export interface IUnboundedLexeme { nesting?: number; priority?: number; regexp: RegExp; + regexpFlags?: number; regexpMatch?: number; syntaxer?: (lexs: any[], pivot: any, pivotIndex: number) => any; transform?: (value: any) => any; diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index ebe56ec7b..e0a6eeec7 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -38,6 +38,11 @@ export enum ExportHeaders { Display = 'display' } +export enum FilterCase { + Insensitive = 'insensitive', + Sensitive = 'sensitive' +} + export enum SortMode { Single = 'single', Multi = 'multi' @@ -193,6 +198,7 @@ export interface IBaseColumn { clearable?: boolean | boolean[] | 'first' | 'last'; deletable?: boolean | boolean[] | 'first' | 'last'; editable: boolean; + filter_option: FilterCase; hideable?: boolean | boolean[] | 'first' | 'last'; renamable?: boolean | boolean[] | 'first' | 'last'; selectable?: boolean | boolean[] | 'first' | 'last'; @@ -327,6 +333,7 @@ export interface IProps { data?: Data; editable?: boolean; fill_width?: boolean; + filter_option?: FilterCase; filter_query?: string; filter_action?: TableAction | IFilterAction; hidden_columns?: string[]; @@ -381,6 +388,7 @@ interface IDefaultProps { export_format: ExportFormat; export_headers: ExportHeaders; fill_width: boolean; + filter_option: FilterCase; filter_query: string; filter_action: TableAction; fixed_columns: Fixed; diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 2b7b5db25..ce5537aae 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -181,6 +181,17 @@ export const propTypes = { */ editable: PropTypes.bool, + /** + * There are two `filter_option` props in the table. + * This is the column-level filter_option prop and there is + * also the table-level `filter_option` prop. + * These props determine whether the applicable filter relational + * operators will default to `sensitive` or `insensitive` comparison. + * If the column-level `filter_option` prop is set it overrides + * the table-level `filter_option` prop for that column. + */ + filter_option: PropTypes.oneOf(['sensitive', 'insensitive']), + /** * If true, the user can hide the column by clicking on the `hide` * action button on the column. If there are multiple header rows, true @@ -1142,6 +1153,17 @@ export const propTypes = { }) ]), + /** + * There are two `filter_option` props in the table. + * This is the table-level filter_option prop and there is + * also the column-level `filter_option` prop. + * These props determine whether the applicable filter relational + * operators will default to `sensitive` or `insensitive` comparison. + * If the column-level `filter_option` prop is set it overrides + * the table-level `filter_option` prop for that column. + */ + filter_option: PropTypes.oneOf(['sensitive', 'insensitive']), + /** * The `sort_action` property enables data to be * sorted on a per-column basis. diff --git a/src/dash-table/dash/Sanitizer.ts b/src/dash-table/dash/Sanitizer.ts index 5aaef28c3..8852fc8de 100644 --- a/src/dash-table/dash/Sanitizer.ts +++ b/src/dash-table/dash/Sanitizer.ts @@ -16,7 +16,8 @@ import { ExportHeaders, IFilterAction, FilterLogicalOperator, - SelectedCells + SelectedCells, + FilterCase } from 'dash-table/components/Table/props'; import headerRows from 'dash-table/derived/header/headerRows'; import resolveFlag from 'dash-table/derived/cell/resolveFlag'; @@ -63,11 +64,13 @@ const applyDefaultsToColumns = ( defaultLocale: INumberLocale, defaultSort: SortAsNull, columns: Columns, - editable: boolean + editable: boolean, + filterCase: FilterCase ) => R.map(column => { const c = R.clone(column); c.editable = resolveFlag(editable, column.editable); + c.filter_option = resolveFlag(filterCase, column.filter_option); c.sort_as_null = c.sort_as_null || defaultSort; if (c.type === ColumnType.Numeric && c.format) { @@ -105,7 +108,8 @@ export default class Sanitizer { locale_format, props.sort_as_null, props.columns, - props.editable + props.editable, + props.filter_option ) : []; const data = props.data ?? []; diff --git a/src/dash-table/derived/cell/resolveFlag.ts b/src/dash-table/derived/cell/resolveFlag.ts index a60717ce3..699e8f856 100644 --- a/src/dash-table/derived/cell/resolveFlag.ts +++ b/src/dash-table/derived/cell/resolveFlag.ts @@ -1,2 +1,2 @@ -export default (tableFlag: boolean, columnFlag: boolean | undefined): boolean => +export default (tableFlag: T, columnFlag: T | undefined): T => columnFlag === undefined ? tableFlag : columnFlag; diff --git a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts index a25c34d7e..148ab79dc 100644 --- a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts +++ b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts @@ -1,9 +1,15 @@ +import * as R from 'ramda'; + import {RequiredPluck, OptionalPluck} from 'core/type'; import SyntaxTree from 'core/syntax-tree'; import {ILexemeResult, ILexerResult} from 'core/syntax-tree/lexer'; import {LexemeType, boundLexeme} from 'core/syntax-tree/lexicon'; -import {ColumnType, IColumn} from 'dash-table/components/Table/props'; +import { + ColumnType, + FilterCase, + IColumn +} from 'dash-table/components/Table/props'; import {fieldExpression} from './lexeme/expression'; import { @@ -53,12 +59,44 @@ function isUnary(lexemes: ILexemeResult[]) { ); } +function isUnflaggedRelational(lexemes: ILexemeResult[]): boolean { + const [op] = lexemes; + return ( + Boolean(lexemes.length) && + op.lexeme.type === LexemeType.RelationalOperator && + !R.isNil(op.lexeme.regexpFlags) && + !Boolean(op.flags?.length) + ); +} + function modifyLex(config: SingleColumnConfig, res: ILexerResult) { if (!res.valid) { return res; } - if (isBinary(res.lexemes) || isUnary(res.lexemes)) { + if (isUnflaggedRelational(res.lexemes)) { + const {filter_option, id} = config; + const [op, rhs] = res.lexemes; + + const flags = R.isNil(filter_option) + ? '' + : filter_option === FilterCase.Insensitive + ? 'i' + : 's'; + + res.lexemes = [ + { + lexeme: boundLexeme(fieldExpression), + value: `{${id}}` + }, + { + ...op, + flags, + value: `${flags}${op.lexeme.subType}` + }, + rhs + ]; + } else if (isBinary(res.lexemes) || isUnary(res.lexemes)) { res.lexemes = [ {lexeme: boundLexeme(fieldExpression), value: `{${config.id}}`}, ...res.lexemes @@ -75,6 +113,7 @@ function modifyLex(config: SingleColumnConfig, res: ILexerResult) { } export type SingleColumnConfig = RequiredPluck & + OptionalPluck & OptionalPluck; export default class SingleColumnSyntaxTree extends SyntaxTree { diff --git a/src/dash-table/syntax-tree/lexeme/relational.ts b/src/dash-table/syntax-tree/lexeme/relational.ts index 3701a3490..3e5765c40 100644 --- a/src/dash-table/syntax-tree/lexeme/relational.ts +++ b/src/dash-table/syntax-tree/lexeme/relational.ts @@ -7,7 +7,7 @@ import {ISyntaxTree} from 'core/syntax-tree/syntaxer'; import {normalizeDate} from 'dash-table/type/date'; import {IDateValidation} from 'dash-table/components/Table/props'; -function evaluator(target: any, tree: ISyntaxTree): [any, any] { +function evaluator(target: any, tree: ISyntaxTree): [any, any, string] { Logger.trace('evaluate -> relational', target, tree); const t = tree as any; @@ -16,7 +16,7 @@ function evaluator(target: any, tree: ISyntaxTree): [any, any] { const expValue = t.right.lexeme.resolve(target, t.right); Logger.trace(`opValue: ${opValue}, expValue: ${expValue}`); - return [opValue, expValue]; + return [opValue, expValue, tree.value]; } function relationalSyntaxer([left, lexeme, right]: any[]) { @@ -44,17 +44,41 @@ const LEXEME_BASE = { type: LexemeType.RelationalOperator }; +const containsEval = (lhs: any, rhs: any, relOp: string): boolean => + relOp[0] == 'i' + ? lhs.toString().toUpperCase().indexOf(rhs.toString().toUpperCase()) !== + -1 + : lhs.toString().indexOf(rhs.toString()) !== -1; + +const equalEval = (lhs: any, rhs: any, relOp: string): boolean => + isNumeric(lhs) && isNumeric(rhs) + ? +lhs === +rhs + : relOp[0] == 'i' + ? lhs.toString().toUpperCase() === rhs.toString().toUpperCase() + : lhs === rhs; + +const fnEval = ( + fn: (lhs: any, rhs: any) => boolean, + lhs: any, + rhs: any, + relOp: string +): boolean => + relOp[0] == 'i' + ? fn(lhs.toString().toUpperCase(), rhs.toString().toUpperCase()) + : fn(lhs, rhs); + export const contains: IUnboundedLexeme = R.merge( { evaluate: relationalEvaluator( - ([op, exp]) => - !R.isNil(exp) && - !R.isNil(op) && - (R.type(exp) === 'String' || R.type(op) === 'String') && - op.toString().indexOf(exp.toString()) !== -1 + ([lhs, rhs, relOp]) => + !R.isNil(rhs) && + !R.isNil(lhs) && + (R.type(rhs) === 'String' || R.type(lhs) === 'String') && + containsEval(lhs, rhs, relOp) ), subType: RelationalOperator.Contains, - regexp: /^((contains)(?=\s|$))/i, + regexp: /^((i|s)?contains)(?=\s|$)/i, + regexpFlags: 2, regexpMatch: 1 }, LEXEME_BASE @@ -62,11 +86,12 @@ export const contains: IUnboundedLexeme = R.merge( export const equal: IUnboundedLexeme = R.merge( { - evaluate: relationalEvaluator(([op, exp]) => - isNumeric(op) && isNumeric(exp) ? +op === +exp : op === exp + evaluate: relationalEvaluator(([lhs, rhs, relOp]) => + equalEval(lhs, rhs, relOp) ), subType: RelationalOperator.Equal, - regexp: /^(=|(eq)(?=\s|$))/i, + regexp: /^((i|s)?(=|(eq)(?=\s|$)))/i, + regexpFlags: 2, regexpMatch: 1 }, LEXEME_BASE @@ -74,9 +99,12 @@ export const equal: IUnboundedLexeme = R.merge( export const greaterOrEqual: IUnboundedLexeme = R.merge( { - evaluate: relationalEvaluator(([op, exp]) => op >= exp), + evaluate: relationalEvaluator(([lhs, rhs, relOp]) => + fnEval((l, r) => l >= r, lhs, rhs, relOp) + ), subType: RelationalOperator.GreaterOrEqual, - regexp: /^(>=|(ge)(?=\s|$))/i, + regexp: /^((i|s)?(>=|(ge)(?=\s|$)))/i, + regexpFlags: 2, regexpMatch: 1 }, LEXEME_BASE @@ -84,9 +112,12 @@ export const greaterOrEqual: IUnboundedLexeme = R.merge( export const greaterThan: IUnboundedLexeme = R.merge( { - evaluate: relationalEvaluator(([op, exp]) => op > exp), + evaluate: relationalEvaluator(([lhs, rhs, relOp]) => + fnEval((l, r) => l > r, lhs, rhs, relOp) + ), subType: RelationalOperator.GreaterThan, - regexp: /^(>|(gt)(?=\s|$))/i, + regexp: /^((i|s)?(>|(gt)(?=\s|$)))/i, + regexpFlags: 2, regexpMatch: 1 }, LEXEME_BASE @@ -121,9 +152,12 @@ export const dateStartsWith: IUnboundedLexeme = R.merge( export const lessOrEqual: IUnboundedLexeme = R.merge( { - evaluate: relationalEvaluator(([op, exp]) => op <= exp), + evaluate: relationalEvaluator(([lhs, rhs, relOp]) => + fnEval((l, r) => l <= r, lhs, rhs, relOp) + ), subType: RelationalOperator.LessOrEqual, - regexp: /^(<=|(le)(?=\s|$))/i, + regexp: /^((i|s)?(<=|(le)(?=\s|$)))/i, + regexpFlags: 2, regexpMatch: 1 }, LEXEME_BASE @@ -131,9 +165,12 @@ export const lessOrEqual: IUnboundedLexeme = R.merge( export const lessThan: IUnboundedLexeme = R.merge( { - evaluate: relationalEvaluator(([op, exp]) => op < exp), + evaluate: relationalEvaluator(([lhs, rhs, relOp]) => + fnEval((l, r) => l < r, lhs, rhs, relOp) + ), subType: RelationalOperator.LessThan, - regexp: /^(<|(lt)(?=\s|$))/i, + regexp: /^((i|s)?(<|(lt)(?=\s|$)))/i, + regexpFlags: 2, regexpMatch: 1 }, LEXEME_BASE @@ -141,9 +178,12 @@ export const lessThan: IUnboundedLexeme = R.merge( export const notEqual: IUnboundedLexeme = R.merge( { - evaluate: relationalEvaluator(([op, exp]) => op !== exp), + evaluate: relationalEvaluator(([lhs, rhs, relOp]) => + fnEval((l, r) => l !== r, lhs, rhs, relOp) + ), subType: RelationalOperator.NotEqual, - regexp: /^(!=|(ne)(?=\s|$))/i, + regexp: /^((i|s)?(!=|(ne)(?=\s|$)))/i, + regexpFlags: 2, regexpMatch: 1 }, LEXEME_BASE diff --git a/tests/js-unit/dash_table_queries_test.ts b/tests/js-unit/dash_table_queries_test.ts index 88799e2b5..9dd6b1354 100644 --- a/tests/js-unit/dash_table_queries_test.ts +++ b/tests/js-unit/dash_table_queries_test.ts @@ -265,6 +265,20 @@ describe('Dash Table Queries', () => { target: {a: '\r\n1\r\n'}, valid: true, evaluate: true + }, + { + name: "compare 'abc' to 'ABC' (insensitive)", + query: `${c.hideOperand ? '' : '{a} '}ieq ABC`, + target: {a: 'abc'}, + valid: true, + evaluate: true + }, + { + name: "compare 'abc' to 'ABC' (sensitive)", + query: `${c.hideOperand ? '' : '{a} '}seq ABC`, + target: {a: 'abc'}, + valid: true, + evaluate: false } ]); }); @@ -280,6 +294,15 @@ describe('Dash Table Queries', () => { valid: true, evaluate: true }, + { + name: 'insensitive compares "abc" to A', + query: `${ + c.hideOperand ? '' : '{a} ' + }icontains A`, + target: {a: 'abc'}, + valid: true, + evaluate: true + }, { name: 'cannot compare 11 to 1', query: `${ @@ -370,6 +393,42 @@ describe('Dash Table Queries', () => { target: {a: 'ab c'}, valid: true, evaluate: true + }, + { + name: "compare 'ab c' to 'B' (insensitive)", + query: `${ + c.hideOperand ? '' : '{a} ' + }icontains B`, + target: {a: 'ab c'}, + valid: true, + evaluate: true + }, + { + name: "compare 'ab c' to 'b' (insensitive)", + query: `${ + c.hideOperand ? '' : '{a} ' + }icontains b`, + target: {a: 'ab c'}, + valid: true, + evaluate: true + }, + { + name: "compare 'ab c' to 'B' (sensitive)", + query: `${ + c.hideOperand ? '' : '{a} ' + }scontains B`, + target: {a: 'ab c'}, + valid: true, + evaluate: false + }, + { + name: "compare 'ab c' to 'b' (sensitive)", + query: `${ + c.hideOperand ? '' : '{a} ' + }scontains b`, + target: {a: 'ab c'}, + valid: true, + evaluate: true } ]); }); diff --git a/tests/js-unit/query_syntactic_tree_test.ts b/tests/js-unit/query_syntactic_tree_test.ts index f867604e5..7cd56705f 100644 --- a/tests/js-unit/query_syntactic_tree_test.ts +++ b/tests/js-unit/query_syntactic_tree_test.ts @@ -646,6 +646,22 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); + it('can do equality (ieq) test', () => { + const tree = new QuerySyntaxTree('{a} ieq "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + }); + + it('can do equality (seq) test', () => { + const tree = new QuerySyntaxTree('{a} seq "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + }); + it('can do equality (=) test', () => { const tree = new QuerySyntaxTree('{a} = "1"'); @@ -656,6 +672,22 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); + it('can do equality (i=) test', () => { + const tree = new QuerySyntaxTree('{a} i= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + }); + + it('can do equality (s=) test', () => { + const tree = new QuerySyntaxTree('{a} s= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + }); + it('can do difference (ne) test', () => { const tree = new QuerySyntaxTree('{a} ne "1"'); @@ -666,6 +698,22 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(true); }); + it('can do difference (ine) test', () => { + const tree = new QuerySyntaxTree('{a} ine "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + }); + + it('can do difference (sne) test', () => { + const tree = new QuerySyntaxTree('{a} sne "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + }); + it('can do difference (!=) test', () => { const tree = new QuerySyntaxTree('{a} != "1"'); @@ -676,6 +724,22 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(true); }); + it('can do difference (i!=) test', () => { + const tree = new QuerySyntaxTree('{a} i!= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + }); + + it('can do difference (s!=) test', () => { + const tree = new QuerySyntaxTree('{a} s!= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + }); + it('can do greater than (gt) test', () => { const tree = new QuerySyntaxTree('{a} gt "1"'); @@ -686,6 +750,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(true); }); + it('can do greater than (igt) test', () => { + const tree = new QuerySyntaxTree('{a} igt "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + + it('can do greater than (sgt) test', () => { + const tree = new QuerySyntaxTree('{a} sgt "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + it('can do greater than (>) test', () => { const tree = new QuerySyntaxTree('{a} > "1"'); @@ -696,6 +784,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(true); }); + it('can do greater than (i>) test', () => { + const tree = new QuerySyntaxTree('{a} i> "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + + it('can do greater than (s>) test', () => { + const tree = new QuerySyntaxTree('{a} s> "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + it('can do less than (lt) test', () => { const tree = new QuerySyntaxTree('{a} lt "1"'); @@ -706,6 +818,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); + it('can do less than (igt) test', () => { + const tree = new QuerySyntaxTree('{a} ilt "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + + it('can do less than (sgt) test', () => { + const tree = new QuerySyntaxTree('{a} slt "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + it('can do less than (<) test', () => { const tree = new QuerySyntaxTree('{a} < "1"'); @@ -716,6 +852,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); + it('can do less than (i<) test', () => { + const tree = new QuerySyntaxTree('{a} i< "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + + it('can do less than (s<) test', () => { + const tree = new QuerySyntaxTree('{a} s< "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(false); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + it('can do greater or equal to (ge) test', () => { const tree = new QuerySyntaxTree('{a} ge "1"'); @@ -726,6 +886,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(true); }); + it('can do greater or equal to (ige) test', () => { + const tree = new QuerySyntaxTree('{a} ige "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + + it('can do greater or equal to (sge) test', () => { + const tree = new QuerySyntaxTree('{a} sge "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + it('can do greater or equal to (>=) test', () => { const tree = new QuerySyntaxTree('{a} >= "1"'); @@ -736,6 +920,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(true); }); + it('can do greater or equal to (i>=) test', () => { + const tree = new QuerySyntaxTree('{a} i>= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + + it('can do greater or equal to (s>=) test', () => { + const tree = new QuerySyntaxTree('{a} s>= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(false); + expect(tree.evaluate({a: 'ABB'})).to.equal(false); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(false); + expect(tree.evaluate({a: 'abd'})).to.equal(true); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + it('can do less or equal to (le) test', () => { const tree = new QuerySyntaxTree('{a} le "1"'); @@ -746,6 +954,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); + it('can do less or equal to (ile) test', () => { + const tree = new QuerySyntaxTree('{a} ile "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + + it('can do less or equal to (sle) test', () => { + const tree = new QuerySyntaxTree('{a} sle "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + it('can do less or equal to (<=) test', () => { const tree = new QuerySyntaxTree('{a} <= "1"'); @@ -756,6 +988,30 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); + it('can do less or equal to (i<=) test', () => { + const tree = new QuerySyntaxTree('{a} i<= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(false); + }); + + it('can do less or equal to (s<=) test', () => { + const tree = new QuerySyntaxTree('{a} s<= "abc"'); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({a: 'abb'})).to.equal(true); + expect(tree.evaluate({a: 'ABB'})).to.equal(true); + expect(tree.evaluate({a: 'abc'})).to.equal(true); + expect(tree.evaluate({a: 'ABC'})).to.equal(true); + expect(tree.evaluate({a: 'abd'})).to.equal(false); + expect(tree.evaluate({a: 'ABD'})).to.equal(true); + }); + it('can do contains (contains) test', () => { const tree = new QuerySyntaxTree('{a} contains v'); @@ -764,6 +1020,24 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate({a: 'abc w'})).to.equal(false); }); + it('can do contains (icontains) test', () => { + const tree = new QuerySyntaxTree('{a} icontains 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); + expect(tree.evaluate({a: 'abc V'})).to.equal(true); + }); + + it('can do contains (scontains) test', () => { + const tree = new QuerySyntaxTree('{a} scontains 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); + expect(tree.evaluate({a: 'abc V'})).to.equal(false); + }); + it('correctly interprets text-based with no spaces as invalid', () => { const tree = new QuerySyntaxTree('{a} le5'); expect(tree.isValid).to.equal(false); From ee42e93e1dcd5e55153e924fb68fe55bcbb0691f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Wed, 28 Apr 2021 17:21:17 -0400 Subject: [PATCH 02/17] sensitive implicit filters --- src/dash-table/components/Filter/Column.tsx | 19 ++++++- .../components/Filter/FilterOptions.tsx | 23 ++++++++ src/dash-table/components/FilterFactory.tsx | 32 +++++++++-- src/dash-table/components/Table/Table.less | 38 +++++++++++-- src/dash-table/components/Table/props.ts | 8 +-- src/dash-table/dash/DataTable.js | 26 ++++----- src/dash-table/dash/Sanitizer.ts | 4 +- src/dash-table/derived/table/index.tsx | 36 +++++++++++-- .../syntax-tree/SingleColumnSyntaxTree.ts | 53 ++++++------------- 9 files changed, 171 insertions(+), 68 deletions(-) create mode 100644 src/dash-table/components/Filter/FilterOptions.tsx diff --git a/src/dash-table/components/Filter/Column.tsx b/src/dash-table/components/Filter/Column.tsx index 1273303a7..99d007dca 100644 --- a/src/dash-table/components/Filter/Column.tsx +++ b/src/dash-table/components/Filter/Column.tsx @@ -2,17 +2,20 @@ import React, {CSSProperties, PureComponent} from 'react'; import IsolatedInput from 'core/components/IsolatedInput'; -import {ColumnId} from 'dash-table/components/Table/props'; +import {ColumnId, FilterCase} from 'dash-table/components/Table/props'; import TableClipboardHelper from 'dash-table/utils/TableClipboardHelper'; +import FilterOptions from 'dash-table/components/Filter/FilterOptions'; type SetFilter = (ev: any) => void; interface IColumnFilterProps { className: string; columnId: ColumnId; + filterOptions: FilterCase; isValid: boolean; setFilter: SetFilter; style?: CSSProperties; + toggleFilterOptions: () => void; value?: string; } @@ -41,7 +44,15 @@ export default class ColumnFilter extends PureComponent< }; render() { - const {className, columnId, isValid, style, value} = this.props; + const { + className, + columnId, + filterOptions, + isValid, + style, + toggleFilterOptions, + value + } = this.props; return ( + ); } diff --git a/src/dash-table/components/Filter/FilterOptions.tsx b/src/dash-table/components/Filter/FilterOptions.tsx new file mode 100644 index 000000000..13b7bb2a6 --- /dev/null +++ b/src/dash-table/components/Filter/FilterOptions.tsx @@ -0,0 +1,23 @@ +import React, {memo} from 'react'; + +import {FilterCase} from 'dash-table/components/Table/props'; + +interface IFilterCaseButtonProps { + filterOptions: FilterCase; + toggleFilterOptions: () => void; +} + +export default memo( + ({filterOptions, toggleFilterOptions}: IFilterCaseButtonProps) => ( + + ) +); diff --git a/src/dash-table/components/FilterFactory.tsx b/src/dash-table/components/FilterFactory.tsx index 0d834d6bc..2e99963c7 100644 --- a/src/dash-table/components/FilterFactory.tsx +++ b/src/dash-table/components/FilterFactory.tsx @@ -13,7 +13,8 @@ import { TableAction, IFilterFactoryProps, SetFilter, - FilterLogicalOperator + FilterLogicalOperator, + FilterCase } from 'dash-table/components/Table/props'; import derivedFilterStyles, { derivedFilterOpStyles @@ -57,13 +58,28 @@ export default class FilterFactory { updateColumnFilter(map, column, operator, value, setFilter); }; + private onToggleChange = ( + column: IColumn, + map: Map, + operator: FilterLogicalOperator, + setFilter: SetFilter, + toggleFilterOptions: (column: IColumn) => IColumn, + value: any + ) => { + const newColumn = toggleFilterOptions(column); + + updateColumnFilter(map, newColumn, operator, value, setFilter); + }; + private filter = memoizerCache<[ColumnId, number]>()( ( column: IColumn, + filterOptions: FilterCase, index: number, map: Map, operator: FilterLogicalOperator, - setFilter: SetFilter + setFilter: SetFilter, + toggleFilterOptions: (column: IColumn) => IColumn ) => { const ast = map.get(column.id.toString()); @@ -72,6 +88,7 @@ export default class FilterFactory { key={`column-${index}`} className={`dash-filter column-${index}`} columnId={column.id} + filterOptions={filterOptions} isValid={!ast || ast.isValid} setFilter={this.onChange.bind( this, @@ -80,6 +97,11 @@ export default class FilterFactory { operator, setFilter )} + // Running into TypeScript binding issues with many parameters.. + // bind with no more than 4 params each time.. sigh.. + toggleFilterOptions={this.onToggleChange + .bind(this, column, map, operator, setFilter) + .bind(this, toggleFilterOptions, ast && ast.query)} value={ast && ast.query} /> ); @@ -99,6 +121,7 @@ export default class FilterFactory { ) { const { filter_action, + filter_options, map, row_deletable, row_selectable, @@ -107,6 +130,7 @@ export default class FilterFactory { style_cell_conditional, style_filter, style_filter_conditional, + toggleFilterOptions, visibleColumns } = this.props; @@ -136,10 +160,12 @@ export default class FilterFactory { (column, index) => { return this.filter.get(column.id, index)( column, + filter_options, index, map, filter_action.operator, - setFilter + setFilter, + toggleFilterOptions ); }, visibleColumns diff --git a/src/dash-table/components/Table/Table.less b/src/dash-table/components/Table/Table.less index a303dbb0c..8d4d13888 100644 --- a/src/dash-table/components/Table/Table.less +++ b/src/dash-table/components/Table/Table.less @@ -481,6 +481,21 @@ top: 0; height: 100%; width: 100%; + + &.dash-filter--case { + position: relative; + left: auto; + top: auto; + width: auto; + height: 16px; + line-height: 0px; + padding: 1px; + } + &.dash-filter--case--sensitive { + border-width: 1px; + border-style: solid; + border-radius: 3px; + } } } @@ -670,7 +685,7 @@ } .dash-spreadsheet-inner { - [class^='column-header--'] { + [class^='column-header--'], [class^='dash-filter--'] { cursor: pointer; float: left; } @@ -685,6 +700,7 @@ } + .dash-filter--case, .column-header--clear, .column-header--delete, .column-header--edit, @@ -695,10 +711,22 @@ } th:hover { - [class^='column-header--']:not(.disabled) { - color: var(--accent); - opacity: 1; - } + [class^='column-header--'], [class^='dash-filter--'] { + &:not(.disabled) { + color: var(--accent); + opacity: 1; + } + } + } + + .dash-filter--case { + font-size: 10px; + } + + .dash-filter--case--sensitive { + border-width: 1px; + border-style: solid; + border-radius: 3px; } } } diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index e0a6eeec7..179a83bf8 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -198,7 +198,7 @@ export interface IBaseColumn { clearable?: boolean | boolean[] | 'first' | 'last'; deletable?: boolean | boolean[] | 'first' | 'last'; editable: boolean; - filter_option: FilterCase; + filter_options: FilterCase; hideable?: boolean | boolean[] | 'first' | 'last'; renamable?: boolean | boolean[] | 'first' | 'last'; selectable?: boolean | boolean[] | 'first' | 'last'; @@ -333,7 +333,7 @@ export interface IProps { data?: Data; editable?: boolean; fill_width?: boolean; - filter_option?: FilterCase; + filter_options?: FilterCase; filter_query?: string; filter_action?: TableAction | IFilterAction; hidden_columns?: string[]; @@ -388,7 +388,7 @@ interface IDefaultProps { export_format: ExportFormat; export_headers: ExportHeaders; fill_width: boolean; - filter_option: FilterCase; + filter_options: FilterCase; filter_query: string; filter_action: TableAction; fixed_columns: Fixed; @@ -490,6 +490,7 @@ export type SetFilter = ( export interface IFilterFactoryProps { filter_query: string; filter_action: IFilterAction; + filter_options: FilterCase; id: string; map: Map; rawFilterQuery: string; @@ -500,6 +501,7 @@ export interface IFilterFactoryProps { style_cell_conditional: Cells; style_filter: Style; style_filter_conditional: BasicFilters; + toggleFilterOptions: (column: IColumn) => IColumn; visibleColumns: Columns; } diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index ce5537aae..dce2116fa 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import {asyncDecorator} from '@plotly/dash-component-plugins'; import LazyLoader from 'dash-table/LazyLoader'; +import {FilterCase} from 'dash-table/components/Table/props'; /** * Dash DataTable is an interactive table component designed for @@ -56,6 +57,7 @@ export const defaultProps = { dropdown_data: [], fill_width: true, + filter_options: FilterCase.Sensitive, fixed_columns: { headers: false, data: 0 @@ -182,15 +184,15 @@ export const propTypes = { editable: PropTypes.bool, /** - * There are two `filter_option` props in the table. - * This is the column-level filter_option prop and there is - * also the table-level `filter_option` prop. + * There are two `filter_options` props in the table. + * This is the column-level filter_options prop and there is + * also the table-level `filter_options` prop. * These props determine whether the applicable filter relational * operators will default to `sensitive` or `insensitive` comparison. - * If the column-level `filter_option` prop is set it overrides - * the table-level `filter_option` prop for that column. + * If the column-level `filter_options` prop is set it overrides + * the table-level `filter_options` prop for that column. */ - filter_option: PropTypes.oneOf(['sensitive', 'insensitive']), + filter_options: PropTypes.oneOf(['sensitive', 'insensitive']), /** * If true, the user can hide the column by clicking on the `hide` @@ -1154,15 +1156,15 @@ export const propTypes = { ]), /** - * There are two `filter_option` props in the table. - * This is the table-level filter_option prop and there is - * also the column-level `filter_option` prop. + * There are two `filter_options` props in the table. + * This is the table-level filter_options prop and there is + * also the column-level `filter_options` prop. * These props determine whether the applicable filter relational * operators will default to `sensitive` or `insensitive` comparison. - * If the column-level `filter_option` prop is set it overrides - * the table-level `filter_option` prop for that column. + * If the column-level `filter_options` prop is set it overrides + * the table-level `filter_options` prop for that column. */ - filter_option: PropTypes.oneOf(['sensitive', 'insensitive']), + filter_options: PropTypes.oneOf(['sensitive', 'insensitive']), /** * The `sort_action` property enables data to be diff --git a/src/dash-table/dash/Sanitizer.ts b/src/dash-table/dash/Sanitizer.ts index 8852fc8de..ac90f2377 100644 --- a/src/dash-table/dash/Sanitizer.ts +++ b/src/dash-table/dash/Sanitizer.ts @@ -70,7 +70,7 @@ const applyDefaultsToColumns = ( R.map(column => { const c = R.clone(column); c.editable = resolveFlag(editable, column.editable); - c.filter_option = resolveFlag(filterCase, column.filter_option); + c.filter_options = resolveFlag(filterCase, column.filter_options); c.sort_as_null = c.sort_as_null || defaultSort; if (c.type === ColumnType.Numeric && c.format) { @@ -109,7 +109,7 @@ export default class Sanitizer { props.sort_as_null, props.columns, props.editable, - props.filter_option + props.filter_options ) : []; const data = props.data ?? []; diff --git a/src/dash-table/derived/table/index.tsx b/src/dash-table/derived/table/index.tsx index ebd0399ce..a688a9b28 100644 --- a/src/dash-table/derived/table/index.tsx +++ b/src/dash-table/derived/table/index.tsx @@ -9,6 +9,8 @@ import HeaderFactory from 'dash-table/components/HeaderFactory'; import {clearSelection} from 'dash-table/utils/actions'; import { ControlledTableProps, + FilterCase, + IColumn, SetProps, SetState } from 'dash-table/components/Table/props'; @@ -26,10 +28,18 @@ const handleSetFilter = ( setState({workFilter: {map, value: filter_query}, rawFilterQuery}); }; -function propsAndMapFn(propsFn: () => ControlledTableProps, setFilter: any) { +function propsAndMapFn( + propsFn: () => ControlledTableProps, + setFilter: any, + toggleFilterOptions: (column: IColumn) => IColumn +) { const props = propsFn(); - return R.merge(props, {map: props.workFilter.map, setFilter}); + return R.merge(props, { + map: props.workFilter.map, + setFilter, + toggleFilterOptions + }); } export default (propsFn: () => ControlledTableProps) => { @@ -37,6 +47,25 @@ export default (propsFn: () => ControlledTableProps) => { handleSetFilter.bind(undefined, setProps, setState) ); + const toggleFilterOptions = memoizeOne( + (setProps: SetProps, columns: IColumn[]) => (column: IColumn) => { + const newColumns = [...columns]; + const iColumn = columns.indexOf(column); + + const newColumn = {...newColumns[iColumn]}; + newColumn.filter_options = + newColumn.filter_options === FilterCase.Insensitive + ? FilterCase.Sensitive + : FilterCase.Insensitive; + + newColumns.splice(iColumn, 1, newColumn); + + setProps({columns: newColumns}); + + return newColumn; + } + ); + const cellFactory = new CellFactory(propsFn); const augmentedPropsFn = () => { @@ -44,7 +73,8 @@ export default (propsFn: () => ControlledTableProps) => { return propsAndMapFn( propsFn, - setFilter(props.setProps, props.setState) + setFilter(props.setProps, props.setState), + toggleFilterOptions(props.setProps, props.columns) ); }; diff --git a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts index 148ab79dc..a019a3195 100644 --- a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts +++ b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts @@ -21,13 +21,22 @@ import { import columnLexicon from './lexicon/column'; -function getImplicitLexeme(type: ColumnType = ColumnType.Any): ILexemeResult { +function getImplicitLexeme( + filterOptions: FilterCase | undefined, + type: ColumnType = ColumnType.Any +): ILexemeResult { + const flags = R.isNil(filterOptions) + ? '' + : filterOptions === FilterCase.Insensitive + ? 'i' + : 's'; + switch (type) { case ColumnType.Any: case ColumnType.Text: return { lexeme: boundLexeme(contains), - value: RelationalOperator.Contains + value: `${flags}${RelationalOperator.Contains}` }; case ColumnType.Datetime: return { @@ -37,7 +46,7 @@ function getImplicitLexeme(type: ColumnType = ColumnType.Any): ILexemeResult { case ColumnType.Numeric: return { lexeme: boundLexeme(equal), - value: RelationalOperator.Equal + value: `${flags}${RelationalOperator.Equal}` }; } } @@ -59,44 +68,12 @@ function isUnary(lexemes: ILexemeResult[]) { ); } -function isUnflaggedRelational(lexemes: ILexemeResult[]): boolean { - const [op] = lexemes; - return ( - Boolean(lexemes.length) && - op.lexeme.type === LexemeType.RelationalOperator && - !R.isNil(op.lexeme.regexpFlags) && - !Boolean(op.flags?.length) - ); -} - function modifyLex(config: SingleColumnConfig, res: ILexerResult) { if (!res.valid) { return res; } - if (isUnflaggedRelational(res.lexemes)) { - const {filter_option, id} = config; - const [op, rhs] = res.lexemes; - - const flags = R.isNil(filter_option) - ? '' - : filter_option === FilterCase.Insensitive - ? 'i' - : 's'; - - res.lexemes = [ - { - lexeme: boundLexeme(fieldExpression), - value: `{${id}}` - }, - { - ...op, - flags, - value: `${flags}${op.lexeme.subType}` - }, - rhs - ]; - } else if (isBinary(res.lexemes) || isUnary(res.lexemes)) { + if (isBinary(res.lexemes) || isUnary(res.lexemes)) { res.lexemes = [ {lexeme: boundLexeme(fieldExpression), value: `{${config.id}}`}, ...res.lexemes @@ -104,7 +81,7 @@ function modifyLex(config: SingleColumnConfig, res: ILexerResult) { } else if (isExpression(res.lexemes)) { res.lexemes = [ {lexeme: boundLexeme(fieldExpression), value: `{${config.id}}`}, - getImplicitLexeme(config.type), + getImplicitLexeme(config.filter_options, config.type), ...res.lexemes ]; } @@ -113,7 +90,7 @@ function modifyLex(config: SingleColumnConfig, res: ILexerResult) { } export type SingleColumnConfig = RequiredPluck & - OptionalPluck & + OptionalPluck & OptionalPluck; export default class SingleColumnSyntaxTree extends SyntaxTree { From 0b7897b54176607d6d04f83813c0bf117960011c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Wed, 28 Apr 2021 20:27:13 -0400 Subject: [PATCH 03/17] ensure FilterOptions re-rerenders correctly --- .../components/Filter/FilterOptions.tsx | 28 +++++++++---------- src/dash-table/components/FilterFactory.tsx | 8 ++---- src/dash-table/components/Table/Table.less | 12 +++----- src/dash-table/components/Table/props.ts | 1 - 4 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/dash-table/components/Filter/FilterOptions.tsx b/src/dash-table/components/Filter/FilterOptions.tsx index 13b7bb2a6..12a180591 100644 --- a/src/dash-table/components/Filter/FilterOptions.tsx +++ b/src/dash-table/components/Filter/FilterOptions.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react'; +import React from 'react'; import {FilterCase} from 'dash-table/components/Table/props'; @@ -7,17 +7,15 @@ interface IFilterCaseButtonProps { toggleFilterOptions: () => void; } -export default memo( - ({filterOptions, toggleFilterOptions}: IFilterCaseButtonProps) => ( - - ) -); +export default ({ + filterOptions, + toggleFilterOptions +}: IFilterCaseButtonProps) => (); diff --git a/src/dash-table/components/FilterFactory.tsx b/src/dash-table/components/FilterFactory.tsx index 2e99963c7..90766ffc6 100644 --- a/src/dash-table/components/FilterFactory.tsx +++ b/src/dash-table/components/FilterFactory.tsx @@ -13,8 +13,7 @@ import { TableAction, IFilterFactoryProps, SetFilter, - FilterLogicalOperator, - FilterCase + FilterLogicalOperator } from 'dash-table/components/Table/props'; import derivedFilterStyles, { derivedFilterOpStyles @@ -74,7 +73,6 @@ export default class FilterFactory { private filter = memoizerCache<[ColumnId, number]>()( ( column: IColumn, - filterOptions: FilterCase, index: number, map: Map, operator: FilterLogicalOperator, @@ -88,7 +86,7 @@ export default class FilterFactory { key={`column-${index}`} className={`dash-filter column-${index}`} columnId={column.id} - filterOptions={filterOptions} + filterOptions={column.filter_options} isValid={!ast || ast.isValid} setFilter={this.onChange.bind( this, @@ -121,7 +119,6 @@ export default class FilterFactory { ) { const { filter_action, - filter_options, map, row_deletable, row_selectable, @@ -160,7 +157,6 @@ export default class FilterFactory { (column, index) => { return this.filter.get(column.id, index)( column, - filter_options, index, map, filter_action.operator, diff --git a/src/dash-table/components/Table/Table.less b/src/dash-table/components/Table/Table.less index 8d4d13888..471e4f8ac 100644 --- a/src/dash-table/components/Table/Table.less +++ b/src/dash-table/components/Table/Table.less @@ -492,9 +492,11 @@ padding: 1px; } &.dash-filter--case--sensitive { - border-width: 1px; - border-style: solid; + border-color: hotpink; border-radius: 3px; + border-style: solid; + border-width: 2px; + color: hotpink; } } } @@ -722,12 +724,6 @@ .dash-filter--case { font-size: 10px; } - - .dash-filter--case--sensitive { - border-width: 1px; - border-style: solid; - border-radius: 3px; - } } } } diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index 179a83bf8..2a55f2b66 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -490,7 +490,6 @@ export type SetFilter = ( export interface IFilterFactoryProps { filter_query: string; filter_action: IFilterAction; - filter_options: FilterCase; id: string; map: Map; rawFilterQuery: string; From 8d19241c4eb84fb59c1785d195fbdc818d652001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Wed, 28 Apr 2021 20:34:10 -0400 Subject: [PATCH 04/17] changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 489cc5c58..835db9b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] +### Added +- [#545](https://github.com/plotly/dash-table/issues/545) + - Case insensitive filtering + - New props: `filter_options` - to control case of all filters, `columns.filter_options` - to control filter case for each column + - New operators: `i=`, `ieq`, `i>=`, `ige`, `i>`, `igt`, `i<=`, `ile`, `i<`, `ilt`, `i!=`, `ine`, `icontains` - for case-insensitive filtering, `s=`, `seq`, `s>=`, `sge`, `s>`, `sgt`, `s<=`, `sle`, `s<`, `slt`, `s!=`, `sne`, `scontains` - to force case-sensitive filtering on case-insensitive columns + ## [4.11.3] - 2021-04-08 ### Changed - [#862](https://github.com/plotly/dash-table/pull/862) - update docstrings per https://github.com/plotly/dash/issues/1205 From 49773fd7dd5211f1e3017f7fa7106a1e51741e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Thu, 29 Apr 2021 20:41:39 -0400 Subject: [PATCH 05/17] lint --- .../components/Filter/FilterOptions.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/dash-table/components/Filter/FilterOptions.tsx b/src/dash-table/components/Filter/FilterOptions.tsx index 12a180591..e8e34daf0 100644 --- a/src/dash-table/components/Filter/FilterOptions.tsx +++ b/src/dash-table/components/Filter/FilterOptions.tsx @@ -10,12 +10,15 @@ interface IFilterCaseButtonProps { export default ({ filterOptions, toggleFilterOptions -}: IFilterCaseButtonProps) => ( ( + ); + onClick={toggleFilterOptions} + value='Aa' + /> +); From 99aa5a9c88ae85f4cf4c7519aaf042a4c5999e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Thu, 29 Apr 2021 20:47:34 -0400 Subject: [PATCH 06/17] fix test (make columns wider to allow clicking elsewhere than the `Aa` toggle) --- tests/selenium/test_derived_props.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/selenium/test_derived_props.py b/tests/selenium/test_derived_props.py index 95df9550c..fe057a7e8 100644 --- a/tests/selenium/test_derived_props.py +++ b/tests/selenium/test_derived_props.py @@ -51,6 +51,7 @@ def get_app(): row_deletable=True, row_selectable=True, sort_action="native", + style_cell=dict(width=100, min_width=100, max_width=100), ), html.Div(id="props_container", children=["Nothing yet"]), ] From d5fddb0e46d2fd08135b945de805d5960fb45719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Fri, 30 Apr 2021 08:43:01 -0400 Subject: [PATCH 07/17] e2e sensitivity test --- tests/selenium/conftest.py | 15 +++++- tests/selenium/test_filter.py | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/tests/selenium/conftest.py b/tests/selenium/conftest.py index dc47f2ac4..a29737443 100644 --- a/tests/selenium/conftest.py +++ b/tests/selenium/conftest.py @@ -260,9 +260,20 @@ def filter(self): ) ) + def filter_clear(self): + self.filter_double_click() + self.mixin.driver.switch_to.active_element.send_keys(Keys.DELETE) + def filter_click(self): self.filter().click() + def filter_double_click(self): + ac = ActionChains(self.mixin.driver) + ac.move_to_element(self.filter()) + ac.pause(1) # sometimes experiencing incorrect behavior on scroll otherwise + ac.double_click() + return ac.perform() + def filter_invalid(self): return "invalid" in self.filter().get_attribute("class").split(" ") @@ -273,8 +284,10 @@ def filter_value(self, value=None): .find_element_by_css_selector("input") .get_attribute("value") ) + elif value == "": + self.filter_clear() else: - self.filter_click() + self.filter_double_click() self.mixin.driver.switch_to.active_element.send_keys(value + Keys.ENTER) diff --git a/tests/selenium/test_filter.py b/tests/selenium/test_filter.py index 88f97af9c..25f743dab 100644 --- a/tests/selenium/test_filter.py +++ b/tests/selenium/test_filter.py @@ -78,3 +78,98 @@ def test_filt001_basic(test, props, expect): assert target.cell(index, "a").get_text() == value assert test.get_log_errors() == [] + + +@pytest.mark.parametrize( + "filter_options,column_filter_options", + [ + ("sensitive", None), + ("sensitive", None), + ("sensitive", None), + ("insensitive", None), + ("sensitive", "insensitive"), + ("insensitive", "sensitive"), + ], +) +def test_filt002_sensitivity(test, filter_options, column_filter_options): + props = dict( + id="table", + data=[dict(a="abc", b="abc", c="abc"), dict(a="ABC", b="ABC", c="ABC")], + columns=[ + dict(id="a", name="a", filter_options=column_filter_options, type="any"), + dict(id="b", name="b", filter_options=column_filter_options, type="text"), + dict( + id="c", name="c", filter_options=column_filter_options, type="numeric" + ), + ], + filter_action="native", + filter_options=filter_options, + style_cell=dict(width=100, min_width=100, max_width=100), + ) + + sensitivity = ( + filter_options if column_filter_options is None else column_filter_options + ) + + test.start_server(get_app(props)) + + target = test.table("table") + # time.sleep(5000) + # any -> implicit contains + target.column("a").filter_value("A") + if sensitivity == "sensitive": + assert target.cell(0, "a").get_text() == "ABC" + assert not target.cell(1, "a").exists() + else: + assert target.cell(0, "a").get_text() == "abc" + assert target.cell(1, "a").get_text() == "ABC" + + target.column("a").filter_value("a") + if sensitivity == "sensitive": + assert target.cell(0, "a").get_text() == "abc" + assert not target.cell(1, "a").exists() + else: + assert target.cell(0, "a").get_text() == "abc" + assert target.cell(1, "a").get_text() == "ABC" + + # text -> implicit contains + target.column("a").filter_value("") + target.column("b").filter_value("A") + if sensitivity == "sensitive": + assert target.cell(0, "b").get_text() == "ABC" + assert not target.cell(1, "b").exists() + else: + assert target.cell(0, "b").get_text() == "abc" + assert target.cell(1, "b").get_text() == "ABC" + + target.column("b").filter_value("a") + if sensitivity == "sensitive": + assert target.cell(0, "b").get_text() == "abc" + assert not target.cell(1, "b").exists() + else: + assert target.cell(0, "b").get_text() == "abc" + assert target.cell(1, "b").get_text() == "ABC" + + # numeric -> implicit equal + target.column("b").filter_value("") + target.column("c").filter_value("A") + assert not target.cell(0, "c").exists() + + target.column("c").filter_value("a") + assert not target.cell(0, "c").exists() + + target.column("c").filter_value("ABC") + if sensitivity == "sensitive": + assert target.cell(0, "c").get_text() == "ABC" + assert not target.cell(1, "c").exists() + else: + assert target.cell(0, "c").get_text() == "abc" + assert target.cell(1, "c").get_text() == "ABC" + + target.column("c").filter_value("abc") + if sensitivity == "sensitive": + assert target.cell(0, "c").get_text() == "abc" + assert not target.cell(1, "c").exists() + else: + assert target.cell(0, "c").get_text() == "abc" + assert target.cell(1, "c").get_text() == "ABC" From 551cd3a609b459a783a2ed5c2505c117725aa4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Fri, 30 Apr 2021 18:26:18 -0400 Subject: [PATCH 08/17] resolveFlag: check for null|undefined instead of undefined only --- src/dash-table/derived/cell/resolveFlag.ts | 6 ++++-- tests/selenium/test_filter.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/dash-table/derived/cell/resolveFlag.ts b/src/dash-table/derived/cell/resolveFlag.ts index 699e8f856..a790f1c8b 100644 --- a/src/dash-table/derived/cell/resolveFlag.ts +++ b/src/dash-table/derived/cell/resolveFlag.ts @@ -1,2 +1,4 @@ -export default (tableFlag: T, columnFlag: T | undefined): T => - columnFlag === undefined ? tableFlag : columnFlag; +import * as R from 'ramda'; + +export default (tableFlag: T, columnFlag: T | null | undefined): T => + R.isNil(columnFlag) ? tableFlag : columnFlag; diff --git a/tests/selenium/test_filter.py b/tests/selenium/test_filter.py index 25f743dab..6ec8454a2 100644 --- a/tests/selenium/test_filter.py +++ b/tests/selenium/test_filter.py @@ -114,7 +114,7 @@ def test_filt002_sensitivity(test, filter_options, column_filter_options): test.start_server(get_app(props)) target = test.table("table") - # time.sleep(5000) + # any -> implicit contains target.column("a").filter_value("A") if sensitivity == "sensitive": From d76ae04ef2badd066ef37437b8a6513f69927642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Fri, 30 Apr 2021 20:59:32 -0400 Subject: [PATCH 09/17] Add title to FilterOptions component --- src/dash-table/components/Filter/FilterOptions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dash-table/components/Filter/FilterOptions.tsx b/src/dash-table/components/Filter/FilterOptions.tsx index e8e34daf0..cb944b05e 100644 --- a/src/dash-table/components/Filter/FilterOptions.tsx +++ b/src/dash-table/components/Filter/FilterOptions.tsx @@ -19,6 +19,7 @@ export default ({ : 'dash-filter--case--insensitive' }`} onClick={toggleFilterOptions} + title='Toggle filter case sensitivity' value='Aa' /> ); From c07a701b695d2a83bf2be9913dcb3dd6d56af675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Sat, 1 May 2021 20:15:46 -0400 Subject: [PATCH 10/17] trigger build From 8ddf84eb035d87eaf26bb3be5d785d0f1497efd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Fri, 7 May 2021 08:09:33 -0400 Subject: [PATCH 11/17] apply sensitive/insensitive on explicit relational operators --- .../syntax-tree/SingleColumnSyntaxTree.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts index a019a3195..f08382c97 100644 --- a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts +++ b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts @@ -21,6 +21,42 @@ import { import columnLexicon from './lexicon/column'; +const sensitiveRelationalOperators: string[] = [ + RelationalOperator.Contains, + RelationalOperator.Equal, + RelationalOperator.GreaterOrEqual, + RelationalOperator.GreaterThan, + RelationalOperator.LessOrEqual, + RelationalOperator.LessThan, + RelationalOperator.NotEqual +]; + +function getFilterLexeme( + filterOptions: FilterCase | undefined, + lexeme: ILexemeResult +): ILexemeResult { + const flags = R.isNil(filterOptions) + ? '' + : filterOptions === FilterCase.Insensitive + ? 'i' + : 's'; + + if ( + lexeme.lexeme.type === LexemeType.RelationalOperator && + lexeme.lexeme.subType && + sensitiveRelationalOperators.indexOf(lexeme.lexeme.subType) !== -1 && + lexeme.value && + ['i', 's'].indexOf(lexeme.value[0]) === -1 + ) { + return { + ...lexeme, + value: `${flags}${lexeme.value}` + }; + } + + return lexeme; +} + function getImplicitLexeme( filterOptions: FilterCase | undefined, type: ColumnType = ColumnType.Any @@ -73,7 +109,13 @@ function modifyLex(config: SingleColumnConfig, res: ILexerResult) { return res; } - if (isBinary(res.lexemes) || isUnary(res.lexemes)) { + if (isBinary(res.lexemes)) { + res.lexemes = [ + {lexeme: boundLexeme(fieldExpression), value: `{${config.id}}`}, + getFilterLexeme(config.filter_options, res.lexemes[0]), + res.lexemes[1] + ]; + } else if (isUnary(res.lexemes)) { res.lexemes = [ {lexeme: boundLexeme(fieldExpression), value: `{${config.id}}`}, ...res.lexemes From d0c744aa9df1c28623e3778ef6d800f9ea04ed45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Fri, 7 May 2021 16:34:27 -0400 Subject: [PATCH 12/17] test sensitive/insensitive binary relational operators --- tests/selenium/conftest.py | 21 +++++---- tests/selenium/test_filter.py | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/tests/selenium/conftest.py b/tests/selenium/conftest.py index a29737443..e9403e315 100644 --- a/tests/selenium/conftest.py +++ b/tests/selenium/conftest.py @@ -1,3 +1,4 @@ +import platform import pytest from dash.testing.browser import Browser @@ -261,19 +262,19 @@ def filter(self): ) def filter_clear(self): - self.filter_double_click() - self.mixin.driver.switch_to.active_element.send_keys(Keys.DELETE) + CMD = Keys.COMMAND if platform.system() == "Darwin" else Keys.CONTROL + + self.filter().find_element_by_css_selector("input").click() + ac = ActionChains(self.mixin.driver) + ac.key_down(CMD) + ac.send_keys("a") + ac.key_up(CMD) + ac.send_keys(Keys.DELETE) + ac.perform() def filter_click(self): self.filter().click() - def filter_double_click(self): - ac = ActionChains(self.mixin.driver) - ac.move_to_element(self.filter()) - ac.pause(1) # sometimes experiencing incorrect behavior on scroll otherwise - ac.double_click() - return ac.perform() - def filter_invalid(self): return "invalid" in self.filter().get_attribute("class").split(" ") @@ -287,7 +288,7 @@ def filter_value(self, value=None): elif value == "": self.filter_clear() else: - self.filter_double_click() + self.filter_clear() self.mixin.driver.switch_to.active_element.send_keys(value + Keys.ENTER) diff --git a/tests/selenium/test_filter.py b/tests/selenium/test_filter.py index 6ec8454a2..d5a5203e3 100644 --- a/tests/selenium/test_filter.py +++ b/tests/selenium/test_filter.py @@ -173,3 +173,89 @@ def test_filt002_sensitivity(test, filter_options, column_filter_options): else: assert target.cell(0, "c").get_text() == "abc" assert target.cell(1, "c").get_text() == "ABC" + + +@pytest.mark.parametrize( + "filter_options,column_filter_options", + [ + ("sensitive", None), + ("sensitive", None), + ("sensitive", None), + ("insensitive", None), + ("sensitive", "insensitive"), + ("insensitive", "sensitive"), + ], +) +def test_filt003_sensitivity(test, filter_options, column_filter_options): + props = dict( + id="table", + data=[dict(a="abc", b="abc", c="abc"), dict(a="ABC", b="ABC", c="ABC")], + columns=[ + dict(id="a", name="a", filter_options=column_filter_options, type="any"), + dict(id="b", name="b", filter_options=column_filter_options, type="text"), + dict( + id="c", name="c", filter_options=column_filter_options, type="numeric" + ), + ], + filter_action="native", + filter_options=filter_options, + style_cell=dict(width=100, min_width=100, max_width=100), + ) + + sensitivity = ( + filter_options if column_filter_options is None else column_filter_options + ) + + test.start_server(get_app(props)) + + target = test.table("table") + + target.column("a").filter_value("contains A") + if sensitivity == "sensitive": + assert target.cell(0, "a").get_text() == "ABC" + assert not target.cell(1, "a").exists() + else: + assert target.cell(0, "a").get_text() == "abc" + assert target.cell(1, "a").get_text() == "ABC" + + target.column("a").filter_value("contains a") + if sensitivity == "sensitive": + assert target.cell(0, "a").get_text() == "abc" + assert not target.cell(1, "a").exists() + else: + assert target.cell(0, "a").get_text() == "abc" + assert target.cell(1, "a").get_text() == "ABC" + + target.column("a").filter_value("") + target.column("b").filter_value("contains A") + if sensitivity == "sensitive": + assert target.cell(0, "b").get_text() == "ABC" + assert not target.cell(1, "b").exists() + else: + assert target.cell(0, "b").get_text() == "abc" + assert target.cell(1, "b").get_text() == "ABC" + + target.column("b").filter_value("contains a") + if sensitivity == "sensitive": + assert target.cell(0, "b").get_text() == "abc" + assert not target.cell(1, "b").exists() + else: + assert target.cell(0, "b").get_text() == "abc" + assert target.cell(1, "b").get_text() == "ABC" + + target.column("b").filter_value("") + target.column("c").filter_value("contains A") + if sensitivity == "sensitive": + assert target.cell(0, "c").get_text() == "ABC" + assert not target.cell(1, "c").exists() + else: + assert target.cell(0, "c").get_text() == "abc" + assert target.cell(1, "c").get_text() == "ABC" + + target.column("c").filter_value("contains a") + if sensitivity == "sensitive": + assert target.cell(0, "c").get_text() == "abc" + assert not target.cell(1, "c").exists() + else: + assert target.cell(0, "c").get_text() == "abc" + assert target.cell(1, "c").get_text() == "ABC" From 5c69ae768837e36d3f08d0bf729d1dbe3d1913e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Fri, 7 May 2021 17:32:26 -0400 Subject: [PATCH 13/17] trigger build From e686aea79fbdf8ae63068e173f34c9e6f0bc3d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Fri, 7 May 2021 18:07:01 -0400 Subject: [PATCH 14/17] trigger build From ada845620586f3eecf77d66a6be9b5b04a543676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Tue, 11 May 2021 12:07:18 -0400 Subject: [PATCH 15/17] make `filter_options` an object with a `case` property instead of a string to improve forward compatibility and consistency --- src/dash-table/components/Filter/Column.tsx | 4 ++-- .../components/Filter/FilterOptions.tsx | 6 +++--- src/dash-table/components/Table/props.ts | 10 +++++++--- src/dash-table/dash/DataTable.js | 7 ++++--- src/dash-table/dash/Sanitizer.ts | 15 ++++++++++++--- src/dash-table/derived/table/index.tsx | 11 +++++++---- .../syntax-tree/SingleColumnSyntaxTree.ts | 11 ++++++----- 7 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/dash-table/components/Filter/Column.tsx b/src/dash-table/components/Filter/Column.tsx index 99d007dca..d04ca8a55 100644 --- a/src/dash-table/components/Filter/Column.tsx +++ b/src/dash-table/components/Filter/Column.tsx @@ -2,7 +2,7 @@ import React, {CSSProperties, PureComponent} from 'react'; import IsolatedInput from 'core/components/IsolatedInput'; -import {ColumnId, FilterCase} from 'dash-table/components/Table/props'; +import {ColumnId, IFilterOptions} from 'dash-table/components/Table/props'; import TableClipboardHelper from 'dash-table/utils/TableClipboardHelper'; import FilterOptions from 'dash-table/components/Filter/FilterOptions'; @@ -11,7 +11,7 @@ type SetFilter = (ev: any) => void; interface IColumnFilterProps { className: string; columnId: ColumnId; - filterOptions: FilterCase; + filterOptions: IFilterOptions; isValid: boolean; setFilter: SetFilter; style?: CSSProperties; diff --git a/src/dash-table/components/Filter/FilterOptions.tsx b/src/dash-table/components/Filter/FilterOptions.tsx index cb944b05e..34c231284 100644 --- a/src/dash-table/components/Filter/FilterOptions.tsx +++ b/src/dash-table/components/Filter/FilterOptions.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import {FilterCase} from 'dash-table/components/Table/props'; +import {FilterCase, IFilterOptions} from 'dash-table/components/Table/props'; interface IFilterCaseButtonProps { - filterOptions: FilterCase; + filterOptions: IFilterOptions; toggleFilterOptions: () => void; } @@ -14,7 +14,7 @@ export default ({ +data || 0; const getFixedColumns = ( @@ -65,12 +70,16 @@ const applyDefaultsToColumns = ( defaultSort: SortAsNull, columns: Columns, editable: boolean, - filterCase: FilterCase + filterOptions: IFilterOptions | undefined ) => R.map(column => { const c = R.clone(column); c.editable = resolveFlag(editable, column.editable); - c.filter_options = resolveFlag(filterCase, column.filter_options); + c.filter_options = { + ...DEFAULT_FILTER_OPTIONS, + ...(c.filter_options ?? {}), + ...(filterOptions ?? {}) + }; c.sort_as_null = c.sort_as_null || defaultSort; if (c.type === ColumnType.Numeric && c.format) { diff --git a/src/dash-table/derived/table/index.tsx b/src/dash-table/derived/table/index.tsx index a688a9b28..f344b5a26 100644 --- a/src/dash-table/derived/table/index.tsx +++ b/src/dash-table/derived/table/index.tsx @@ -53,10 +53,13 @@ export default (propsFn: () => ControlledTableProps) => { const iColumn = columns.indexOf(column); const newColumn = {...newColumns[iColumn]}; - newColumn.filter_options = - newColumn.filter_options === FilterCase.Insensitive - ? FilterCase.Sensitive - : FilterCase.Insensitive; + newColumn.filter_options = { + ...newColumn.filter_options, + case: + newColumn.filter_options.case === FilterCase.Insensitive + ? FilterCase.Sensitive + : FilterCase.Insensitive + }; newColumns.splice(iColumn, 1, newColumn); diff --git a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts index f08382c97..d0f9c7abf 100644 --- a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts +++ b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts @@ -8,7 +8,8 @@ import {LexemeType, boundLexeme} from 'core/syntax-tree/lexicon'; import { ColumnType, FilterCase, - IColumn + IColumn, + IFilterOptions } from 'dash-table/components/Table/props'; import {fieldExpression} from './lexeme/expression'; @@ -32,12 +33,12 @@ const sensitiveRelationalOperators: string[] = [ ]; function getFilterLexeme( - filterOptions: FilterCase | undefined, + filterOptions: IFilterOptions | undefined, lexeme: ILexemeResult ): ILexemeResult { const flags = R.isNil(filterOptions) ? '' - : filterOptions === FilterCase.Insensitive + : filterOptions.case === FilterCase.Insensitive ? 'i' : 's'; @@ -58,12 +59,12 @@ function getFilterLexeme( } function getImplicitLexeme( - filterOptions: FilterCase | undefined, + filterOptions: IFilterOptions | undefined, type: ColumnType = ColumnType.Any ): ILexemeResult { const flags = R.isNil(filterOptions) ? '' - : filterOptions === FilterCase.Insensitive + : filterOptions.case === FilterCase.Insensitive ? 'i' : 's'; From 6f012c3e419dedc4cf186fd1e51f90b0505c70f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Tue, 11 May 2021 12:39:20 -0400 Subject: [PATCH 16/17] fix props, fix tests --- src/dash-table/dash/DataTable.js | 5 ++-- tests/selenium/test_filter.py | 42 ++++++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 56b09ad74..53ca225e3 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -1165,8 +1165,9 @@ export const propTypes = { * If the column-level `filter_options` prop is set it overrides * the table-level `filter_options` prop for that column. */ - filter_options: PropTypes.oneOf(['sensitive', 'insensitive']), - + filter_options: PropTypes.shape({ + case: PropTypes.oneOf(['sensitive', 'insensitive']) + }), /** * The `sort_action` property enables data to be * sorted on a per-column basis. diff --git a/tests/selenium/test_filter.py b/tests/selenium/test_filter.py index d5a5203e3..1a8a4031a 100644 --- a/tests/selenium/test_filter.py +++ b/tests/selenium/test_filter.py @@ -96,14 +96,27 @@ def test_filt002_sensitivity(test, filter_options, column_filter_options): id="table", data=[dict(a="abc", b="abc", c="abc"), dict(a="ABC", b="ABC", c="ABC")], columns=[ - dict(id="a", name="a", filter_options=column_filter_options, type="any"), - dict(id="b", name="b", filter_options=column_filter_options, type="text"), dict( - id="c", name="c", filter_options=column_filter_options, type="numeric" + id="a", + name="a", + filter_options=dict(case=column_filter_options), + type="any", + ), + dict( + id="b", + name="b", + filter_options=dict(case=column_filter_options), + type="text", + ), + dict( + id="c", + name="c", + filter_options=dict(case=column_filter_options), + type="numeric", ), ], filter_action="native", - filter_options=filter_options, + filter_options=dict(case=filter_options), style_cell=dict(width=100, min_width=100, max_width=100), ) @@ -191,14 +204,27 @@ def test_filt003_sensitivity(test, filter_options, column_filter_options): id="table", data=[dict(a="abc", b="abc", c="abc"), dict(a="ABC", b="ABC", c="ABC")], columns=[ - dict(id="a", name="a", filter_options=column_filter_options, type="any"), - dict(id="b", name="b", filter_options=column_filter_options, type="text"), dict( - id="c", name="c", filter_options=column_filter_options, type="numeric" + id="a", + name="a", + filter_options=dict(case=column_filter_options), + type="any", + ), + dict( + id="b", + name="b", + filter_options=dict(case=column_filter_options), + type="text", + ), + dict( + id="c", + name="c", + filter_options=dict(case=column_filter_options), + type="numeric", ), ], filter_action="native", - filter_options=filter_options, + filter_options=dict(case=filter_options), style_cell=dict(width=100, min_width=100, max_width=100), ) From 4697136f6e59ac4e4feeb9296d18937b7645c777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Tue, 11 May 2021 13:47:14 -0400 Subject: [PATCH 17/17] - handle `None` case correctly in tests - invert column and table filter_options priority (column takes precedence) --- src/dash-table/dash/Sanitizer.ts | 4 ++-- tests/selenium/test_filter.py | 32 ++++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/dash-table/dash/Sanitizer.ts b/src/dash-table/dash/Sanitizer.ts index 2b3188792..c3d4e8f1a 100644 --- a/src/dash-table/dash/Sanitizer.ts +++ b/src/dash-table/dash/Sanitizer.ts @@ -77,8 +77,8 @@ const applyDefaultsToColumns = ( c.editable = resolveFlag(editable, column.editable); c.filter_options = { ...DEFAULT_FILTER_OPTIONS, - ...(c.filter_options ?? {}), - ...(filterOptions ?? {}) + ...(filterOptions ?? {}), + ...(c.filter_options ?? {}) }; c.sort_as_null = c.sort_as_null || defaultSort; diff --git a/tests/selenium/test_filter.py b/tests/selenium/test_filter.py index 1a8a4031a..eeb746649 100644 --- a/tests/selenium/test_filter.py +++ b/tests/selenium/test_filter.py @@ -99,24 +99,32 @@ def test_filt002_sensitivity(test, filter_options, column_filter_options): dict( id="a", name="a", - filter_options=dict(case=column_filter_options), + filter_options=dict(case=column_filter_options) + if column_filter_options is not None + else None, type="any", ), dict( id="b", name="b", - filter_options=dict(case=column_filter_options), + filter_options=dict(case=column_filter_options) + if column_filter_options is not None + else None, type="text", ), dict( id="c", name="c", - filter_options=dict(case=column_filter_options), + filter_options=dict(case=column_filter_options) + if column_filter_options is not None + else None, type="numeric", ), ], filter_action="native", - filter_options=dict(case=filter_options), + filter_options=dict(case=filter_options) + if filter_options is not None + else None, style_cell=dict(width=100, min_width=100, max_width=100), ) @@ -207,24 +215,32 @@ def test_filt003_sensitivity(test, filter_options, column_filter_options): dict( id="a", name="a", - filter_options=dict(case=column_filter_options), + filter_options=dict(case=column_filter_options) + if column_filter_options is not None + else None, type="any", ), dict( id="b", name="b", - filter_options=dict(case=column_filter_options), + filter_options=dict(case=column_filter_options) + if column_filter_options is not None + else None, type="text", ), dict( id="c", name="c", - filter_options=dict(case=column_filter_options), + filter_options=dict(case=column_filter_options) + if column_filter_options is not None + else None, type="numeric", ), ], filter_action="native", - filter_options=dict(case=filter_options), + filter_options=dict(case=filter_options) + if filter_options is not None + else None, style_cell=dict(width=100, min_width=100, max_width=100), )