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/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 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/Filter/Column.tsx b/src/dash-table/components/Filter/Column.tsx index 1273303a7..d04ca8a55 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, IFilterOptions} 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: IFilterOptions; 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..34c231284 --- /dev/null +++ b/src/dash-table/components/Filter/FilterOptions.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import {FilterCase, IFilterOptions} from 'dash-table/components/Table/props'; + +interface IFilterCaseButtonProps { + filterOptions: IFilterOptions; + toggleFilterOptions: () => void; +} + +export default ({ + filterOptions, + toggleFilterOptions +}: IFilterCaseButtonProps) => ( + +); diff --git a/src/dash-table/components/FilterFactory.tsx b/src/dash-table/components/FilterFactory.tsx index 0d834d6bc..90766ffc6 100644 --- a/src/dash-table/components/FilterFactory.tsx +++ b/src/dash-table/components/FilterFactory.tsx @@ -57,13 +57,27 @@ 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, index: number, map: Map, operator: FilterLogicalOperator, - setFilter: SetFilter + setFilter: SetFilter, + toggleFilterOptions: (column: IColumn) => IColumn ) => { const ast = map.get(column.id.toString()); @@ -72,6 +86,7 @@ export default class FilterFactory { key={`column-${index}`} className={`dash-filter column-${index}`} columnId={column.id} + filterOptions={column.filter_options} isValid={!ast || ast.isValid} setFilter={this.onChange.bind( this, @@ -80,6 +95,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} /> ); @@ -107,6 +127,7 @@ export default class FilterFactory { style_cell_conditional, style_filter, style_filter_conditional, + toggleFilterOptions, visibleColumns } = this.props; @@ -139,7 +160,8 @@ export default class FilterFactory { 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..471e4f8ac 100644 --- a/src/dash-table/components/Table/Table.less +++ b/src/dash-table/components/Table/Table.less @@ -481,6 +481,23 @@ 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-color: hotpink; + border-radius: 3px; + border-style: solid; + border-width: 2px; + color: hotpink; + } } } @@ -670,7 +687,7 @@ } .dash-spreadsheet-inner { - [class^='column-header--'] { + [class^='column-header--'], [class^='dash-filter--'] { cursor: pointer; float: left; } @@ -685,6 +702,7 @@ } + .dash-filter--case, .column-header--clear, .column-header--delete, .column-header--edit, @@ -695,10 +713,16 @@ } 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; } } } diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index ebe56ec7b..aa7249bd7 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' @@ -49,6 +54,10 @@ export enum TableAction { None = 'none' } +export interface IFilterOptions { + case?: FilterCase; +} + export interface IDerivedData { data: Data; indices: Indices; @@ -193,6 +202,7 @@ export interface IBaseColumn { clearable?: boolean | boolean[] | 'first' | 'last'; deletable?: boolean | boolean[] | 'first' | 'last'; editable: boolean; + filter_options: IFilterOptions; hideable?: boolean | boolean[] | 'first' | 'last'; renamable?: boolean | boolean[] | 'first' | 'last'; selectable?: boolean | boolean[] | 'first' | 'last'; @@ -327,6 +337,7 @@ export interface IProps { data?: Data; editable?: boolean; fill_width?: boolean; + filter_options?: IFilterOptions; filter_query?: string; filter_action?: TableAction | IFilterAction; hidden_columns?: string[]; @@ -381,6 +392,7 @@ interface IDefaultProps { export_format: ExportFormat; export_headers: ExportHeaders; fill_width: boolean; + filter_options?: IFilterOptions; filter_query: string; filter_action: TableAction; fixed_columns: Fixed; @@ -492,6 +504,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 2b7b5db25..53ca225e3 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -56,6 +56,7 @@ export const defaultProps = { dropdown_data: [], fill_width: true, + filter_options: {}, fixed_columns: { headers: false, data: 0 @@ -181,6 +182,19 @@ export const propTypes = { */ editable: PropTypes.bool, + /** + * 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_options` prop is set it overrides + * the table-level `filter_options` prop for that column. + */ + filter_options: PropTypes.shape({ + case: 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 +1156,18 @@ export const propTypes = { }) ]), + /** + * 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_options` prop is set it overrides + * the table-level `filter_options` prop for that column. + */ + 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/src/dash-table/dash/Sanitizer.ts b/src/dash-table/dash/Sanitizer.ts index 5aaef28c3..c3d4e8f1a 100644 --- a/src/dash-table/dash/Sanitizer.ts +++ b/src/dash-table/dash/Sanitizer.ts @@ -16,7 +16,9 @@ import { ExportHeaders, IFilterAction, FilterLogicalOperator, - SelectedCells + SelectedCells, + FilterCase, + IFilterOptions } from 'dash-table/components/Table/props'; import headerRows from 'dash-table/derived/header/headerRows'; import resolveFlag from 'dash-table/derived/cell/resolveFlag'; @@ -35,6 +37,10 @@ const DEFAULT_NULLY = ''; const DEFAULT_SPECIFIER = ''; const NULL_SELECTED_CELLS: SelectedCells = []; +const DEFAULT_FILTER_OPTIONS = { + case: FilterCase.Sensitive +}; + const data2number = (data?: any) => +data || 0; const getFixedColumns = ( @@ -63,11 +69,17 @@ const applyDefaultsToColumns = ( defaultLocale: INumberLocale, defaultSort: SortAsNull, columns: Columns, - editable: boolean + editable: boolean, + filterOptions: IFilterOptions | undefined ) => R.map(column => { const c = R.clone(column); c.editable = resolveFlag(editable, column.editable); + c.filter_options = { + ...DEFAULT_FILTER_OPTIONS, + ...(filterOptions ?? {}), + ...(c.filter_options ?? {}) + }; c.sort_as_null = c.sort_as_null || defaultSort; if (c.type === ColumnType.Numeric && c.format) { @@ -105,7 +117,8 @@ export default class Sanitizer { locale_format, props.sort_as_null, props.columns, - props.editable + props.editable, + props.filter_options ) : []; const data = props.data ?? []; diff --git a/src/dash-table/derived/cell/resolveFlag.ts b/src/dash-table/derived/cell/resolveFlag.ts index a60717ce3..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: boolean, columnFlag: boolean | undefined): boolean => - 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/src/dash-table/derived/table/index.tsx b/src/dash-table/derived/table/index.tsx index ebd0399ce..f344b5a26 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,28 @@ 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, + case: + newColumn.filter_options.case === 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 +76,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 a25c34d7e..d0f9c7abf 100644 --- a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts +++ b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts @@ -1,9 +1,16 @@ +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, + IFilterOptions +} from 'dash-table/components/Table/props'; import {fieldExpression} from './lexeme/expression'; import { @@ -15,13 +22,58 @@ import { import columnLexicon from './lexicon/column'; -function getImplicitLexeme(type: ColumnType = ColumnType.Any): ILexemeResult { +const sensitiveRelationalOperators: string[] = [ + RelationalOperator.Contains, + RelationalOperator.Equal, + RelationalOperator.GreaterOrEqual, + RelationalOperator.GreaterThan, + RelationalOperator.LessOrEqual, + RelationalOperator.LessThan, + RelationalOperator.NotEqual +]; + +function getFilterLexeme( + filterOptions: IFilterOptions | undefined, + lexeme: ILexemeResult +): ILexemeResult { + const flags = R.isNil(filterOptions) + ? '' + : filterOptions.case === 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: IFilterOptions | undefined, + type: ColumnType = ColumnType.Any +): ILexemeResult { + const flags = R.isNil(filterOptions) + ? '' + : filterOptions.case === 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 { @@ -31,7 +83,7 @@ function getImplicitLexeme(type: ColumnType = ColumnType.Any): ILexemeResult { case ColumnType.Numeric: return { lexeme: boundLexeme(equal), - value: RelationalOperator.Equal + value: `${flags}${RelationalOperator.Equal}` }; } } @@ -58,7 +110,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 @@ -66,7 +124,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 ]; } @@ -75,6 +133,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); diff --git a/tests/selenium/conftest.py b/tests/selenium/conftest.py index dc47f2ac4..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 @@ -260,6 +261,17 @@ def filter(self): ) ) + def filter_clear(self): + 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() @@ -273,8 +285,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_clear() self.mixin.driver.switch_to.active_element.send_keys(value + Keys.ENTER) 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"]), ] diff --git a/tests/selenium/test_filter.py b/tests/selenium/test_filter.py index 88f97af9c..eeb746649 100644 --- a/tests/selenium/test_filter.py +++ b/tests/selenium/test_filter.py @@ -78,3 +78,226 @@ 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=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) + if column_filter_options is not None + else None, + type="text", + ), + dict( + id="c", + name="c", + 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) + if filter_options is not None + else None, + 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") + + # 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" + + +@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=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) + if column_filter_options is not None + else None, + type="text", + ), + dict( + id="c", + name="c", + 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) + if filter_options is not None + else None, + 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"