diff --git a/packages/grid/src/GridMetricCalculator.ts b/packages/grid/src/GridMetricCalculator.ts index 4d9b00971..0fbf17c42 100644 --- a/packages/grid/src/GridMetricCalculator.ts +++ b/packages/grid/src/GridMetricCalculator.ts @@ -616,7 +616,7 @@ export class GridMetricCalculator { visibleRows, visibleColumns, - // Map of the height/width of visible rows/columns + // Map of the height/width of columns in the viewport (excluding floating columns) visibleRowHeights, visibleColumnWidths, @@ -632,7 +632,7 @@ export class GridMetricCalculator { allRows, allColumns, - // Map of the height/width of visible rows/columns + // Map of the height/width of all rendered columns (visible + floating + dragging) allRowHeights, allColumnWidths, diff --git a/packages/iris-grid/src/FilterInputField.tsx b/packages/iris-grid/src/FilterInputField.tsx index 0c7c018c6..8c7709e64 100644 --- a/packages/iris-grid/src/FilterInputField.tsx +++ b/packages/iris-grid/src/FilterInputField.tsx @@ -15,6 +15,7 @@ interface FilterInputFieldProps { className: string; style: React.CSSProperties; value: string; + showAdvancedFilterButton: boolean; isAdvancedFilterSet: boolean; onAdvancedFiltersTriggered: React.MouseEventHandler; onChange: (value: string) => void; @@ -39,6 +40,7 @@ class FilterInputField extends PureComponent< style: {}, className: '', value: '', + showAdvancedFilterButton: false, isAdvancedFilterSet: false, onAdvancedFiltersTriggered: (): void => undefined, onChange: (): void => undefined, @@ -207,6 +209,7 @@ class FilterInputField extends PureComponent< const { className, style, + showAdvancedFilterButton, isAdvancedFilterSet, onAdvancedFiltersTriggered, } = this.props; @@ -234,21 +237,26 @@ class FilterInputField extends PureComponent< autoCapitalize="off" spellCheck="false" /> -
- -
+ {showAdvancedFilterButton && ( +
+ +
+ )} ); } diff --git a/packages/iris-grid/src/IrisGrid.test.tsx b/packages/iris-grid/src/IrisGrid.test.tsx index ca14728ef..3606e4bfc 100644 --- a/packages/iris-grid/src/IrisGrid.test.tsx +++ b/packages/iris-grid/src/IrisGrid.test.tsx @@ -359,14 +359,18 @@ describe('handleResizeAllColumns', () => { }); jest.spyOn(component, 'setState'); expect(component.setState).not.toBeCalled(); - component.rebuildFilters(); + act(() => { + component.rebuildFilters(); + }); expect(component.setState).toBeCalled(); }); it('does not update state for empty filters', () => { const component = makeComponent(); jest.spyOn(component, 'setState'); - component.rebuildFilters(); + act(() => { + component.rebuildFilters(); + }); expect(component.setState).not.toBeCalled(); }); }); @@ -430,3 +434,33 @@ describe('handleResizeAllColumns', () => { }); }); }); + +describe('Advanced Filter', () => { + it.each([ + { columnIndex: -1, expectedVisibility: false }, + { columnIndex: 0, expectedVisibility: true }, + { columnIndex: 1, expectedVisibility: true }, + ])( + 'advanced filter button visibility is $expectedVisibility for column index $columnIndex', + ({ columnIndex, expectedVisibility }) => { + const model = irisGridTestUtils.makeModel(); + const ref = React.createRef(); + const { container } = render( + + ); + + act(() => { + ref.current?.setState({ + focusedFilterBarColumn: columnIndex, + isFilterBarShown: true, + }); + }); + + const advancedFilterButtons = container.querySelectorAll( + '.advanced-filter-button' + ); + + expect(advancedFilterButtons.length > 0).toBe(expectedVisibility); + } + ); +}); diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index af051d98a..235da947d 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -2513,7 +2513,6 @@ class IrisGrid extends Component { if ( column == null || - column < 0 || columnCount <= column || !model.isFilterable(modelColumn) ) { @@ -2523,22 +2522,19 @@ class IrisGrid extends Component { const { metricCalculator, metrics } = this.state; assertNotNull(metrics); - const { left, rightVisible, lastLeft } = metrics; - if (column < left) { - this.grid?.setViewState({ left: column }, true); - } else if (rightVisible < column) { - const metricState = this.getMetricState(); - assertNotNull(metricState); - const newLeft = metricCalculator.getLastLeft( - metricState, - column, - metricCalculator.getVisibleWidth(metricState) - ); - this.grid?.setViewState( - { left: Math.min(newLeft, lastLeft), leftOffset: 0 }, - true - ); + const metricState = this.getMetricState(); + assertNotNull(metricState); + + const scrollColumn = metricCalculator.getScrollLeftForColumn( + column, + metricState, + metrics + ); + + if (scrollColumn != null) { + this.grid?.setViewState({ left: scrollColumn, leftOffset: 0 }, true); } + this.lastFocusedFilterBarColumn = column; this.setState({ focusedFilterBarColumn: column, isFilterBarShown: true }); } @@ -4515,6 +4511,99 @@ class IrisGrid extends Component { this.seekRow(gotoValue, isBackwards); } + /** + * Render the input field for the focused filter + * @param metrics Grid metrics + * @param metricCalculator Metric calculator + * @param focusedFilterBarColumn Column index for the focused filter + * @param quickFilters Quick filters map + * @param advancedFilters Advanced filters map + * @returns The filter input field element or null if not applicable + */ + getFilterBarInput( + metrics: GridMetrics | undefined, + metricCalculator: IrisGridMetricCalculator, + focusedFilterBarColumn: VisibleIndex | null, + quickFilters: ReadonlyQuickFilterMap, + advancedFilters: ReadonlyAdvancedFilterMap + ): ReactElement | null { + if (metrics == null || focusedFilterBarColumn == null) { + return null; + } + + const metricState = this.getMetricState(); + if (metricState == null) { + return null; + } + + const filterBoxCoordinates = metricCalculator.getFilterInputCoordinates( + focusedFilterBarColumn, + metricState, + metrics + ); + if (filterBoxCoordinates == null) { + return null; + } + + const debounceMs = Math.min( + Math.max(IrisGrid.minDebounce, Math.round(metrics.rowCount / 200)), + IrisGrid.maxDebounce + ); + const { + x, + y, + width: fieldWidth, + height: fieldHeight, + } = filterBoxCoordinates; + const { width } = metrics; + const style = { + top: y, + left: x, + minWidth: Math.min(fieldWidth, width - x), // Don't cause overflow + height: fieldHeight, + }; + let value = ''; + let isValid = true; + const modelColumn = this.getModelColumn(focusedFilterBarColumn); + assertNotNull(modelColumn); + const quickFilter = quickFilters.get(modelColumn); + const advancedFilter = advancedFilters.get(modelColumn); + if (quickFilter != null) { + value = quickFilter.text; + isValid = quickFilter.filter != null; + } + const isBarFiltered = quickFilters.size !== 0 || advancedFilters.size !== 0; + const showAdvancedFilterButton = + metricCalculator.getAdvancedFilterButtonCoordinates( + focusedFilterBarColumn, + metricState, + metrics + ) != null; + return ( + { + this.setState({ shownAdvancedFilter: focusedFilterBarColumn }); + }} + key={focusedFilterBarColumn} + onChange={this.handleFilterBarChange} + onDone={this.handleFilterBarDone} + onTab={this.handleFilterBarTab} + onContextMenu={this.grid?.handleContextMenu} + debounceMs={debounceMs} + value={value} + /> + ); + } + render(): ReactElement | null { const { children, @@ -4649,66 +4738,15 @@ class IrisGrid extends Component { metrics != null && metrics.width > 0 && metrics.height > 0; const isRollup = (rollupConfig?.columns?.length ?? 0) > 0; - let focusField = null; - - const debounceMs = metrics - ? Math.min( - Math.max(IrisGrid.minDebounce, Math.round(metrics.rowCount / 200)), - IrisGrid.maxDebounce + const focusField = isFilterBarShown + ? this.getFilterBarInput( + metrics, + metricCalculator, + focusedFilterBarColumn, + quickFilters, + advancedFilters ) - : IrisGrid.maxDebounce; - - if (isFilterBarShown && focusedFilterBarColumn != null && metrics != null) { - const { gridX, gridY, allColumnXs, allColumnWidths, width } = metrics; - const columnX = allColumnXs.get(focusedFilterBarColumn); - const columnWidth = allColumnWidths.get(focusedFilterBarColumn); - if (columnX != null && columnWidth != null) { - const x = gridX + columnX; - const y = gridY - (theme.filterBarHeight ?? 0); - const fieldWidth = columnWidth + 1; // cover right border - const fieldHeight = (theme.filterBarHeight ?? 0) - 1; // remove bottom border - const style = { - top: y, - left: x, - minWidth: Math.min(fieldWidth, width - x), // Don't cause overflow - height: fieldHeight, - }; - let value = ''; - let isValid = true; - const modelColumn = this.getModelColumn(focusedFilterBarColumn); - assertNotNull(modelColumn); - const quickFilter = quickFilters.get(modelColumn); - const advancedFilter = advancedFilters.get(modelColumn); - if (quickFilter != null) { - value = quickFilter.text; - isValid = quickFilter.filter != null; - } - const isBarFiltered = - quickFilters.size !== 0 || advancedFilters.size !== 0; - focusField = ( - { - this.setState({ shownAdvancedFilter: focusedFilterBarColumn }); - }} - key={focusedFilterBarColumn} - onChange={this.handleFilterBarChange} - onDone={this.handleFilterBarDone} - onTab={this.handleFilterBarTab} - onContextMenu={this.grid?.handleContextMenu} - debounceMs={debounceMs} - value={value} - /> - ); - } - } + : null; let loadingElement = null; if (loadingText != null) { @@ -4753,26 +4791,27 @@ class IrisGrid extends Component { const filterBar = []; if (metrics && isFilterBarShown) { - const { gridX, gridY, visibleColumns, allColumnXs, allColumnWidths } = - metrics; - const { filterBarHeight } = theme; + const metricState = this.getMetricState(); + + // Advanced Filter buttons + const { visibleColumns } = metrics; for (let i = 0; i < visibleColumns.length; i += 1) { const columnIndex = visibleColumns[i]; - - const columnX = allColumnXs.get(columnIndex); - const columnWidth = allColumnWidths.get(columnIndex); const modelColumn = this.getModelColumn(columnIndex); + if (modelColumn != null) { const isFilterable = model.isFilterable(modelColumn); - if ( - isFilterable && - columnX != null && - columnWidth != null && - columnWidth > 0 - ) { - const x = gridX + columnX + columnWidth - 24; - const y = gridY - (filterBarHeight ?? 0) + 2; // 2 acts as top margin for the button + const buttonCoordinates = + isFilterable && metricState + ? metricCalculator.getAdvancedFilterButtonCoordinates( + columnIndex, + metricState, + metrics + ) + : null; + if (buttonCoordinates != null) { + const { x, y } = buttonCoordinates; const style: CSSProperties = { position: 'absolute', top: y, diff --git a/packages/iris-grid/src/IrisGridMetricCalculator.test.ts b/packages/iris-grid/src/IrisGridMetricCalculator.test.ts index 9c92e8cdf..08ca40826 100644 --- a/packages/iris-grid/src/IrisGridMetricCalculator.test.ts +++ b/packages/iris-grid/src/IrisGridMetricCalculator.test.ts @@ -1,4 +1,4 @@ -import { GridMetricCalculator } from '@deephaven/grid'; +import { GridMetricCalculator, type GridMetrics } from '@deephaven/grid'; import { type dh } from '@deephaven/jsapi-types'; import { TestUtils } from '@deephaven/test-utils'; import { @@ -6,6 +6,7 @@ import { type IrisGridMetricState, } from './IrisGridMetricCalculator'; import type IrisGridModel from './IrisGridModel'; +import { type IrisGridThemeType } from './IrisGridTheme'; const { createMockProxy } = TestUtils; @@ -109,4 +110,370 @@ describe('IrisGridMetricCalculator', () => { calculator.setColumnWidth(model.getColumnIndexByName('Column1'), 150); expect(calculator.getUserColumnWidths().get(0)).toBe(150); }); + + describe('getFilterInputCoordinates', () => { + it.each([ + { + description: 'returns null for negative column index', + index: -1, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: 30, + expected: null, + }, + { + description: 'returns null when columnX is not found', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map(), // Empty map + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: 30, + expected: null, + }, + { + description: 'returns null when columnWidth is not found', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map(), // Empty map + filterBarHeight: 30, + expected: null, + }, + { + description: 'returns correct coordinates for valid column', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: 30, + expected: { + x: 110, // gridX (10) + columnX (100) + y: 20, // gridY (50) - filterBarHeight (30) + width: 151, // columnWidth (150) + 1 + height: 29, // filterBarHeight (30) - 1 + }, + }, + { + description: 'handles undefined filterBarHeight', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: undefined, + expected: null, + }, + ])( + '$description', + ({ + index, + gridX, + gridY, + allColumnXs, + allColumnWidths, + filterBarHeight, + expected, + }) => { + const metrics = createMockProxy({ + gridX, + gridY, + allColumnXs, + allColumnWidths, + }); + const stateWithTheme = createMockProxy({ + ...state, + theme: { filterBarHeight } as IrisGridThemeType, + }); + + const result = calculator.getFilterInputCoordinates( + index, + stateWithTheme, + metrics + ); + + expect(result).toEqual(expected); + } + ); + + it('works with multiple columns', () => { + const metrics = createMockProxy({ + gridX: 20, + gridY: 100, + allColumnXs: new Map([ + [0, 0], + [1, 100], + [2, 250], + ]), + allColumnWidths: new Map([ + [0, 100], + [1, 150], + [2, 200], + ]), + }); + const stateWithTheme = createMockProxy({ + ...state, + theme: { filterBarHeight: 40 } as IrisGridThemeType, + }); + + const testCases = [ + { + index: 0, + expected: { + x: 20, // gridX (20) + columnX (0) + y: 60, // gridY (100) - filterBarHeight (40) + width: 101, + height: 39, + }, + }, + { + index: 1, + expected: { + x: 120, // gridX (20) + columnX (100) + y: 60, + width: 151, + height: 39, + }, + }, + { + index: 2, + expected: { + x: 270, // gridX (20) + columnX (250) + y: 60, + width: 201, + height: 39, + }, + }, + ]; + + testCases.forEach(({ index, expected }) => { + const result = calculator.getFilterInputCoordinates( + index, + stateWithTheme, + metrics + ); + expect(result).toEqual(expected); + }); + }); + }); + + describe('getAdvancedFilterButtonCoordinates', () => { + it.each([ + { + description: 'returns null for negative column index', + index: -1, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: 30, + expected: null, + }, + { + description: 'returns null when columnX is not found', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map(), // Empty map + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: 30, + expected: null, + }, + { + description: 'returns null when columnWidth is not found', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map(), // Empty map + filterBarHeight: 30, + expected: null, + }, + { + description: 'returns null when columnWidth is zero', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map([[0, 0]]), + filterBarHeight: 30, + expected: null, + }, + { + description: 'returns correct coordinates for valid column', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: 30, + expected: { + x: 236, // gridX (10) + columnX (100) + columnWidth (150) - 24 + y: 22, // gridY (50) - filterBarHeight (30) + 2 + }, + }, + { + description: 'handles undefined filterBarHeight', + index: 0, + gridX: 10, + gridY: 50, + allColumnXs: new Map([[0, 100]]), + allColumnWidths: new Map([[0, 150]]), + filterBarHeight: undefined, + expected: null, + }, + ])( + '$description', + ({ + index, + gridX, + gridY, + allColumnXs, + allColumnWidths, + filterBarHeight, + expected, + }) => { + const metrics = createMockProxy({ + gridX, + gridY, + allColumnXs, + allColumnWidths, + }); + const stateWithTheme = createMockProxy({ + ...state, + theme: { filterBarHeight } as IrisGridThemeType, + }); + + const result = calculator.getAdvancedFilterButtonCoordinates( + index, + stateWithTheme, + metrics + ); + + expect(result).toEqual(expected); + } + ); + + it('works with multiple columns', () => { + const metrics = createMockProxy({ + gridX: 20, + gridY: 100, + allColumnXs: new Map([ + [0, 0], + [1, 100], + [2, 250], + ]), + allColumnWidths: new Map([ + [0, 100], + [1, 150], + [2, 200], + ]), + }); + const stateWithTheme = createMockProxy({ + ...state, + theme: { filterBarHeight: 40 } as IrisGridThemeType, + }); + + const testCases = [ + { + index: 0, + expected: { + x: 96, // gridX (20) + columnX (0) + columnWidth (100) - 24 + y: 62, // gridY (100) - filterBarHeight (40) + 2 + }, + }, + { + index: 1, + expected: { + x: 246, // gridX (20) + columnX (100) + columnWidth (150) - 24 + y: 62, + }, + }, + { + index: 2, + expected: { + x: 446, // gridX (20) + columnX (250) + columnWidth (200) - 24 + y: 62, + }, + }, + ]; + + testCases.forEach(({ index, expected }) => { + const result = calculator.getAdvancedFilterButtonCoordinates( + index, + stateWithTheme, + metrics + ); + expect(result).toEqual(expected); + }); + }); + }); + + describe('getScrollLeftForColumn', () => { + it.each([ + { + description: 'returns null for negative column index', + column: -1, + left: 5, + rightVisible: 10, + lastLeft: 50, + expected: null, + }, + { + description: 'returns column when column < left', + column: 3, + left: 5, + rightVisible: 10, + lastLeft: 50, + expected: 3, + }, + { + description: 'returns null when column is in visible range', + column: 7, + left: 5, + rightVisible: 10, + lastLeft: 50, + expected: null, + }, + { + description: 'calculates new left when column > rightVisible', + column: 15, + left: 5, + rightVisible: 10, + lastLeft: 50, + expected: expect.any(Number), + }, + ])('$description', ({ column, left, rightVisible, lastLeft, expected }) => { + const metrics = createMockProxy({ + left, + rightVisible, + lastLeft, + }); + + const result = calculator.getScrollLeftForColumn(column, state, metrics); + if (expected === expect.any(Number)) { + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(lastLeft); + } else { + expect(result).toEqual(expected); + } + }); + + it('returns min of calculated left and lastLeft when scrolling right', () => { + const metrics = createMockProxy({ + left: 0, + rightVisible: 5, + lastLeft: 10, + }); + + const result = calculator.getScrollLeftForColumn(15, state, metrics); + + expect(result).not.toBeNull(); + expect(result).toBeLessThanOrEqual(10); // Should not exceed lastLeft + }); + }); }); diff --git a/packages/iris-grid/src/IrisGridMetricCalculator.ts b/packages/iris-grid/src/IrisGridMetricCalculator.ts index 4dade4a80..872fba294 100644 --- a/packages/iris-grid/src/IrisGridMetricCalculator.ts +++ b/packages/iris-grid/src/IrisGridMetricCalculator.ts @@ -8,6 +8,7 @@ import { type ModelSizeMap, trimMap, isExpandableColumnGridModel, + type VisibleIndex, } from '@deephaven/grid'; import type { dh } from '@deephaven/jsapi-types'; import { assertNotNull } from '@deephaven/utils'; @@ -392,6 +393,117 @@ export class IrisGridMetricCalculator extends GridMetricCalculator { return padding + expandCollapseIconWidth; } + + /** + * Get metrics for positioning the filter bar input field. + * @param index The visible index of the column to get the filter box coordinates for + * @param state The current IrisGridMetricState + * @param metrics The grid metrics + * @returns Coordinates for the filter input field, or null if positioning cannot be calculated + */ + // eslint-disable-next-line class-methods-use-this + getFilterInputCoordinates( + index: VisibleIndex, + state: IrisGridMetricState, + metrics: GridMetrics + ): { x: number; y: number; width: number; height: number } | null { + // Only handle standard columns (>= 0) in the base implementation + // Plugins can override to handle special columns (e.g., negative indices) + if (index < 0) { + return null; + } + + const { theme } = state; + const { filterBarHeight = 0 } = theme; + const { gridX, gridY, allColumnXs, allColumnWidths } = metrics; + + const columnX = allColumnXs.get(index); + const columnWidth = allColumnWidths.get(index); + + if ( + columnX == null || + columnWidth == null || + // Don't show the filter box for invisible columns + columnWidth === 0 || + filterBarHeight === 0 + ) { + return null; + } + + return { + x: gridX + columnX, + y: gridY - filterBarHeight, + width: columnWidth + 1, // cover right border + height: filterBarHeight - 1, // remove bottom border + }; + } + + /** + * Calculate the new left index to bring the given column into view. + * @param column The column that should be scrolled into view + * @param state The current IrisGridMetricState + * @param metrics The grid metrics + * @returns The left column index to scroll to, or null if no scroll is needed + */ + getScrollLeftForColumn( + column: VisibleIndex, + state: IrisGridMetricState, + metrics: GridMetrics + ): VisibleIndex | null { + const { left, rightVisible, lastLeft } = metrics; + + if (column < 0) { + return null; + } + + if (column < left) { + // Column is to the left of visible area + return column; + } + + if (rightVisible < column) { + // Column is to the right of visible area + const newLeft = this.getLastLeft( + state, + column, + this.getVisibleWidth(state) + ); + return Math.min(newLeft, lastLeft); + } + + // Column is already visible, no scroll needed + return null; + } + + /** + * Get the coordinates for the advanced filter button positioned in the filter bar. + * @param index The column index + * @param state The current IrisGridMetricState + * @param metrics The grid metrics + * @returns Coordinates for the advanced filter button, or null if it should not be shown + */ + getAdvancedFilterButtonCoordinates( + index: VisibleIndex, + state: IrisGridMetricState, + metrics: GridMetrics + ): { x: number; y: number } | null { + const filterBoxCoordinates = this.getFilterInputCoordinates( + index, + state, + metrics + ); + + if (filterBoxCoordinates == null) { + return null; + } + + const { x, y, width } = filterBoxCoordinates; + + return { + x: x + width - 25, // Right edge of filter box (24px button + 1px for border) + y: y + 2, // 2px top margin for the button + }; + } } export default IrisGridMetricCalculator;