diff --git a/packages/grid/src/Grid.test.tsx b/packages/grid/src/Grid.test.tsx index ffacabe39f..9324173001 100644 --- a/packages/grid/src/Grid.test.tsx +++ b/packages/grid/src/Grid.test.tsx @@ -199,6 +199,34 @@ function mouseClick( mouseUp(column, row, component, extraMouseArgs, clientX, clientY); } +function mouseRightClick( + column: VisibleIndex, + row: VisibleIndex, + component: Grid, + extraMouseArgs?: MouseEventInit, + clientX?: number, + clientY?: number +) { + mouseEvent( + column, + row, + component.handleContextMenu, + 'mousedown', + extraMouseArgs, + clientX, + clientY + ); + mouseEvent( + column, + row, + component.handleContextMenu, + 'mouseup', + extraMouseArgs, + clientX, + clientY + ); +} + function mouseDoubleClick( column: VisibleIndex, row: VisibleIndex, @@ -334,6 +362,34 @@ it('ctrl clicking a selected cell should deselect it', () => { expect(component.state.selectedRanges.length).toBe(0); }); +it('right click outside the range changes the selected ranges', () => { + const component = makeGridComponent(); + + mouseClick(3, 5, component); + mouseClick(3, 6, component, { ctrlKey: true }); + expect(component.state.cursorColumn).toBe(3); + expect(component.state.cursorRow).toBe(6); + expect(component.state.selectedRanges[0]).toEqual(new GridRange(3, 5, 3, 6)); + + mouseRightClick(5, 7, component); + expect(component.state.cursorColumn).toBe(5); + expect(component.state.cursorRow).toBe(7); + expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 7, 5, 7)); +}); + +it('right click inside the range keeps the selected ranges', () => { + const component = makeGridComponent(); + + mouseClick(3, 5, component); + mouseClick(3, 6, component, { ctrlKey: true }); + expect(component.state.selectedRanges.length).toBe(1); + expect(component.state.selectedRanges[0]).toEqual(new GridRange(3, 5, 3, 6)); + + mouseRightClick(3, 5, component); + expect(component.state.selectedRanges.length).toBe(1); + expect(component.state.selectedRanges[0]).toEqual(new GridRange(3, 5, 3, 6)); +}); + it('handles mouse drag down to update selection', () => { const component = makeGridComponent(); mouseDown(3, 5, component); diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index 04cdbf2ef5..84157d5264 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -355,6 +355,7 @@ class Grid extends PureComponent { this.handleMouseUp = this.handleMouseUp.bind(this); this.handleResize = this.handleResize.bind(this); this.handleWheel = this.handleWheel.bind(this); + this.getSelectedRanges = this.getSelectedRanges.bind(this); const { isStuckToBottom, @@ -976,6 +977,12 @@ class Grid extends PureComponent { } } + /** Gets the selected ranges */ + getSelectedRanges(): readonly GridRange[] { + const { selectedRanges } = this.state; + return selectedRanges; + } + /** * Begin a selection operation at the provided location * @param column Column where the selection is beginning diff --git a/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts b/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts index 0789bc5e6c..f36ac33934 100644 --- a/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts +++ b/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts @@ -1,7 +1,7 @@ import { EventHandlerResult } from '../EventHandlerResult'; import Grid from '../Grid'; -import { BoxCoordinates } from '../GridMetrics'; import GridMouseHandler, { GridMouseEvent } from '../GridMouseHandler'; +import GridRange from '../GridRange'; import GridUtils, { GridPoint } from '../GridUtils'; const DEFAULT_INTERVAL_MS = 100; @@ -16,8 +16,6 @@ class GridSelectionMouseHandler extends GridMouseHandler { private lastTriggerTime?: number; - private dragBounds?: BoxCoordinates; - onDown( gridPoint: GridPoint, grid: Grid, @@ -68,7 +66,6 @@ class GridSelectionMouseHandler extends GridMouseHandler { this.startPoint = gridPoint; this.hasExtendedFloating = false; - this.dragBounds = GridUtils.getScrollDragBounds(metrics, row, column); return true; } @@ -210,7 +207,6 @@ class GridSelectionMouseHandler extends GridMouseHandler { onUp(gridPoint: GridPoint, grid: Grid): EventHandlerResult { if (this.startPoint !== undefined) { this.startPoint = undefined; - this.dragBounds = undefined; this.stopTimer(); grid.commitSelection(); } @@ -218,6 +214,30 @@ class GridSelectionMouseHandler extends GridMouseHandler { return false; } + onContextMenu( + gridPoint: GridPoint, + grid: Grid, + event: GridMouseEvent + ): EventHandlerResult { + // check if the selected is already in the selected range + const selectedRanges = grid.getSelectedRanges(); + const isInRange = GridRange.containsCell( + selectedRanges, + gridPoint.column, + gridPoint.row + ); + + // only change the selected range if the selected cell is not in the selected range + if (!isInRange && gridPoint.row !== null) { + this.startPoint = undefined; + this.stopTimer(); + grid.clearSelectedRanges(); + grid.moveCursorToPosition(gridPoint.column, gridPoint.row); + } + + return false; + } + moveSelection( grid: Grid, gridPoint: GridPoint, diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.scss b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.scss index db5e3cf4b9..d3aff0f85b 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.scss +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.scss @@ -11,6 +11,11 @@ $padding-x: 2rem; overflow: hidden; } +.iris-grid-filter-menu-subtitle { + color: $text-muted; + font-size: small; +} + .advanced-filter-button-container { transition: $transition opacity; } diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx index 7cc3676f9a..48494089ca 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx @@ -21,6 +21,7 @@ import { Grid, GridMouseHandler, GridPoint, + GridRange, GridRenderer, isEditableGridModel, isExpandableGridModel, @@ -43,7 +44,13 @@ import { } from '@deephaven/jsapi-utils'; import Log from '@deephaven/log'; import type { DebouncedFunc } from 'lodash'; -import { assertNotNull, copyToClipboard } from '@deephaven/utils'; +import { + TextUtils, + assertNotEmpty, + assertNotNaN, + assertNotNull, + copyToClipboard, +} from '@deephaven/utils'; import { DateTimeFormatContextMenu, DecimalFormatContextMenu, @@ -58,6 +65,7 @@ const log = Log.module('IrisGridContextMenuHandler'); const DEBOUNCE_UPDATE_FORMAT = 150; const CONTEXT_MENU_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss.SSSSSSSSS'; +const MAX_MULTISELECT_ROWS = 1000; /** * Used to eat the mouse event in the bottom right corner of the scroll bar @@ -366,7 +374,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { grid: Grid, gridPoint: GridPoint ): ContextAction[] { - const { dh, irisGrid } = this; + const { irisGrid } = this; const { column: columnIndex, row: rowIndex } = gridPoint; const { model, canCopy } = irisGrid.props; const { columns } = model; @@ -376,186 +384,15 @@ class IrisGridContextMenuHandler extends GridMouseHandler { const { column: sourceColumn, row: sourceRow } = sourceCell; const value = model.valueForCell(sourceColumn, sourceRow); - const valueText = model.textForCell(sourceColumn, sourceRow); const column = columns[sourceColumn]; const actions = [] as ContextAction[]; - const { quickFilters } = irisGrid.state; const theme = irisGrid.getTheme(); const { filterIconColor } = theme; - const { settings } = irisGrid.props; - - const dateFilterFormatter = new DateTimeColumnFormatter(dh, { - timeZone: settings?.timeZone, - showTimeZone: false, - showTSeparator: true, - defaultDateTimeFormatString: CONTEXT_MENU_DATE_FORMAT, - }); - const previewFilterFormatter = new DateTimeColumnFormatter(dh, { - timeZone: settings?.timeZone, - showTimeZone: settings?.showTimeZone, - showTSeparator: settings?.showTSeparator, - defaultDateTimeFormatString: CONTEXT_MENU_DATE_FORMAT, - }); if (column == null || rowIndex == null) return actions; - // grid data area context menu options - if (model.isFilterable(sourceColumn)) { - // cell data area contextmenu options - const filterMenu = { - title: 'Filter by Value', - icon: vsRemove, - iconColor: filterIconColor, - group: IrisGridContextMenuHandler.GROUP_FILTER, - order: 10, - actions: [], - } as { - title: string; - icon: IconDefinition; - iconColor: string; - group: number; - order: number; - actions: ContextAction[]; - }; - - if (value == null) { - // null gets a special menu - if (quickFilters.get(sourceColumn)) { - filterMenu.actions.push({ - title: 'And', - actions: this.nullFilterActions( - column, - quickFilters.get(sourceColumn), - '&&' - ), - order: 2, - group: ContextActions.groups.high, - }); - } - filterMenu.actions.push(...this.nullFilterActions(column)); - } else if (value === '') { - // empty string gets a special menu - if (quickFilters.get(sourceColumn)) { - filterMenu.actions.push({ - title: 'And', - - actions: this.emptyStringFilterActions( - column, - quickFilters.get(sourceColumn), - '&&' - ), - order: 2, - group: ContextActions.groups.high, - }); - } - filterMenu.actions.push(...this.emptyStringFilterActions(column)); - } else if (TableUtils.isBooleanType(column.type)) { - // boolean should have OR condition, and handles it's own null menu options - if (quickFilters.get(sourceColumn)) { - filterMenu.actions.push({ - title: 'Or', - actions: this.booleanFilterActions( - column, - valueText, - quickFilters.get(sourceColumn), - '||' - ), - order: 2, - group: ContextActions.groups.high, - }); - } - filterMenu.actions.push( - ...this.booleanFilterActions(column, valueText) - ); - } else if ( - TableUtils.isNumberType(column.type) || - TableUtils.isCharType(column.type) - ) { - // Chars get treated like numbers in terms of which filters are available - assertNotNull(sourceColumn); - // We want to show the full unformatted value if it's a number, so user knows which value they are matching - // If it's a Char we just show the char - const numberValueText = TableUtils.isCharType(column.type) - ? String.fromCharCode(value as number) - : `${value}`; - - if (quickFilters.get(sourceColumn)) { - filterMenu.actions.push({ - title: 'And', - actions: this.numberFilterActions( - column, - numberValueText, - value, - quickFilters.get(sourceColumn), - '&&' - ), - order: 2, - group: ContextActions.groups.high, - }); - } - filterMenu.actions.push( - ...this.numberFilterActions( - column, - numberValueText, - value, - quickFilters.get(sourceColumn) - ) - ); - } else if (TableUtils.isDateType(column.type)) { - const dateValueText = dateFilterFormatter.format(value as Date); - const previewValue = previewFilterFormatter.format(value as Date); - if (quickFilters.get(sourceColumn)) { - filterMenu.actions.push({ - title: 'And', - actions: this.dateFilterActions( - column, - dateValueText, - previewValue, - value, - quickFilters.get(sourceColumn), - '&&' - ), - order: 2, - group: ContextActions.groups.high, - }); - } - filterMenu.actions.push( - ...this.dateFilterActions( - column, - dateValueText, - previewValue, - value, - quickFilters.get(sourceColumn) - ) - ); - } else { - if (quickFilters.get(sourceColumn)) { - filterMenu.actions.push({ - title: 'And', - - actions: this.stringFilterActions( - column, - valueText, - value, - quickFilters.get(sourceColumn), - '&&' - ), - order: 2, - group: ContextActions.groups.high, - }); - } - filterMenu.actions.push( - ...this.stringFilterActions(column, valueText, value) - ); - } - - if (filterMenu.actions != null && filterMenu.actions.length > 0) { - actions.push(filterMenu); - } - } - // Expand/Collapse options if (isExpandableGridModel(model) && model.isRowExpandable(sourceRow)) { // If there are grouped columns, then it is a rollup @@ -670,6 +507,255 @@ class IrisGridContextMenuHandler extends GridMouseHandler { return actions; } + // moved out of getCellActions since snapshots are async + async getCellFilterActions( + modelColumn: ModelIndex, + grid: Grid, + gridPoint: GridPoint + ): Promise { + const { dh, irisGrid } = this; + const { row: rowIndex } = gridPoint; + const { model } = irisGrid.props; + const { columns } = model; + const modelRow = irisGrid.getModelRow(rowIndex); + const { getSelectedRanges } = grid; + assertNotNull(modelRow); + const sourceCell = model.sourceForCell(modelColumn, modelRow); + const { column: sourceColumn, row: sourceRow } = sourceCell; + const column = columns[sourceColumn]; + + if (column == null || rowIndex == null) return []; + if (!model.isFilterable(sourceColumn)) return []; + + const { quickFilters } = irisGrid.state; + const theme = irisGrid.getTheme(); + const { filterIconColor } = theme; + const { settings } = irisGrid.props; + + let selectedRanges = [...getSelectedRanges()]; + // no selected range (i.e. right clicked a cell without highlighting it) + // although GridSelectionMouseHandler does change selectedRanges, state isn't updated in + // time for getSelectedRanges to show the selected cell + if (selectedRanges.length === 0) { + selectedRanges.push( + new GridRange(sourceColumn, sourceRow, sourceColumn, sourceRow) + ); + } + + // - this block truncates the selected ranges to MAX_MULTISELECT_ROWS rows + // - NOT first MAX_MULTISELECT_ROWS rows after the first row + // - NOT first MAX_MULTISELECT_ROWS unique values (prevent case where there are a small + // amount of values, but a large amount of rows with those values) + if (GridRange.containsCell(selectedRanges, sourceColumn, sourceRow)) { + let rowCount = GridRange.rowCount(selectedRanges); + while (rowCount > MAX_MULTISELECT_ROWS) { + const lastRow = selectedRanges.pop(); + // should never occur, sanity check + assertNotNull(lastRow, 'Selected ranges should not be empty'); + + const lastRowSize = GridRange.rowCount([lastRow]); + // should never occur, sanity check + assertNotNaN(lastRowSize, 'Selected ranges should not be unbounded'); + + // if removing the last rows makes it dip below the max, then need to + // bring it back but truncated + if (rowCount - lastRowSize < MAX_MULTISELECT_ROWS) { + // nullish operator to make TS happy, but the check above should prevent this + selectedRanges.push( + new GridRange( + lastRow.startColumn, + lastRow.startRow, + lastRow.endColumn, + (lastRow.endRow ?? 0) - (rowCount - MAX_MULTISELECT_ROWS) + ) + ); + break; + } + rowCount -= lastRowSize; + } + } else { + // if the block is not in the selected ranges, meaning the user must've right-clicked + // outside the selected ranges` + selectedRanges = [ + new GridRange(sourceColumn, sourceRow, sourceColumn, sourceRow), + ]; + } + + // this should be non empty + // - valid selected ranges will always have a startRow and endRow + // - if there are no selected ranges, then one with sourceColumn/Row is added + assertNotEmpty(selectedRanges); + + // get the snapshot values, but ignore all null/undefined values + const snapshot = await model.snapshot(selectedRanges); + const snapshotValues = new Set(); + for (let i = 0; i < snapshot.length; i += 1) { + if (snapshot[i].length === 1) { + // if the selected range has start/end columns defined, so the snapshot is a 1D array of the row + if (snapshot[i][0] != null) { + snapshotValues.add(snapshot[i][0]); + } + } else if (snapshot[i][sourceColumn] != null) { + // if the selected range is an entire row + snapshotValues.add(snapshot[i][sourceColumn]); + } + } + // if snapshotValues is empty here, it means all of the snapshot's values were null/undefined + + const filterMenu = { + title: `Filter by Value${snapshotValues.size > 1 ? 's' : ''}`, + icon: vsRemove, + iconColor: filterIconColor, + group: IrisGridContextMenuHandler.GROUP_FILTER, + order: 10, + actions: [], + } as { + title: string; + icon: IconDefinition; + iconColor: string; + group: number; + order: number; + actions: ContextAction[]; + }; + + // only made of null/undefineds + if (snapshotValues.size === 0) { + // null gets a special menu + if (quickFilters.get(sourceColumn)) { + filterMenu.actions.push({ + title: 'And', + actions: this.nullFilterActions( + column, + quickFilters.get(sourceColumn), + '&&' + ), + order: 2, + group: ContextActions.groups.high, + }); + } + filterMenu.actions.push(...this.nullFilterActions(column)); + } else if (snapshotValues.size === 1 && snapshotValues.has('')) { + // empty string gets a special menu + if (quickFilters.get(sourceColumn)) { + filterMenu.actions.push({ + title: 'And', + + actions: this.emptyStringFilterActions( + column, + quickFilters.get(sourceColumn), + '&&' + ), + order: 2, + group: ContextActions.groups.high, + }); + } + filterMenu.actions.push(...this.emptyStringFilterActions(column)); + } else if (TableUtils.isBooleanType(column.type)) { + // boolean should have OR condition, and handles it's own null menu options + if (quickFilters.get(sourceColumn)) { + filterMenu.actions.push({ + title: 'Or', + actions: this.booleanFilterActions( + column, + model.textForCell(sourceColumn, sourceRow), + quickFilters.get(sourceColumn), + '||' + ), + order: 2, + group: ContextActions.groups.high, + }); + } + filterMenu.actions.push( + ...this.booleanFilterActions( + column, + model.textForCell(sourceColumn, sourceRow) + ) + ); + } else if ( + TableUtils.isNumberType(column.type) || + TableUtils.isCharType(column.type) + ) { + // Chars get treated like numbers in terms of which filters are available + assertNotNull(sourceColumn); + + if (quickFilters.get(sourceColumn)) { + filterMenu.actions.push({ + title: 'And', + actions: this.numberFilterActions( + column, + snapshotValues as Set, + quickFilters.get(sourceColumn), + '&&' + ), + order: 2, + group: ContextActions.groups.high, + }); + } + filterMenu.actions.push( + ...this.numberFilterActions( + column, + snapshotValues as Set, + quickFilters.get(sourceColumn) + ) + ); + } else if (TableUtils.isDateType(column.type)) { + const dateFilterFormatter = new DateTimeColumnFormatter(dh, { + timeZone: settings?.timeZone, + showTimeZone: false, + showTSeparator: true, + defaultDateTimeFormatString: CONTEXT_MENU_DATE_FORMAT, + }); + const previewFilterFormatter = new DateTimeColumnFormatter(dh, { + timeZone: settings?.timeZone, + showTimeZone: settings?.showTimeZone, + showTSeparator: settings?.showTSeparator, + defaultDateTimeFormatString: CONTEXT_MENU_DATE_FORMAT, + }); + if (quickFilters.get(sourceColumn)) { + filterMenu.actions.push({ + title: 'And', + actions: this.dateFilterActions( + column, + snapshotValues as Set, + dateFilterFormatter, + previewFilterFormatter, + quickFilters.get(sourceColumn), + '&&' + ), + order: 2, + group: ContextActions.groups.high, + }); + } + filterMenu.actions.push( + ...this.dateFilterActions( + column, + snapshotValues as Set, + dateFilterFormatter, + previewFilterFormatter, + quickFilters.get(sourceColumn) + ) + ); + } else { + if (quickFilters.get(sourceColumn)) { + filterMenu.actions.push({ + title: 'And', + actions: this.stringFilterActions( + column, + snapshotValues as Set, + quickFilters.get(sourceColumn), + '&&' + ), + order: 2, + group: ContextActions.groups.high, + }); + } + filterMenu.actions.push( + ...this.stringFilterActions(column, snapshotValues as Set) + ); + } + return [filterMenu]; + } + /** * Gets an equality filter for the provided numeric value * @param column The column to make the filter for @@ -803,6 +889,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { // grid body context menu options if (modelColumn != null && modelRow != null) { actions.push(...this.getCellActions(modelColumn, grid, gridPoint)); + actions.push(this.getCellFilterActions(modelColumn, grid, gridPoint)); } // blank space context menu options @@ -949,13 +1036,16 @@ class IrisGridContextMenuHandler extends GridMouseHandler { stringFilterActions( column: Column, - valueText: string | null, - value?: unknown, + snapshotValues: Set, quickFilter?: QuickFilter, operator?: '&&' | '||' | null ): ContextAction[] { const { dh } = this; - const filterValue = dh.FilterValue.ofString(value); + const values = Array.from(snapshotValues.keys()); + const filterValues = values.map(value => dh.FilterValue.ofString(value)); + const valueDescription = + filterValues.length === 1 ? filterValues[0] : 'the selected values'; + let newQuickFilter: | { filter: null | FilterCondition | undefined; @@ -971,8 +1061,8 @@ class IrisGridContextMenuHandler extends GridMouseHandler { const { model } = this.irisGrid.props; const columnIndex = model.getColumnIndexByName(column.name); - const quickFilterValueText: string | null = - TableUtils.escapeQuickTextFilter(valueText); + const toFilterText = (item: string) => + TableUtils.escapeQuickTextFilter(item) ?? ''; assertNotNull(columnIndex); @@ -982,7 +1072,14 @@ class IrisGridContextMenuHandler extends GridMouseHandler { {operator ? IrisGridContextMenuHandler.getOperatorAsText(operator) : ''}{' '} - "{valueText}" + {TextUtils.join( + values.slice(0, 20).map(value => `"${toFilterText(value)}"`) + )} + {values.length > 1 && ( +
+ ({values.length} values selected) +
+ )} ), order: 1, @@ -991,18 +1088,20 @@ class IrisGridContextMenuHandler extends GridMouseHandler { actions.push({ title: 'text is exactly', - description: `Show only rows where ${column.name} is ${value} (case sensitive)`, + description: `Show only rows where ${column.name} is ${valueDescription} (case sensitive)`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().eq(filterValue), + filterValues + .map(filterValue => column.filter().eq(filterValue)) + .reduce((prev, curr) => prev.or(curr)), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `${quickFilterValueText}`, + values.map(toFilterText).join(' || '), operator ) ); @@ -1012,18 +1111,20 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'text is not exactly', - description: `Show only rows where ${column.name} is not ${valueText} (case sensitive)`, + description: `Show only rows where ${column.name} is not ${valueDescription} (case sensitive)`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().notEq(filterValue), + filterValues + .map(filterValue => column.filter().notEq(filterValue)) + .reduce((prev, curr) => prev.and(curr)), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `!=${quickFilterValueText}`, + values.map(value => `!=${toFilterText(value)}`).join(' && '), operator ) ); @@ -1033,7 +1134,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: `text contains`, - description: `Show only rows where ${column.name} contains ${valueText}`, + description: `Show only rows where ${column.name} contains ${valueDescription}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, @@ -1043,12 +1144,16 @@ class IrisGridContextMenuHandler extends GridMouseHandler { .filter() .isNull() .not() - .and(column.filter().contains(filterValue)), + .and( + filterValues + .map(filterValue => column.filter().contains(filterValue)) + .reduce((prev, curr) => prev.or(curr)) + ), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `~${quickFilterValueText}`, + values.map(value => `~${toFilterText(value)}`).join(' || '), operator ) ); @@ -1058,7 +1163,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'text does not contain', - description: `Show only rows where ${column.name} does not contain ${value}`, + description: `Show only rows where ${column.name} does not contain ${valueDescription}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, @@ -1067,12 +1172,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { column .filter() .isNull() - .or(column.filter().contains(filterValue).not()), + .or( + filterValues + .map(filterValue => + column.filter().contains(filterValue).not() + ) + .reduce((prev, curr) => prev.and(curr)) + ), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `!~${quickFilterValueText}`, + values.map(value => `!~${toFilterText(value)}`).join(' && '), operator ) ); @@ -1082,7 +1193,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'text starts with', - description: `Show only rows where ${column.name} starts with ${valueText}`, + description: `Show only rows where ${column.name} starts with ${valueDescription}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, @@ -1092,12 +1203,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { .filter() .isNull() .not() - .and(column.filter().invoke('startsWith', filterValue)), + .and( + filterValues + .map(filterValue => + column.filter().invoke('startsWith', filterValue) + ) + .reduce((prev, curr) => prev.or(curr)) + ), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `${quickFilterValueText}*`, + values.map(value => `${toFilterText(value)}*`).join(' || '), operator ) ); @@ -1107,7 +1224,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'text ends with', - description: `Show only rows where ${column.name} ends with ${valueText}`, + description: `Show only rows where ${column.name} ends with ${valueDescription}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, @@ -1117,12 +1234,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { .filter() .isNull() .not() - .and(column.filter().invoke('endsWith', filterValue)), + .and( + filterValues + .map(filterValue => + column.filter().invoke('endsWith', filterValue) + ) + .reduce((prev, curr) => prev.or(curr)) + ), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `*${quickFilterValueText}`, + values.map(value => `*${toFilterText(value)}`).join(' || '), operator ) ); @@ -1135,12 +1258,19 @@ class IrisGridContextMenuHandler extends GridMouseHandler { numberFilterActions( column: Column, - valueText: string, - value: unknown, + snapshotValues: Set, quickFilter?: QuickFilter | null, operator?: '&&' | '||' | null ): ContextAction[] { - const filterValue = this.getFilterValueForNumberOrChar(column.type, value); + const values = Array.from(snapshotValues.keys()); + const valueDesc = values.length === 1 ? `${values}` : 'the selected values'; + // We want to show the full unformatted value if it's a number, so user knows which value they are matching + // If it's a Char we just show the char + const toFilterText = (item: number) => + TableUtils.isCharType(column.type) + ? String.fromCharCode(item as number) + : `${item}`; + let filter: FilterCondition | null = null; let filterText: string | null = null; if (quickFilter) { @@ -1148,10 +1278,6 @@ class IrisGridContextMenuHandler extends GridMouseHandler { filterText = quickFilter.text; } const actions = []; - const isFinite = - value !== Number.POSITIVE_INFINITY && - value !== Number.NEGATIVE_INFINITY && - !Number.isNaN(value); const { model } = this.irisGrid.props; const columnIndex = model.getColumnIndexByName(column.name); assertNotNull(columnIndex); @@ -1161,7 +1287,14 @@ class IrisGridContextMenuHandler extends GridMouseHandler { {operator ? IrisGridContextMenuHandler.getOperatorAsText(operator) : ''}{' '} - "{valueText}" + {TextUtils.join( + values.slice(0, 20).map(value => `"${toFilterText(value)}"`) + )} + {values.length > 1 && ( +
+ ({values.length} values selected) +
+ )} ), order: 1, @@ -1169,22 +1302,22 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'is equal to', - description: `Show only rows where ${column.name} is ${valueText}`, + description: `Show only rows where ${column.name} is ${valueDesc}`, action: () => { - const valueFilter = this.getNumberValueEqualsFilter( - column, - value as number - ); this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - valueFilter, + values + .map(value => + this.getNumberValueEqualsFilter(column, value as number) + ) + .reduce((acc, curr) => acc.or(curr)), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `=${valueText}`, + values.map(value => `=${toFilterText(value)}`).join(' || '), operator ) ); @@ -1194,22 +1327,22 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'is not equal to', - description: `Show only rows where ${column.name} is not ${valueText}`, + description: `Show only rows where ${column.name} is not ${valueDesc}`, action: () => { - const valueFilter = this.getNumberValueEqualsFilter( - column, - value as number - ).not(); this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - valueFilter, + values + .map(value => + this.getNumberValueEqualsFilter(column, value as number).not() + ) + .reduce((acc, curr) => acc.and(curr)), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `!=${valueText}`, + values.map(value => `!=${toFilterText(value)}`).join(' && '), operator ) ); @@ -1221,21 +1354,40 @@ class IrisGridContextMenuHandler extends GridMouseHandler { // IDS-6092 Less/greater than filters don't make sense for Infinite/NaN // TODO (DH-11799): These char filters should work in Bard, with the merge for DH-11040: https://gitlab.eng.illumon.com/illumon/iris/merge_requests/5801 // They do not work in Powell though, so disable them. - if (isFinite && !TableUtils.isCharType(column.type)) { + if ( + !snapshotValues.has(Number.NaN) && + !snapshotValues.has(Number.POSITIVE_INFINITY) && + !snapshotValues.has(Number.NEGATIVE_INFINITY) && + !TableUtils.isCharType(column.type) + ) { + // get the min/max because these are all ge/ne filters + const maxValue = values.reduce((a, b) => (a > b ? a : b)); + const minValue = values.reduce((a, b) => (a < b ? a : b)); + const maxFilterValue = this.getFilterValueForNumberOrChar( + column.type, + maxValue + ); + const minFilterValue = this.getFilterValueForNumberOrChar( + column.type, + minValue + ); + const maxValueText = `${maxFilterValue}`; + const minValueText = `${minFilterValue}`; + actions.push({ title: 'greater than', - description: `Show only rows where ${column.name} is greater than ${valueText}`, + description: `Show only rows where ${column.name} is greater than ${maxValueText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().greaterThan(filterValue), + column.filter().greaterThan(maxFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `>${valueText}`, + `>${toFilterText(maxValue)}`, operator ) ); @@ -1245,18 +1397,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'greater than or equal to', - description: `Show only rows where ${column.name} is greater than or equal to ${valueText}`, + description: `Show only rows where ${column.name} is greater than or equal to ${maxValueText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().greaterThanOrEqualTo(filterValue), + column.filter().greaterThanOrEqualTo(maxFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `>=${valueText}`, + `>=${toFilterText(maxValue)}`, operator ) ); @@ -1266,18 +1418,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'less than', - description: `Show only rows where ${column.name} is less than ${valueText}`, + description: `Show only rows where ${column.name} is less than ${minValueText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().lessThan(filterValue), + column.filter().lessThan(minFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `<${valueText}`, + `<${toFilterText(minValue)}`, operator ) ); @@ -1287,18 +1439,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'less than or equal to', - description: `Show only rows where ${column.name} is less than or equal to ${valueText}`, + description: `Show only rows where ${column.name} is less than or equal to ${minValueText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().lessThanOrEqualTo(filterValue), + column.filter().lessThanOrEqualTo(minFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `<=${valueText}`, + `<=${toFilterText(minValue)}`, operator ) ); @@ -1307,6 +1459,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { group: ContextActions.groups.low, }); } + return actions; } @@ -1427,14 +1580,33 @@ class IrisGridContextMenuHandler extends GridMouseHandler { dateFilterActions( column: Column, - valueText: string, - previewValue: unknown, - value: unknown, + snapshotValues: Set, + dateFilterFormatter: DateTimeColumnFormatter, + previewFilterFormatter: DateTimeColumnFormatter, quickFilter?: QuickFilter | null, operator?: '&&' | '||' | null ): ContextAction[] { const { dh } = this; - const filterValue = dh.FilterValue.ofNumber(value); + + const values = Array.from(snapshotValues.keys()); + const filterValues = values.map(value => dh.FilterValue.ofNumber(value)); + const valueDesc = + filterValues.length === 1 + ? previewFilterFormatter.format(values[0] as Date) + : 'the selected values'; + + const maxValue = values.reduce((a, b) => + (a as Date) > (b as Date) ? a : b + ); + const minValue = values.reduce((a, b) => + (a as Date) < (b as Date) ? a : b + ); + const maxFilterValue = dh.FilterValue.ofNumber(maxValue); + const minFilterValue = dh.FilterValue.ofNumber(minValue); + const maxDateText = dateFilterFormatter.format(maxValue as Date); + const minDateText = dateFilterFormatter.format(minValue as Date); + const maxPreviewText = previewFilterFormatter.format(maxValue as Date); + const minPreviewText = previewFilterFormatter.format(minValue as Date); let filter: FilterCondition | null = null; let filterText: string | null = null; @@ -1454,7 +1626,16 @@ class IrisGridContextMenuHandler extends GridMouseHandler { {operator ? IrisGridContextMenuHandler.getOperatorAsText(operator) : ''}{' '} - "{previewValue}" + {TextUtils.join( + values + .slice(0, 20) + .map(value => `"${previewFilterFormatter.format(value as Date)}"`) + )} + {values.length > 1 && ( +
+ ({values.length} values selected) +
+ )} ), order: 1, @@ -1462,18 +1643,22 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'date is', - description: `Show only rows where ${column.name} is ${previewValue}`, + description: `Show only rows where ${column.name} is ${valueDesc}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().eq(filterValue), + filterValues + .map(valueFilter => column.filter().eq(valueFilter)) + .reduce((acc, curr) => acc.or(curr)), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `=${valueText}`, + values + .map(value => `=${dateFilterFormatter.format(value as Date)}`) + .join(' || '), operator ) ); @@ -1483,18 +1668,22 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'date is not', - description: `Show only rows where ${column.name} is not ${previewValue}`, + description: `Show only rows where ${column.name} is not ${valueDesc}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().notEq(filterValue), + filterValues + .map(valueFilter => column.filter().notEq(valueFilter)) + .reduce((acc, curr) => acc.and(curr)), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `!=${valueText}`, + values + .map(value => `!=${dateFilterFormatter.format(value as Date)}`) + .join(' && '), operator ) ); @@ -1504,18 +1693,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'date is before', - description: `Show only rows where ${column.name} is before ${previewValue}`, + description: `Show only rows where ${column.name} is before ${minPreviewText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().lessThan(filterValue), + column.filter().lessThan(minFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `<${valueText}`, + `<${minDateText}`, operator ) ); @@ -1525,18 +1714,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'date is before or equal', - description: `Show only rows where ${column.name} is before or equal to ${previewValue}`, + description: `Show only rows where ${column.name} is before or equal to ${minPreviewText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().lessThanOrEqualTo(filterValue), + column.filter().lessThanOrEqualTo(minFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `<=${valueText}`, + `<=${minDateText}`, operator ) ); @@ -1546,18 +1735,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'date is after', - description: `Show only rows where ${column.name} is greater than ${previewValue}`, + description: `Show only rows where ${column.name} is greater than ${maxPreviewText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().greaterThan(filterValue), + column.filter().greaterThan(maxFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `>${valueText}`, + `>${maxDateText}`, operator ) ); @@ -1567,18 +1756,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }); actions.push({ title: 'date is after or equal', - description: `Show only rows where ${column.name} is after or equal to ${previewValue}`, + description: `Show only rows where ${column.name} is after or equal to ${maxPreviewText}`, action: () => { this.irisGrid.setQuickFilter( columnIndex, IrisGridContextMenuHandler.getQuickFilterCondition( filter, - column.filter().greaterThanOrEqualTo(filterValue), + column.filter().greaterThanOrEqualTo(maxFilterValue), operator ), IrisGridContextMenuHandler.getQuickFilterText( filterText, - `>=${valueText}`, + `>=${maxDateText}`, operator ) ); diff --git a/packages/utils/src/Asserts.test.ts b/packages/utils/src/Asserts.test.ts index 95593b9604..de285fc87b 100644 --- a/packages/utils/src/Asserts.test.ts +++ b/packages/utils/src/Asserts.test.ts @@ -1,4 +1,10 @@ -import { assertNever, assertNotNull, getOrThrow } from './Asserts'; +import { + assertNever, + assertNotEmpty, + assertNotNaN, + assertNotNull, + getOrThrow, +} from './Asserts'; describe('assertNever', () => { it.each([undefined, 'mock.name'])('should throw if called', name => { @@ -19,6 +25,20 @@ it('throws an error when a value is null', () => { ); }); +describe('assertNotEmpty', () => { + expect(() => assertNotEmpty(new Map())).toThrowError('Size of value is 0'); + expect(() => assertNotEmpty(new Map([[1, 2]]))).not.toThrowError( + 'Size of value is 0' + ); + expect(() => assertNotEmpty([])).toThrowError('Size of value is 0'); + expect(() => assertNotEmpty([1, 2])).not.toThrowError('Size of value is 0'); +}); + +describe('assertNotNaN', () => { + expect(() => assertNotNaN(NaN)).toThrowError('Value is NaN'); + expect(() => assertNotNaN(0)).not.toThrowError('Value is NaN'); +}); + describe('getOrThrow', () => { const MAP = new Map([ [5, 10], diff --git a/packages/utils/src/Asserts.ts b/packages/utils/src/Asserts.ts index df07772fe5..2d76f4c320 100644 --- a/packages/utils/src/Asserts.ts +++ b/packages/utils/src/Asserts.ts @@ -36,6 +36,21 @@ export function assertNotNull( if (value == null) throw new Error(message); } +export function assertNotEmpty( + value: Map | Array, + message = 'Size of value is 0' +): void { + if (value instanceof Map) { + if (value.size === 0) throw new Error(message); + } else if (value.length === 0) { + throw new Error(message); + } +} + +export function assertNotNaN(value: number, message = 'Value is NaN'): void { + if (Number.isNaN(value)) throw new Error(message); +} + /** * Retrieve a value from a map. If the value is not found and no default value is provided, throw. * Use when the value _must_ be present diff --git a/tests/table-multiselect.spec.ts b/tests/table-multiselect.spec.ts new file mode 100644 index 0000000000..e7e71a98da --- /dev/null +++ b/tests/table-multiselect.spec.ts @@ -0,0 +1,272 @@ +import { test, expect, Page } from '@playwright/test'; +import { pasteInMonaco } from './utils'; + +const rowHeight = 19; +const columnHeight = 30; +const filterHeight = 30; + +async function waitForLoadingDone(page: Page) { + await expect( + page.locator('.iris-grid .iris-grid-loading-status') + ).toHaveCount(0); +} + +async function getGridLocation(page: Page) { + const grid = await page.locator('.iris-grid-panel .iris-grid'); + const gridLocation = await grid.boundingBox(); + expect(gridLocation).not.toBeNull(); + return gridLocation; +} + +async function createSingleColumnTable(page: Page, cmd: string) { + const consoleInput = page.locator('.console-input'); + await pasteInMonaco(consoleInput, cmd); + await page.keyboard.press('Enter'); + + // wait for panel to show, finish loading, and data to be loaded + await expect(page.locator('.iris-grid-panel')).toHaveCount(1); + await expect(page.locator('.iris-grid-panel .loading-spinner')).toHaveCount( + 0 + ); + await waitForLoadingDone(page); +} + +async function filterAndScreenshot( + page: Page, + gridLocation: { + x: number; + y: number; + width: number; + height: number; + }, + filterType: string, + screenshotName: string +) { + // select the first 3 rows + await page.mouse.move( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeight + filterHeight + ); + await page.mouse.down(); + await page.mouse.move( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeight + filterHeight + rowHeight * 2 + ); + await page.mouse.up(); + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeight + filterHeight + rowHeight * 2, + { button: 'right' } + ); + // apply filter + await page.getByRole('button', { name: 'Filter by Values' }).hover(); + await page.getByRole('button', { name: filterType, exact: true }).click(); + await waitForLoadingDone(page); + await expect(page.locator('.iris-grid-column')).toHaveScreenshot( + screenshotName + ); + + // reset + await page.keyboard.down('Control'); + await page.keyboard.press('E'); + await page.keyboard.up('Control'); + await waitForLoadingDone(page); +} + +function runMultiselectFilter( + testName: string, + testFilePrefix: string, + cmd: string, + filters: { filter: string; name: string }[] +) { + test(testName, async ({ page }) => { + await page.goto(''); + await createSingleColumnTable(page, cmd); + const gridLocation = await getGridLocation(page); + if (gridLocation === null) return; + + // activate the quick filter to get that text as well + await page.mouse.click(gridLocation.x + 805, gridLocation.y + 1); + await page.keyboard.down('Control'); + await page.keyboard.press('F'); + await page.keyboard.up('Control'); + + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < filters.length; i += 1) { + await filterAndScreenshot( + page, + gridLocation, + filters[i].filter, + `multi-${testFilePrefix}-${i + 1}-${filters[i].name}.png` + ); + } + /* eslint-enable no-await-in-loop */ + }); +} + +// these are select filters that do not do multiselect +function runSpecialSelectFilter( + testName: string, + cmd: string, + expectedButtons: string[] +) { + test(testName, async ({ page }) => { + await page.goto(''); + await createSingleColumnTable(page, cmd); + + const gridLocation = await getGridLocation(page); + if (gridLocation === null) return; + + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeight, + { button: 'right' } + ); + + await page.getByRole('button', { name: 'Filter by Value' }).hover(); + await Promise.all( + expectedButtons.map(async button => { + await expect(page.getByRole('button', { name: button })).toBeVisible(); + }) + ); + }); +} + +// null, empty string, and bool, where +runSpecialSelectFilter( + 'null select filter', + ` +from deephaven.column import string_col +from deephaven import new_table + +my_table = new_table([ + string_col("MultiselectTestData", [None]) +])`, + ['is null', 'is not null'] +); +runSpecialSelectFilter( + 'empty string select filter', + ` +from deephaven.column import string_col +from deephaven import new_table + +my_table = new_table([ + string_col("MultiselectTestData", [""]) +])`, + ['is empty string', 'is not empty string'] +); +runSpecialSelectFilter( + 'bool select filter', + ` +from deephaven.column import bool_col +from deephaven import new_table + +my_table = new_table([ + bool_col("MultiselectTestData", [True]) +])`, + ['true', 'false', 'is null', 'is not null'] +); + +// the other types +runMultiselectFilter( + 'multiselect string filters', + 'string', + ` +from deephaven.column import string_col +from deephaven import new_table + +my_table = new_table([ + string_col("MultiselectTestData", ["A", "ABA", None, "ABABA", "AA", "ABAA", "AABA", "a", "aba", "ababa", "aa", "abaa", "aaba"]) +])`, + [ + { name: 'is', filter: 'text is exactly' }, + { name: 'not-is', filter: 'text is not exactly' }, + { name: 'contains', filter: 'text contains' }, + { name: 'not-contains', filter: 'text does not contain' }, + { name: 'starts', filter: 'text starts with' }, + { name: 'ends', filter: 'text ends with' }, + ] +); +runMultiselectFilter( + 'multiselect number filters', + 'number', + ` +from deephaven.column import double_col +from deephaven import new_table + +my_table = new_table([ + double_col("MultiselectTestData", [1, 2, None, 3, 4, 0, -1.1, 1.1]) +])`, + [ + { name: 'equal', filter: 'is equal to' }, + { name: 'not-equal', filter: 'is not equal to' }, + { name: 'greater', filter: 'greater than' }, + { name: 'greater-eq', filter: 'greater than or equal to' }, + { name: 'less', filter: 'less than' }, + { name: 'less-eq', filter: 'less than or equal to' }, + ] +); +runMultiselectFilter( + 'multiselect date filters', + 'date', + ` +from deephaven.time import to_j_instant +from deephaven import new_table +from deephaven.column import datetime_col + +t1 = to_j_instant("2021-06-02T08:00:02 ET") +t2 = to_j_instant("2021-06-03T08:00:03 ET") +t3 = to_j_instant("2021-06-04T08:00:04 ET") +t4 = to_j_instant("2021-06-01T08:00:01 ET") + +result = new_table([ + datetime_col("MultiselectTestData", [t1, t2, None, t3, t4]) +])`, + [ + { name: 'is', filter: 'date is' }, + { name: 'not-is', filter: 'date is not' }, + { name: 'before', filter: 'date is before' }, + { name: 'before-eq', filter: 'date is before or equal' }, + { name: 'after', filter: 'date is after' }, + { name: 'after-eq', filter: 'date is after or equal' }, + ] +); + +// misc tests +test('char formatting, non selected right click, preview formatting', async ({ + page, +}) => { + await page.goto(''); + await createSingleColumnTable( + page, + ` +from deephaven.column import char_col +from deephaven import new_table + +my_table = new_table([ + char_col("MultiselectTestData", [97, 98, 99, 100, 101]) +])` + ); + const gridLocation = await getGridLocation(page); + if (gridLocation === null) return; + + // select row 2, 4 + await page.keyboard.down('Control'); + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeight + rowHeight + ); + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeight + rowHeight * 3 + ); + await page.keyboard.up('Control'); + + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeight, + { button: 'right' } + ); + await page.getByRole('button', { name: 'Filter by Value' }).hover(); + await expect(page.getByText('"a"', { exact: true })).toHaveCount(1); +}); diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-chromium-linux.png new file mode 100644 index 0000000000..9b5a047134 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-firefox-linux.png new file mode 100644 index 0000000000..44bfdd8eaa Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-webkit-linux.png new file mode 100644 index 0000000000..b4b8143a2c Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-1-is-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-chromium-linux.png new file mode 100644 index 0000000000..be6da34967 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-firefox-linux.png new file mode 100644 index 0000000000..afa2bab396 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-webkit-linux.png new file mode 100644 index 0000000000..6f20757c81 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-2-not-is-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-chromium-linux.png new file mode 100644 index 0000000000..7cffe705af Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-firefox-linux.png new file mode 100644 index 0000000000..2411965c1b Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-webkit-linux.png new file mode 100644 index 0000000000..380b92d613 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-3-before-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-chromium-linux.png new file mode 100644 index 0000000000..118cdb8085 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-firefox-linux.png new file mode 100644 index 0000000000..29934b8ccf Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-webkit-linux.png new file mode 100644 index 0000000000..3d4261f49a Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-4-before-eq-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-chromium-linux.png new file mode 100644 index 0000000000..9a08aa526a Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-firefox-linux.png new file mode 100644 index 0000000000..452fe41a69 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-webkit-linux.png new file mode 100644 index 0000000000..17a06e586b Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-5-after-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-chromium-linux.png new file mode 100644 index 0000000000..55f55b8ffe Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-firefox-linux.png new file mode 100644 index 0000000000..953d30d858 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-webkit-linux.png new file mode 100644 index 0000000000..be53726104 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-date-6-after-eq-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-chromium-linux.png new file mode 100644 index 0000000000..b2f724c02b Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-firefox-linux.png new file mode 100644 index 0000000000..386cd915d4 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-webkit-linux.png new file mode 100644 index 0000000000..18ecf1f4ab Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-1-equal-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-chromium-linux.png new file mode 100644 index 0000000000..e0e084b73e Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-firefox-linux.png new file mode 100644 index 0000000000..08a4c635fa Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-webkit-linux.png new file mode 100644 index 0000000000..2c39c620ef Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-2-not-equal-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-chromium-linux.png new file mode 100644 index 0000000000..4ee2e11a4b Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-firefox-linux.png new file mode 100644 index 0000000000..867859066a Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-webkit-linux.png new file mode 100644 index 0000000000..e7e2ac111e Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-3-greater-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-chromium-linux.png new file mode 100644 index 0000000000..0e355c8f2f Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-firefox-linux.png new file mode 100644 index 0000000000..faec78fd42 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-webkit-linux.png new file mode 100644 index 0000000000..a91accfcce Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-4-greater-eq-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-chromium-linux.png new file mode 100644 index 0000000000..ece639cd29 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-firefox-linux.png new file mode 100644 index 0000000000..1bc56ab9dc Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-webkit-linux.png new file mode 100644 index 0000000000..21a15dc146 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-5-less-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-chromium-linux.png new file mode 100644 index 0000000000..f64972432b Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-firefox-linux.png new file mode 100644 index 0000000000..0d32db7ab0 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-webkit-linux.png new file mode 100644 index 0000000000..bb84d28435 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-number-6-less-eq-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-chromium-linux.png new file mode 100644 index 0000000000..a8c5bd5a50 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-firefox-linux.png new file mode 100644 index 0000000000..cae74e2235 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-webkit-linux.png new file mode 100644 index 0000000000..d54f4aefc9 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-1-is-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-chromium-linux.png new file mode 100644 index 0000000000..f8507043eb Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-firefox-linux.png new file mode 100644 index 0000000000..e6efc08919 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-webkit-linux.png new file mode 100644 index 0000000000..5dfaa1eacb Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-2-not-is-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-chromium-linux.png new file mode 100644 index 0000000000..bc19a04dd4 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-firefox-linux.png new file mode 100644 index 0000000000..6c09bf84d4 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-webkit-linux.png new file mode 100644 index 0000000000..1b5fbfb5ff Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-3-contains-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-chromium-linux.png new file mode 100644 index 0000000000..38ddd13bd4 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-firefox-linux.png new file mode 100644 index 0000000000..ac0ee24d63 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-webkit-linux.png new file mode 100644 index 0000000000..eeb749c453 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-4-not-contains-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-chromium-linux.png new file mode 100644 index 0000000000..618149ad59 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-firefox-linux.png new file mode 100644 index 0000000000..830488c02b Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-webkit-linux.png new file mode 100644 index 0000000000..85e722c110 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-5-starts-webkit-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-chromium-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-chromium-linux.png new file mode 100644 index 0000000000..d3e057555b Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-chromium-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-firefox-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-firefox-linux.png new file mode 100644 index 0000000000..8819ed9c15 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-firefox-linux.png differ diff --git a/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-webkit-linux.png b/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-webkit-linux.png new file mode 100644 index 0000000000..c23e642857 Binary files /dev/null and b/tests/table-multiselect.spec.ts-snapshots/multi-string-6-ends-webkit-linux.png differ