diff --git a/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx b/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx index 2ae54266d6..d6789a9ad9 100644 --- a/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx +++ b/packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx @@ -25,7 +25,11 @@ import { } from '@deephaven/dashboard'; import { assertNotNull, getErrorMessage } from '@deephaven/utils'; import { useApi } from '@deephaven/jsapi-bootstrap'; -import { type GridRange, type GridState } from '@deephaven/grid'; +import { + type GridRange, + type GridState, + type Selection, +} from '@deephaven/grid'; import { useIrisGridModel } from './useIrisGridModel'; import useDashboardColumnFilters from './useDashboardColumnFilters'; import { InputFilterEvent } from './events'; @@ -155,7 +159,10 @@ export function GridWidgetPlugin({ handleClearAllFilters ); - const [selection, setSelection] = useState([]); + const [selectedRanges, setSelectedRanges] = useState( + [] + ); + const [selection, setSelection] = useState(null); const { Plugin, @@ -166,7 +173,8 @@ export function GridWidgetPlugin({ model, irisGridRef, irisGridUtils, - selectedRanges: selection, + selectedRanges, + selection, }); const alwaysFetchColumns = useMemo(() => { @@ -201,7 +209,8 @@ export function GridWidgetPlugin({ model={model} settings={settings} onStateChange={handleIrisGridChange} - onSelectionChanged={setSelection} + onSelectionChanged={setSelectedRanges} + onSelectionChange={setSelection} onContextMenu={onContextMenu} inputFilters={inputFilters} customFilters={customFilters} diff --git a/packages/dashboard-core-plugins/src/TablePluginWrapper.tsx b/packages/dashboard-core-plugins/src/TablePluginWrapper.tsx index d02275c864..72cf275d3f 100644 --- a/packages/dashboard-core-plugins/src/TablePluginWrapper.tsx +++ b/packages/dashboard-core-plugins/src/TablePluginWrapper.tsx @@ -19,6 +19,7 @@ export const TablePluginWrapper = forwardRef( filter, fetchColumns, selectedRanges, + selection, irisGridRef, pluginState, onStateChange, @@ -28,6 +29,7 @@ export const TablePluginWrapper = forwardRef( | 'filter' | 'fetchColumns' | 'selectedRanges' + | 'selection' | 'pluginState' | 'onStateChange' > & { @@ -72,6 +74,7 @@ export const TablePluginWrapper = forwardRef( table={model.table} tableName={panelName} selectedRanges={selectedRanges} + selection={selection} onStateChange={onStateChange} pluginState={pluginState} // Mimic the panel containing `irisGrid.current` for backwards compatibility diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx index cab0285d68..3c2add43c8 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -71,10 +71,12 @@ import { import { type ResolvableContextAction } from '@deephaven/components'; import type { dh } from '@deephaven/jsapi-types'; import { + selectionToRanges, type GridState, type ModelIndex, type ModelSizeMap, type MoveOperation, + type Selection, } from '@deephaven/grid'; import type { TablePluginComponent, @@ -244,6 +246,9 @@ interface IrisGridPanelState { frozenColumns?: readonly ColumnName[]; columnHeaderGroups?: readonly ColumnHeaderGroup[]; + // Current grid selection for memoization in getPluginContent + gridSelection: Selection | null; + // eslint-disable-next-line react/no-unused-state panelState?: PanelState | null; // Dehydrated panel state that can load this panel irisGridStateOverrides: Partial; @@ -294,6 +299,7 @@ export class IrisGridPanel extends PureComponent< this.handleDataSelected = this.handleDataSelected.bind(this); this.handleError = this.handleError.bind(this); this.handleGridStateChange = this.handleGridStateChange.bind(this); + this.handleGridSelectionChange = this.handleGridSelectionChange.bind(this); this.handlePluginStateChange = this.handlePluginStateChange.bind(this); this.handleCreateChart = this.handleCreateChart.bind(this); this.handleShow = this.handleShow.bind(this); @@ -363,6 +369,7 @@ export class IrisGridPanel extends PureComponent< isStuckToRight: false, conditionalFormats: [], selectDistinctColumns: [], + gridSelection: null, }; } @@ -469,7 +476,8 @@ export class IrisGridPanel extends PureComponent< ( Plugin: TablePluginComponent | undefined, model: IrisGridModel | undefined, - pluginState: unknown + pluginState: unknown, + gridSel: Selection | null ) => { if ( !model || @@ -485,6 +493,8 @@ export class IrisGridPanel extends PureComponent< panel: this, }; + const selectedRanges = selectionToRanges(gridSel); + return (
) : null, - [model, selectedRanges, irisGridRef, pluginState, setPluginState] + [model, selectedRanges, selection, irisGridRef, pluginState, setPluginState] ); const onContextMenu = useCallback( diff --git a/packages/grid/src/Grid.test.tsx b/packages/grid/src/Grid.test.tsx index ebd16f2c8b..6ac6bcc160 100644 --- a/packages/grid/src/Grid.test.tsx +++ b/packages/grid/src/Grid.test.tsx @@ -211,7 +211,9 @@ it('handles mouse down in middle of grid to update selection', () => { expect(component.state.cursorRow).toBe(5); expect(component.state.cursorColumn).toBe(3); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(3, 5, 3, 5)); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(3, 5, 3, 5) + ); }); it('only calls onSelectionChanged once when clicking a cell', () => { @@ -244,7 +246,7 @@ it('handles mouse down in the very bottom right of last cell to update selection expect(component.state.cursorColumn).toBe(column); expect(component.state.cursorRow).toBe(row); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges[0]).toEqual( new GridRange(column, row, column, row) ); }); @@ -253,26 +255,26 @@ it('clicking a selected cell should deselect it', () => { const component = makeGridComponent(); mouseClick(3, 5, component); - expect(component.state.selectedRanges.length).toBe(1); + expect(component.state.selection.ranges.length).toBe(1); mouseClick(3, 5, component); expect(component.state.cursorRow).toBe(null); expect(component.state.cursorColumn).toBe(null); - expect(component.state.selectedRanges.length).toBe(0); + expect(component.state.selection.ranges.length).toBe(0); }); it('ctrl clicking a selected cell should deselect it', () => { const component = makeGridComponent(); mouseClick(3, 5, component); - expect(component.state.selectedRanges.length).toBe(1); + expect(component.state.selection.ranges.length).toBe(1); mouseClick(3, 5, component, { ctrlKey: true }); expect(component.state.cursorRow).toBe(null); expect(component.state.cursorColumn).toBe(null); - expect(component.state.selectedRanges.length).toBe(0); + expect(component.state.selection.ranges.length).toBe(0); }); it('right click outside the range changes the selected ranges', () => { @@ -282,12 +284,16 @@ it('right click outside the range changes the selected ranges', () => { 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)); + expect(component.state.selection.ranges[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)); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 7, 5, 7) + ); }); it('right click inside the range keeps the selected ranges', () => { @@ -295,12 +301,16 @@ it('right click inside the range keeps the selected ranges', () => { 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)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[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)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(3, 5, 3, 6) + ); }); it('handles mouse drag down to update selection', () => { @@ -311,15 +321,19 @@ it('handles mouse drag down to update selection', () => { expect(component.state.selectionEndRow).toBe(7); expect(component.state.selectionEndColumn).toBe(8); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(3, 5, 8, 7)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(3, 5, 8, 7) + ); mouseMove(5, 6, component); expect(component.state.selectionEndRow).toBe(6); expect(component.state.selectionEndColumn).toBe(5); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(3, 5, 5, 6)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(3, 5, 5, 6) + ); }); it('handles mouse drag from floating section to non-floating section to scroll and update selection', () => { @@ -343,8 +357,8 @@ it('handles mouse drag from floating section to non-floating section to scroll a mouseMove(8, 3, component); expect(component.state.selectionEndRow).toBe(midDragRow); expect(component.state.selectionEndColumn).toBe(8); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(3, midDragRow, 8, lastRow) ); @@ -364,8 +378,8 @@ it('handles mouse drag from floating section to non-floating section to scroll a expect(component.state.selectionEndRow).toBe(endRow); expect(component.state.selectionEndColumn).toBe(5); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(3, endRow, 5, lastRow) ); }); @@ -379,15 +393,19 @@ it('handles mouse drag up to update selection', () => { expect(component.state.selectionEndRow).toBe(2); expect(component.state.selectionEndColumn).toBe(1); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(1, 2, 3, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(1, 2, 3, 5) + ); mouseMove(2, 3, component); expect(component.state.selectionEndRow).toBe(3); expect(component.state.selectionEndColumn).toBe(2); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(2, 3, 3, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(2, 3, 3, 5) + ); }); it('handles mouse shift click to extend selection', () => { @@ -399,22 +417,28 @@ it('handles mouse shift click to extend selection', () => { expect(component.state.selectionEndRow).toBe(7); expect(component.state.selectionEndColumn).toBe(8); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 5, 8, 7)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 5, 8, 7) + ); mouseClick(3, 2, component, { shiftKey: true }); expect(component.state.selectionEndRow).toBe(2); expect(component.state.selectionEndColumn).toBe(3); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(3, 2, 5, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(3, 2, 5, 5) + ); mouseClick(9, 9, component, { shiftKey: true }); expect(component.state.selectionEndRow).toBe(9); expect(component.state.selectionEndColumn).toBe(9); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 5, 9, 9)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 5, 9, 9) + ); }); it('handles mouse ctrl click to add to selection', () => { @@ -426,9 +450,13 @@ it('handles mouse ctrl click to add to selection', () => { expect(component.state.cursorColumn).toBe(8); expect(component.state.cursorRow).toBe(7); - expect(component.state.selectedRanges.length).toBe(2); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 5, 5, 5)); - expect(component.state.selectedRanges[1]).toEqual(new GridRange(8, 7, 8, 7)); + expect(component.state.selection.ranges.length).toBe(2); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 5, 5, 5) + ); + expect(component.state.selection.ranges[1]).toEqual( + new GridRange(8, 7, 8, 7) + ); }); it('deselects when ctrl clicking within a selected range', () => { @@ -442,8 +470,8 @@ it('deselects when ctrl clicking within a selected range', () => { // Cursor should reset to the start range expect(component.state.cursorColumn).toBe(5); expect(component.state.cursorRow).toBe(5); - expect(component.state.selectedRanges.length).toBe(4); - expect(component.state.selectedRanges).toEqual([ + expect(component.state.selection.ranges.length).toBe(4); + expect(component.state.selection.ranges).toEqual([ new GridRange(5, 5, 9, 6), new GridRange(5, 7, 7, 7), new GridRange(9, 7, 9, 7), @@ -460,22 +488,28 @@ it('handles ctrl+shift click to extend range in both direcitons', () => { expect(component.state.selectionEndColumn).toBe(8); expect(component.state.selectionEndRow).toBe(7); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 5, 8, 7)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 5, 8, 7) + ); mouseClick(2, 3, component, { ctrlKey: true, shiftKey: true }); expect(component.state.selectionEndColumn).toBe(2); expect(component.state.selectionEndRow).toBe(3); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(2, 3, 8, 7)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(2, 3, 8, 7) + ); mouseClick(9, 9, component, { ctrlKey: true, shiftKey: true }); expect(component.state.selectionEndColumn).toBe(9); expect(component.state.selectionEndRow).toBe(9); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(2, 3, 9, 9)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(2, 3, 9, 9) + ); }); it('handles double clicking a cell to edit', async () => { @@ -514,8 +548,10 @@ it('handles keyboard arrow to update selection with no previous selection', () = expect(component.state.cursorRow).toBe(0); expect(component.state.cursorColumn).toBe(0); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(0, 0, 0, 0)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(0, 0, 0, 0) + ); }); it('handles keyboard arrow to move selection down/right', () => { @@ -528,8 +564,10 @@ it('handles keyboard arrow to move selection down/right', () => { expect(component.state.cursorColumn).toBe(1); expect(component.state.cursorRow).toBe(2); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(1, 2, 1, 2)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(1, 2, 1, 2) + ); }); it('handles keyboard arrow to extend selection down/up', () => { @@ -543,8 +581,10 @@ it('handles keyboard arrow to extend selection down/up', () => { expect(component.state.selectionEndColumn).toBe(6); expect(component.state.selectionEndRow).toBe(7); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 5, 6, 7)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 5, 6, 7) + ); arrowUp(component, { shiftKey: true }); arrowUp(component, { shiftKey: true }); @@ -555,8 +595,10 @@ it('handles keyboard arrow to extend selection down/up', () => { expect(component.state.selectionEndColumn).toBe(4); expect(component.state.selectionEndRow).toBe(3); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(4, 3, 5, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(4, 3, 5, 5) + ); }); it('handles keyboard pageDown to move cursor and/or selection down', () => { @@ -572,17 +614,17 @@ it('handles keyboard pageDown to move cursor and/or selection down', () => { // Try selecting a range with shift+pageDown pageDown(component, { shiftKey: true }); - expect(component.state.selectionStartRow).toBe(51); + expect(component.state.selection.getGestureAnchor()?.row).toBe(51); expect(component.state.selectionEndRow).toBe(98); // Try increasing the selection with another shift+pageDown. pageDown(component, { shiftKey: true }); - expect(component.state.selectionStartRow).toBe(51); + expect(component.state.selection.getGestureAnchor()?.row).toBe(51); expect(component.state.selectionEndRow).toBe(145); // Try changing the selected row with an arrow key. arrowUp(component); - expect(component.state.selectionStartRow).toBe(50); + expect(component.state.selection.getGestureAnchor()?.row).toBe(50); expect(component.state.selectionEndRow).toBe(50); expect(component.state.cursorRow).toBe(50); @@ -591,7 +633,7 @@ it('handles keyboard pageDown to move cursor and/or selection down', () => { pageDown(component, { shiftKey: true }); pageDown(component, { shiftKey: true }); pageDown(component, { shiftKey: true }); - expect(component.state.selectionStartRow).toBe(50); + expect(component.state.selection.getGestureAnchor()?.row).toBe(50); expect(component.state.selectionEndRow).toBe(199); }); @@ -613,17 +655,17 @@ it('handles keyboard pageUp to move cursor and/or selection up', () => { // Try selecting a range with shift+pageUp pageUp(component, { shiftKey: true }); - expect(component.state.selectionStartRow).toBe(152); + expect(component.state.selection.getGestureAnchor()?.row).toBe(152); expect(component.state.selectionEndRow).toBe(105); // Try increasing the selection with another shift+pageUp. pageUp(component, { shiftKey: true }); - expect(component.state.selectionStartRow).toBe(152); + expect(component.state.selection.getGestureAnchor()?.row).toBe(152); expect(component.state.selectionEndRow).toBe(58); // Try changing the selected row with an arrow key. arrowDown(component); - expect(component.state.selectionStartRow).toBe(153); + expect(component.state.selection.getGestureAnchor()?.row).toBe(153); expect(component.state.selectionEndRow).toBe(153); expect(component.state.cursorRow).toBe(153); @@ -632,7 +674,7 @@ it('handles keyboard pageUp to move cursor and/or selection up', () => { pageUp(component, { shiftKey: true }); pageUp(component, { shiftKey: true }); pageUp(component, { shiftKey: true }); - expect(component.state.selectionStartRow).toBe(153); + expect(component.state.selection.getGestureAnchor()?.row).toBe(153); expect(component.state.selectionEndRow).toBe(0); }); @@ -647,15 +689,17 @@ it('handles ctrl+shift keyboard arrows to extend selection to beginning/end', () expect(component.state.selectionEndColumn).toBe(5); expect(component.state.selectionEndRow).toBe(0); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 0, 5, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 0, 5, 5) + ); arrowDown(component, { shiftKey: true, ctrlKey: true }); expect(component.state.selectionEndColumn).toBe(5); expect(component.state.selectionEndRow).toBe(rowCount - 1); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(5, 0, 5, rowCount - 1) ); @@ -663,8 +707,8 @@ it('handles ctrl+shift keyboard arrows to extend selection to beginning/end', () expect(component.state.selectionEndColumn).toBe(columnCount - 1); expect(component.state.selectionEndRow).toBe(rowCount - 1); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(5, 0, columnCount - 1, rowCount - 1) ); }); @@ -680,15 +724,17 @@ it('handles Home/End to go to beginning/end column', () => { expect(component.state.selectionEndColumn).toBe(0); expect(component.state.selectionEndRow).toBe(5); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(0, 5, 0, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(0, 5, 0, 5) + ); end(component); expect(component.state.selectionEndColumn).toBe(columnCount - 1); expect(component.state.selectionEndRow).toBe(5); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(columnCount - 1, 5, columnCount - 1, 5) ); }); @@ -704,15 +750,17 @@ it('handles Shift+Home/End to extend selection to beginning/end column', () => { expect(component.state.selectionEndColumn).toBe(0); expect(component.state.selectionEndRow).toBe(5); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(0, 5, 5, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(0, 5, 5, 5) + ); end(component, { shiftKey: true }); expect(component.state.selectionEndColumn).toBe(columnCount - 1); expect(component.state.selectionEndRow).toBe(5); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(0, 5, columnCount - 1, 5) ); @@ -720,8 +768,8 @@ it('handles Shift+Home/End to extend selection to beginning/end column', () => { expect(component.state.selectionEndColumn).toBe(5); expect(component.state.selectionEndRow).toBe(rowCount - 1); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(0, 5, columnCount - 1, rowCount - 1) ); }); @@ -737,15 +785,17 @@ it('handles Ctrl+Home/End to go to beginning/end row', () => { expect(component.state.selectionEndColumn).toBe(5); expect(component.state.selectionEndRow).toBe(0); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 0, 5, 0)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 0, 5, 0) + ); end(component, { ctrlKey: true }); expect(component.state.selectionEndColumn).toBe(5); expect(component.state.selectionEndRow).toBe(rowCount - 1); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(5, rowCount - 1, 5, rowCount - 1) ); }); @@ -761,15 +811,17 @@ it('handles Ctrl+Shift+Home/End to go to beginning/end row and extend selection' expect(component.state.selectionEndColumn).toBe(5); expect(component.state.selectionEndRow).toBe(0); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual(new GridRange(5, 0, 5, 5)); + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(5, 0, 5, 5) + ); end(component, { shiftKey: true, ctrlKey: true }); expect(component.state.selectionEndColumn).toBe(5); expect(component.state.selectionEndRow).toBe(rowCount - 1); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(5, 0, 5, rowCount - 1) ); }); @@ -779,23 +831,23 @@ it('handles escape to clear current ranges', () => { arrowDown(component); - expect(component.state.selectedRanges.length).toBe(1); + expect(component.state.selection.ranges.length).toBe(1); keyDown('Escape', component); - expect(component.state.selectedRanges.length).toBe(0); + expect(component.state.selection.ranges.length).toBe(0); }); it('selects all with ctrl+a', () => { const model = new MockGridModel(); - const { columnCount, rowCount } = model; + const { rowCount } = model; const component = makeGridComponent(model); keyDown('a', component, { ctrlKey: true }); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( - new GridRange(0, 0, columnCount - 1, rowCount - 1) + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( + new GridRange(null, 0, null, rowCount - 1) ); }); @@ -808,8 +860,8 @@ it('auto selects the row with the autoselect row option set', () => { expect(component.state.cursorColumn).toBe(0); expect(component.state.cursorRow).toBe(0); - expect(component.state.selectedRanges.length).toBe(1); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(1); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(null, 0, null, 0) ); }); @@ -834,11 +886,11 @@ describe('mac specific shortcut tests', () => { expect(component.state.cursorColumn).toBe(8); expect(component.state.cursorRow).toBe(7); - expect(component.state.selectedRanges.length).toBe(2); - expect(component.state.selectedRanges[0]).toEqual( + expect(component.state.selection.ranges.length).toBe(2); + expect(component.state.selection.ranges[0]).toEqual( new GridRange(5, 5, 5, 5) ); - expect(component.state.selectedRanges[1]).toEqual( + expect(component.state.selection.ranges[1]).toEqual( new GridRange(8, 7, 8, 7) ); }); diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index a6fa69173a..2ab9fb537c 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -73,6 +73,12 @@ import { import { type EventHandlerResultOptions } from './EventHandlerResult'; import { assertIsDefined } from './errors'; import ThemeContext from './ThemeContext'; +import { type GetModel, type Selection } from './Selection'; +import { + RangedSelection, + assertIsRangedSelection, + selectionToRanges, +} from './RangedSelection'; import { type DraggingColumn } from './mouse-handlers/GridColumnMoveMouseHandler'; import { type EditingCell, @@ -116,6 +122,11 @@ export type GridProps = typeof Grid.defaultProps & { keyHandlers?: readonly KeyHandler[]; mouseHandlers?: readonly GridMouseHandler[]; + // Factory that creates an empty Selection; defaults to an empty RangedSelection. + // Uses a `GetModel` closure (not a direct `model` reference) so Selections + // always read the current `props.model` even after prop updates. + createEmptySelection?: (getModel: GetModel) => Selection; + // Initial state of moved columns or rows movedColumns?: readonly MoveOperation[]; movedRows?: readonly MoveOperation[]; @@ -123,9 +134,12 @@ export type GridProps = typeof Grid.defaultProps & { // Callback for if an error occurs onError?: (e: Error) => void; - // Callback when the selection within the grid changes + /** @deprecated Use onSelectionChange instead. */ onSelectionChanged?: (ranges: readonly GridRange[]) => void; + // Callback when the selection within the grid changes + onSelectionChange?: (selection: Selection) => void; + // Callback when the moved columns or rows have changed onMovedColumnsChanged?: (movedColumns: readonly MoveOperation[]) => void; onMovedRowsChanged?: (movedRows: readonly MoveOperation[]) => void; @@ -197,16 +211,19 @@ export type GridState = { // Cursor (highlighted cell) location and active selected range cursorRow: VisibleIndex | null; cursorColumn: VisibleIndex | null; - selectionStartRow: VisibleIndex | null; - selectionStartColumn: VisibleIndex | null; selectionEndRow: VisibleIndex | null; selectionEndColumn: VisibleIndex | null; - // Currently selected ranges and previously selected ranges - // Store the previously selected ranges to determine if the new selection should - // deselect again (if it's the same range) + // The current selection + selection: Selection; + // Previous selection; used for deselect-on-reclick detection in commitSelection. + lastSelection: Selection; + + /** + * @deprecated Use `selection` instead. Kept for backward compat with consumers + * that read `grid.state.selectedRanges` directly. + */ selectedRanges: readonly GridRange[]; - lastSelectedRanges: readonly GridRange[]; // The mouse cursor style to use when hovering over the grid element cursor: string | null; @@ -262,6 +279,7 @@ class Grid extends PureComponent { movedRows: EMPTY_ARRAY as readonly MoveOperation[], onError: (): void => undefined, onSelectionChanged: (): void => undefined, + onSelectionChange: (_selection: Selection): void => undefined, onMovedColumnsChanged: (moveOperations: readonly MoveOperation[]): void => undefined, onMoveColumnComplete: (): void => undefined, @@ -279,6 +297,9 @@ class Grid extends PureComponent { autoSelectColumn: false, autoSelectRow: false, } as Partial, + createEmptySelection: RangedSelection.empty as ( + getModel: GetModel + ) => Selection, }; // use same constant as chrome source for windows @@ -395,6 +416,7 @@ class Grid extends PureComponent { this.handleResize = this.handleResize.bind(this); this.handleWheel = this.handleWheel.bind(this); this.getSelectedRanges = this.getSelectedRanges.bind(this); + this.getModel = this.getModel.bind(this); const { isStuckToBottom, @@ -449,6 +471,10 @@ class Grid extends PureComponent { new GridSelectionMouseHandler(900), ]; + // Selections are immutable, so a single instance can back both selection + // and lastSelection until the first commit produces a fresh committed value. + const emptySelection = props.createEmptySelection(this.getModel); + this.state = { // Top/left visible cell in the grid. Note that it's visible row/column index, not the model index (ie. if columns are re-ordered) top: 0, @@ -487,16 +513,15 @@ class Grid extends PureComponent { // Cursor (highlighted cell) location and active selected range cursorRow: null, cursorColumn: null, - selectionStartRow: null, - selectionStartColumn: null, selectionEndRow: null, selectionEndColumn: null, // Currently selected ranges and previously selected ranges // Store the previously selected ranges to determine if the new selection should // deselect again (if it's the same range) - selectedRanges: EMPTY_ARRAY, - lastSelectedRanges: EMPTY_ARRAY, + selection: emptySelection, + lastSelection: emptySelection, + selectedRanges: [], // The mouse cursor style to use when hovering over the grid element cursor: null, @@ -568,6 +593,7 @@ class Grid extends PureComponent { onMoveRowComplete, renderer, metricCalculator, + createEmptySelection, } = this.props; const { @@ -631,6 +657,13 @@ class Grid extends PureComponent { this.metricCalculator = metricCalculator ?? new GridMetricCalculator(); } + if (prevProps.createEmptySelection !== createEmptySelection) { + const empty = createEmptySelection(this.getModel); + stateUpdates.selection = empty; + stateUpdates.lastSelection = empty; + stateUpdates.selectedRanges = EMPTY_ARRAY; + } + const updatedState = { ...this.state, ...stateUpdates }; this.updateMetrics(updatedState); @@ -854,32 +887,38 @@ class Grid extends PureComponent { setSelectedRanges(gridRanges: readonly GridRange[]): void { const { model } = this.props; const { columnCount, rowCount } = model; - const { cursorRow, cursorColumn, selectedRanges } = this.state; - this.setState({ - selectedRanges: gridRanges, - lastSelectedRanges: selectedRanges, - }); - if (gridRanges.length > 0) { - const range = GridRange.boundedRange( - gridRanges[0], - columnCount, - rowCount - ); - let newCursorRow = cursorRow; - let newCursorColumn = cursorColumn; - if (!range.containsCell(cursorColumn, cursorRow)) { - ({ row: newCursorRow, column: newCursorColumn } = range.startCell()); + this.setState(state => { + let { cursorRow, cursorColumn, selectionEndColumn, selectionEndRow } = + state; + let anchorRow: GridRangeIndex = null; + let anchorColumn: GridRangeIndex = null; + if (gridRanges.length > 0) { + const range = GridRange.boundedRange( + gridRanges[0], + columnCount, + rowCount + ); + if (!range.containsCell(cursorColumn, cursorRow)) { + ({ row: cursorRow, column: cursorColumn } = range.startCell()); + } + anchorColumn = range.startColumn; + anchorRow = range.startRow; + selectionEndColumn = range.endColumn; + selectionEndRow = range.endRow; } - - this.setState({ - selectionStartColumn: range.startColumn, - selectionStartRow: range.startRow, - selectionEndColumn: range.endColumn, - selectionEndRow: range.endRow, - cursorColumn: newCursorColumn, - cursorRow: newCursorRow, - }); - } + const selection = state.selection + .withCommittedRanges(gridRanges) + .withGestureAnchor(anchorRow, anchorColumn); + return { + selection, + selectedRanges: selectionToRanges(selection), + lastSelection: state.selection, + selectionEndColumn, + selectionEndRow, + cursorColumn, + cursorRow, + }; + }); } initContext(): void { @@ -1020,12 +1059,11 @@ class Grid extends PureComponent { * @param prevState The previous grid state */ checkSelectionChange(prevState: GridState): void { - const { selectedRanges: oldSelectedRanges } = prevState; - const { selectedRanges } = this.state; - - if (selectedRanges !== oldSelectedRanges) { - const { onSelectionChanged } = this.props; - onSelectionChanged(selectedRanges); + const { selection } = this.state; + if (selection !== prevState.selection) { + const { onSelectionChanged, onSelectionChange } = this.props; + onSelectionChanged(selectionToRanges(selection)); + onSelectionChange(selection); } } @@ -1034,20 +1072,19 @@ class Grid extends PureComponent { * @returns True if the selection is valid, false if the selection was invalid and has been reset */ validateSelection(): boolean { - const { model } = this.props; - const { selectedRanges } = this.state; + const { model, createEmptySelection } = this.props; const { columnCount, rowCount } = model; + const { selection } = this.state; - for (let i = 0; i < selectedRanges.length; i += 1) { - const range = selectedRanges[i]; - if ( - (range.endColumn != null && range.endColumn >= columnCount) || - (range.endRow != null && range.endRow >= rowCount) - ) { - // Just clear the selection rather than trying to trim it. - this.setState({ selectedRanges: [], lastSelectedRanges: [] }); - return false; - } + if (!selection.isValid(columnCount, rowCount)) { + // Just clear the selection rather than trying to trim it. + const empty = createEmptySelection(this.getModel); + this.setState({ + selection: empty, + lastSelection: empty, + selectedRanges: EMPTY_ARRAY, + }); + return false; } return true; } @@ -1056,27 +1093,55 @@ class Grid extends PureComponent { * Clears all selected ranges */ clearSelectedRanges(): void { - const { selectedRanges } = this.state; - this.setState({ + this.setState(state => ({ + selection: state.selection.clear(), + lastSelection: state.selection, selectedRanges: EMPTY_ARRAY, - lastSelectedRanges: selectedRanges, - }); + })); } /** Clears all but the last selected range */ trimSelectedRanges(): void { - const { selectedRanges } = this.state; - if (selectedRanges.length > 0) { - this.setState({ - selectedRanges: selectedRanges.slice(selectedRanges.length - 1), - }); - } + const { selection } = this.state; + const trimmed = selection.trimmed(); + this.setState({ + selection: trimmed, + selectedRanges: selectionToRanges(trimmed), + }); + } + + /** Sets the selection directly, bypassing mouse/keyboard gesture state. */ + setSelection(selection: Selection): void { + // Sync lastSelection so the next gesture compares against the just-installed + // committed state; otherwise a pending → resolved swap would leave the next + // ctrl+click's toggle referencing the pending selection's empty keys. + this.setState({ + selection, + lastSelection: selection, + selectedRanges: selectionToRanges(selection), + }); + } + + /** Gets the current selection */ + getSelection(): Selection { + const { selection } = this.state; + return selection; } - /** Gets the selected ranges */ + /** @deprecated Use getSelection() instead */ getSelectedRanges(): readonly GridRange[] { - const { selectedRanges } = this.state; - return selectedRanges; + const { selection } = this.state; + // toRanges() is RangedSelection-only; returns [] for keyed selections + return selectionToRanges(selection); + } + + /** + * Queries the current grid model. + * @returns The current GridModel instance. + */ + getModel(): GridModel { + const { model } = this.props; + return model; } /** @@ -1085,14 +1150,13 @@ class Grid extends PureComponent { * @param row Row where the selection is beginning */ beginSelection(column: GridRangeIndex, row: GridRangeIndex): void { - this.setState({ - selectionStartColumn: column, - selectionStartRow: row, + this.setState(state => ({ + selection: state.selection.withGestureAnchor(row, column), selectionEndColumn: column, selectionEndRow: row, cursorColumn: column, cursorRow: row, - }); + })); } /** @@ -1109,12 +1173,26 @@ class Grid extends PureComponent { maximizePreviousRange = false ): void { this.setState(state => { - const { selectedRanges, selectionStartRow, selectionStartColumn } = state; + const { selection } = state; const { theme } = this.props; const { autoSelectRow, autoSelectColumn } = theme; - - if (extendSelection && selectedRanges.length > 0) { - const lastSelectedRange = selectedRanges[selectedRanges.length - 1]; + const selectedRanges = selection.toActiveRanges(); + // Fall back to the selection's gesture anchor so shift+click works after trimming. + const anchor = selection.getGestureAnchor(); + const selectionStartRow = anchor?.row ?? null; + const selectionStartColumn = anchor?.column ?? null; + const hasCursorAnchor = anchor != null; + + if (extendSelection && (selectedRanges.length > 0 || hasCursorAnchor)) { + const lastSelectedRange = + selectedRanges.length > 0 + ? selectedRanges[selectedRanges.length - 1] + : GridRange.makeNormalized( + selectionStartColumn, + selectionStartRow, + selectionStartColumn, + selectionStartRow + ); let left = null; let top = null; let right = null; @@ -1164,9 +1242,20 @@ class Grid extends PureComponent { } const newRanges = [...selectedRanges]; - newRanges[newRanges.length - 1] = selectedRange; + if (newRanges.length > 0) { + newRanges[newRanges.length - 1] = selectedRange; + } else { + newRanges.push(selectedRange); + } + // drag / shift+click extend by replacing; ctrl+shift+click grows the previous range instead. + const isReplacing = !maximizePreviousRange; + const newSelection = selection.withMouseGestureRanges( + newRanges, + isReplacing + ); return { - selectedRanges: newRanges, + selection: newSelection, + selectedRanges: selectionToRanges(newSelection), selectionEndColumn: column, selectionEndRow: row, }; @@ -1185,8 +1274,10 @@ class Grid extends PureComponent { selectedRow ) ); + const newSelection = selection.withMouseGestureRanges(newRanges); return { - selectedRanges: newRanges, + selection: newSelection, + selectedRanges: selectionToRanges(newSelection), selectionEndColumn: column, selectionEndRow: row, }; @@ -1195,62 +1286,37 @@ class Grid extends PureComponent { /** * Commits the last selected range to the selected ranges. - * First checks if the last range is completely contained within another range, and if it - * is then it blows those ranges apart. - * Then it consolidates all the selected ranges, reducing them. + * Consolidation, deselect-on-reclick, and subtract logic are handled by Selection.commitMouseGesture. */ commitSelection(): void { this.setState((state: GridState) => { const { theme } = this.props; const { autoSelectRow } = theme; - const { selectedRanges, lastSelectedRanges, cursorRow, cursorColumn } = - state; + const { selection, lastSelection, cursorRow, cursorColumn } = state; - if ( - selectedRanges.length === 1 && - (autoSelectRow !== undefined && autoSelectRow - ? GridRange.rowCount(selectedRanges) === 1 - : GridRange.cellCount(selectedRanges) === 1) && - GridRange.rangeArraysEqual(selectedRanges, lastSelectedRanges) - ) { - // If it's the exact same single selection, then deselect. - // For if we click on one cell multiple times. - return { - selectedRanges: EMPTY_ARRAY, - lastSelectedRanges: EMPTY_ARRAY, - cursorColumn: null, - cursorRow: null, - }; - } - - let newSelectedRanges = selectedRanges.slice(); - if (newSelectedRanges.length > 1) { - // Check if the latest selection is entirely within a previously selected range - // If that's the case, then deselect that section instead - const lastRange = newSelectedRanges[newSelectedRanges.length - 1]; - for (let i = 0; i < newSelectedRanges.length - 1; i += 1) { - const selectedRange = newSelectedRanges[i]; - if (selectedRange.contains(lastRange)) { - // We found a match, now remove the two matching ranges, and add back - // the remainder of the two - const remainder = selectedRange.subtract(lastRange); - newSelectedRanges.pop(); - newSelectedRanges.splice(i, 1); - newSelectedRanges = newSelectedRanges.concat(remainder); - break; - } - } - - newSelectedRanges = GridRange.consolidate(newSelectedRanges); - } + const newSelection = selection.commitMouseGesture(lastSelection, { + autoSelectRow: autoSelectRow ?? false, + }); + if (newSelection === selection) return null; - let newCursorColumn = cursorColumn; let newCursorRow = cursorRow; - if (!GridRange.containsCell(newSelectedRanges, cursorColumn, cursorRow)) { + let newCursorColumn = cursorColumn; + + if (newSelection.isEmpty()) { + newCursorRow = null; + newCursorColumn = null; + } else if ( + cursorRow == null || + !newSelection.isCellSelected(cursorColumn ?? 0, cursorRow) + ) { const { model } = this.props; const { columnCount, rowCount } = model; const nextCursor = GridRange.nextCell( - GridRange.boundedRanges(selectedRanges, columnCount, rowCount) + GridRange.boundedRanges( + selection.toActiveRanges(), + columnCount, + rowCount + ) ); if (nextCursor != null) { ({ column: newCursorColumn, row: newCursorRow } = nextCursor); @@ -1260,25 +1326,14 @@ class Grid extends PureComponent { } } - if (newSelectedRanges.length === 0) { - newCursorColumn = null; - newCursorRow = null; - } - - const selectionChanged = - newSelectedRanges.length !== selectedRanges.length || - newSelectedRanges.some( - (range, index) => !range.equals(selectedRanges[index]) - ); - + // Always use the committed result as lastSelection so the next gesture + // sees the actual committed keys, not the transient overlay-phase state. return { cursorRow: newCursorRow, cursorColumn: newCursorColumn, - // The onSelectionChanged callback has already been called with the selectedRanges at this point. - // If the selection is not changed (e.g., the user is adding via ctrl+click and not removing), - // there is no need to change and trigger the callback again. - selectedRanges: selectionChanged ? newSelectedRanges : selectedRanges, - lastSelectedRanges: selectedRanges, + selection: newSelection, + lastSelection: newSelection, + selectedRanges: selectionToRanges(newSelection), }; }); } @@ -1302,33 +1357,35 @@ class Grid extends PureComponent { focusedRow + 1, halfViewportHeight ); - this.setState({ - top: Math.min(lastTop, newTop), - selectedRanges: [new GridRange(null, focusedRow, null, focusedRow)], - isStuckToBottom: false, - }); const { cursorColumn } = this.state; - this.moveCursorToPosition(cursorColumn, focusedRow, false, false); + this.setState(state => { + const newSel = state.selection.withCommittedRanges([ + new GridRange(null, focusedRow, null, focusedRow), + ]); + return { + top: Math.min(lastTop, newTop), + selection: newSel, + lastSelection: newSel, + selectedRanges: selectionToRanges(newSel), + isStuckToBottom: false, + }; + }); + // Update cursor coordinates only — no gesture/commit cycle so deselect-on-reclick cannot fire. + this.beginSelection(cursorColumn, focusedRow); } /** * Set the selection to the entire grid */ selectAll(): void { - const { model, theme } = this.props; - const { autoSelectRow, autoSelectColumn } = theme; - - const top = autoSelectColumn !== undefined && autoSelectColumn ? null : 0; - const bottom = - autoSelectColumn !== undefined && autoSelectColumn - ? null - : model.rowCount - 1; - const left = autoSelectRow !== undefined && autoSelectRow ? null : 0; - const right = - autoSelectRow !== undefined && autoSelectRow - ? null - : model.columnCount - 1; - this.setSelectedRanges([new GridRange(left, top, right, bottom)]); + this.setState(state => { + const newSelection = state.selection.selectAll(); + return { + selection: newSelection, + lastSelection: newSelection, + selectedRanges: selectionToRanges(newSelection), + }; + }); } /** @@ -1366,7 +1423,8 @@ class Grid extends PureComponent { moveCursorInDirection(direction = GridRange.SELECTION_DIRECTION.DOWN): void { const { model } = this.props; const { columnCount, rowCount } = model; - const { cursorRow, cursorColumn, selectedRanges } = this.state; + const { cursorRow, cursorColumn, selection } = this.state; + const selectedRanges = selection.toActiveRanges(); const ranges = selectedRanges.length > 0 ? selectedRanges @@ -1395,10 +1453,12 @@ class Grid extends PureComponent { }); if (!GridRange.containsCell(selectedRanges, column, row)) { + const newSel = selection + .withCommittedRanges([GridRange.makeCell(column, row)]) + .withGestureAnchor(row, column); this.setState({ - selectedRanges: [GridRange.makeCell(column, row)], - selectionStartColumn: column, - selectionStartRow: row, + selection: newSel, + selectedRanges: selectionToRanges(newSel), selectionEndColumn: column, selectionEndRow: row, }); @@ -1415,19 +1475,25 @@ class Grid extends PureComponent { * @param extendSelection Whether to extend the current selection (eg. holding Shift) * @param keepCursorInView Whether to move the viewport so that the cursor is in view * @param maximizePreviousRange With this and `extendSelection` true, it will maximize/add to the previous range only, ignoring where the selection was started + * @param commit Whether to commit the selection after moving. Drag paths pass `false` to keep the movement a transient overlay — committing per-move would trigger a resolve/fetch on every mousemove for keyed selections. */ moveCursorToPosition( column: GridRangeIndex, row: GridRangeIndex, extendSelection = false, keepCursorInView = true, - maximizePreviousRange = false + maximizePreviousRange = false, + commit = true ): void { if (!extendSelection) { this.beginSelection(column, row); } this.moveSelection(column, row, extendSelection, maximizePreviousRange); + if (commit) { + // Commit after every keyboard move so KeyedSelection resolves keys immediately. + this.commitSelection(); + } if (keepCursorInView) { this.moveViewToCell(column, row); @@ -1564,7 +1630,10 @@ class Grid extends PureComponent { */ async pasteValue(value: string[][] | string): Promise { const { model } = this.props; - const { movedColumns, movedRows, selectedRanges } = this.state; + const { movedColumns, movedRows, selection } = this.state; + // pasteValue is only reachable for editable (non-keyed) tables + assertIsRangedSelection(selection); + const selectedRanges = selection.toRanges(); try { assertIsEditableGridModel(model); @@ -1576,7 +1645,7 @@ class Grid extends PureComponent { throw new PasteError("Can't paste in to read-only area."); } - if (selectedRanges.length <= 0) { + if (selection.isEmpty()) { throw new PasteError('Select an area to paste to.'); } @@ -1701,23 +1770,8 @@ class Grid extends PureComponent { * @returns True if the cell is in the current selection, false otherwise */ isSelected(row: VisibleIndex, column: VisibleIndex): boolean { - const { selectedRanges } = this.state; - - for (let i = 0; i < selectedRanges.length; i += 1) { - const selectedRange = selectedRanges[i]; - const rowSelected = - selectedRange.startRow === null || - (selectedRange.startRow <= row && row <= (selectedRange.endRow ?? 0)); - const columnSelected = - selectedRange.startColumn === null || - (selectedRange.startColumn <= column && - column <= (selectedRange.endColumn ?? 0)); - if (rowSelected && columnSelected) { - return true; - } - } - - return false; + const { selection } = this.state; + return selection.isCellSelected(column, row); } addDocumentCursor(cursor: string | null = null): void { @@ -2230,7 +2284,7 @@ class Grid extends PureComponent { fillRange = false, }: { direction?: SELECTION_DIRECTION | null; fillRange?: boolean } = {} ): void { - const { editingCell, selectedRanges } = this.state; + const { editingCell, selection } = this.state; if (!editingCell) throw new Error('editingCell not set'); const { column, row } = editingCell; @@ -2244,7 +2298,9 @@ class Grid extends PureComponent { } if (fillRange) { - this.setValueForRanges(selectedRanges, value); + // fillRange is only reachable for editable (non-keyed) tables + assertIsRangedSelection(selection); + this.setValueForRanges(selection.toRanges(), value); } else { this.setValueForCell(column, row, value); } @@ -2409,7 +2465,7 @@ class Grid extends PureComponent { isDragging, mouseX, mouseY, - selectedRanges, + selection, } = this.state; const { model, stateOverride } = this.props; const { metrics } = this; @@ -2429,7 +2485,7 @@ class Grid extends PureComponent { metrics, mouseX, mouseY, - selectedRanges, + selection, draggingColumn, draggingColumnSeparator, draggingRow, diff --git a/packages/grid/src/GridRenderer.test.tsx b/packages/grid/src/GridRenderer.test.tsx index a968fcf412..63817f87ff 100644 --- a/packages/grid/src/GridRenderer.test.tsx +++ b/packages/grid/src/GridRenderer.test.tsx @@ -6,6 +6,7 @@ import GridTheme from './GridTheme'; import type TextCellRenderer from './TextCellRenderer'; import { type LinkToken } from './GridUtils'; import { type GridRenderState } from './GridRendererTypes'; +import { RangedSelection } from './RangedSelection'; const makeMockContext = (): CanvasRenderingContext2D => // Just return a partial mock @@ -89,7 +90,7 @@ const makeMockGridRenderState = ({ mouseY: 0, cursorColumn: 0, cursorRow: 0, - selectedRanges: [], + selection: RangedSelection.empty(() => model), draggingColumn: null, draggingColumnSeparator: null, draggingRow: null, diff --git a/packages/grid/src/GridRenderer.ts b/packages/grid/src/GridRenderer.ts index 3932d63119..d4af2b7cd7 100644 --- a/packages/grid/src/GridRenderer.ts +++ b/packages/grid/src/GridRenderer.ts @@ -19,10 +19,6 @@ import type CellRenderer from './CellRenderer'; import DataBarCellRenderer from './DataBarCellRenderer'; import TextCellRenderer from './TextCellRenderer'; -type NoneNullColumnRange = { startColumn: number; endColumn: number }; - -type NoneNullRowRange = { startRow: number; endRow: number }; - /* eslint react/destructuring-assignment: "off" */ /* eslint class-methods-use-this: "off" */ /* eslint no-param-reassign: "off" */ @@ -410,6 +406,7 @@ export class GridRenderer { if (floatingLeftColumnCount > 0) { this.drawSelectedRanges(context, state, { left: 0, + right: floatingLeftColumnCount - 1, maxX: getOrThrow(allColumnXs, floatingLeftColumnCount - 1) + getOrThrow(allColumnWidths, floatingLeftColumnCount - 1), @@ -819,7 +816,7 @@ export class GridRenderer { state: GridRenderState, row: VisibleIndex ): void { - const { metrics, selectedRanges, theme } = state; + const { metrics, selection, theme } = state; const { allRowHeights, allRowYs, maxX } = metrics; const y = getOrThrow(allRowYs, row); @@ -828,18 +825,9 @@ export class GridRenderer { if (theme.rowHoverBackgroundColor != null) { context.fillStyle = theme.rowHoverBackgroundColor; } - for (let i = 0; i < selectedRanges.length; i += 1) { - const { startRow, endRow } = selectedRanges[i]; - if ( - startRow != null && - endRow != null && - startRow <= row && - endRow >= row - ) { - if (theme.selectedRowHoverBackgroundColor != null) { - context.fillStyle = theme.selectedRowHoverBackgroundColor; - } - break; + if (selection.isRowSelected(row)) { + if (theme.selectedRowHoverBackgroundColor != null) { + context.fillStyle = theme.selectedRowHoverBackgroundColor; } } context.fillRect(0, y, maxX, rowHeight); @@ -2039,7 +2027,7 @@ export class GridRenderer { editingCell, metrics, model, - selectedRanges, + selection, theme, } = state; const { @@ -2060,7 +2048,8 @@ export class GridRenderer { minX = -10, maxX = width + 10, } = viewport; - if (selectedRanges.length === 0) { + + if (selection.isEmpty()) { return; } @@ -2091,51 +2080,123 @@ export class GridRenderer { context.clip('evenodd'); } - // Draw selection ranges + // Column bounds are constant across all rows for full-row selection. + // Guard against missing keys during resize/initial-load when allColumnXs may be empty. + const rowSelectionX = allColumnXs.has(left) + ? Math.max(Math.round(getOrThrow(allColumnXs, left)) + 0.5, minX) + : maxX; + const rowSelectionEndX = + allColumnXs.has(right) && allColumnWidths.has(right) + ? Math.min( + Math.round( + getOrThrow(allColumnXs, right) + + getOrThrow(allColumnWidths, right) + ) - 0.5, + maxX + ) + : minX; + context.beginPath(); - for (let i = 0; i < selectedRanges.length; i += 1) { - const selectedRange = selectedRanges[i]; - const startColumn = - selectedRange.startColumn !== null ? selectedRange.startColumn : left; - const startRow = - selectedRange.startRow !== null ? selectedRange.startRow : top; - const endColumn = - selectedRange.endColumn !== null ? selectedRange.endColumn : right; - const endRow = - selectedRange.endRow !== null ? selectedRange.endRow : bottom; - if ( - endRow >= top && - bottom >= startRow && - endColumn >= left && - right >= startColumn - ) { - // Need to offset the x/y coordinates so that the line draws nice and crisp - const x = - startColumn >= left && allColumnXs.has(startColumn) - ? Math.round(getOrThrow(allColumnXs, startColumn)) + 0.5 - : minX; - const y = - startRow >= top && allRowYs.has(startRow) - ? Math.max(Math.round(getOrThrow(allRowYs, startRow)) + 0.5, 0.5) - : minY; - - const endX = - endColumn <= right && allColumnXs.has(endColumn) - ? Math.round( - getOrThrow(allColumnXs, endColumn) + - getOrThrow(allColumnWidths, endColumn) - ) - 0.5 - : maxX; - const endY = - endRow <= bottom && allRowYs.has(endRow) - ? Math.round( - getOrThrow(allRowYs, endRow) + getOrThrow(allRowHeights, endRow) - ) - 0.5 - : maxY; - - context.rect(x, y, endX - x, endY - y); + // A full-row run is a special case of a partial run whose column list is a + // single full-width entry, so both cases share one coalescer below. Reused + // by reference so the equality check short-circuits for consecutive full rows. + const FULL_ROW_COLS: readonly { x: number; endX: number }[] = + rowSelectionEndX > rowSelectionX + ? [{ x: rowSelectionX, endX: rowSelectionEndX }] + : []; + + // Coalesce vertically-adjacent rows sharing an identical column signature + // into one rect per column run rather than rendering strokes between each row. + let runStartY: number | null = null; + let runEndY = 0; + let runCols: readonly { x: number; endX: number }[] = []; + const colsEqual = ( + a: readonly { x: number; endX: number }[], + b: readonly { x: number; endX: number }[] + ): boolean => { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i].x !== b[i].x || a[i].endX !== b[i].endX) return false; + } + return true; + }; + const flushRun = (): void => { + if (runStartY != null) { + for (let i = 0; i < runCols.length; i += 1) { + const { x, endX } = runCols[i]; + context.rect(x, runStartY, endX - x, runEndY - runStartY); + } + } + runStartY = null; + runCols = []; + }; + + for (let r = top; r <= bottom; r += 1) { + const rowY = allRowYs.get(r); + const rowH = allRowHeights.get(r); + if (rowY == null || rowH == null) { + flushRun(); + // eslint-disable-next-line no-continue + continue; + } + const y = Math.max(Math.round(rowY) + 0.5, 0.5); + const endY = Math.round(rowY + rowH) - 0.5; + if (endY < minY || y > maxY) { + flushRun(); + // eslint-disable-next-line no-continue + continue; + } + + let rowCols: readonly { x: number; endX: number }[]; + if (selection.isRowSelected(r)) { + rowCols = FULL_ROW_COLS; + } else { + const built: { x: number; endX: number }[] = []; + const pushRun = ( + runStartCol: VisibleIndex, + runEndCol: VisibleIndex + ): void => { + const x = Math.max( + Math.round(getOrThrow(allColumnXs, runStartCol)) + 0.5, + minX + ); + const endX = Math.min( + Math.round( + getOrThrow(allColumnXs, runEndCol) + + getOrThrow(allColumnWidths, runEndCol) + ) - 0.5, + maxX + ); + if (endX > x) built.push({ x, endX }); + }; + let runStart: VisibleIndex | null = null; + for (let c = left; c <= right; c += 1) { + if (selection.isCellSelected(c, r)) { + if (runStart === null) runStart = c; + } else if (runStart !== null) { + pushRun(runStart, c - 1); + runStart = null; + } + } + if (runStart !== null) { + pushRun(runStart, right); + } + rowCols = built; + } + + if (rowCols.length === 0) { + flushRun(); + } else if (runStartY != null && colsEqual(rowCols, runCols)) { + runEndY = endY; + } else { + flushRun(); + runCols = rowCols; + runStartY = y; + runEndY = endY; } } + flushRun(); /** * Create the path, then draw it once. Fill and @@ -2609,20 +2670,14 @@ export class GridRenderer { ) { context.fillStyle = scrollBarSelectionTickColor; // Scrollbar Selection Tick - const { selectedRanges, cursorColumn } = state; + const { cursorColumn } = state; const { lastLeft, columnCount } = metrics; - const filteredRanges = [...selectedRanges].filter( - value => value.startColumn != null && value.endColumn != null - ) as NoneNullColumnRange[]; - - const sortedRanges = filteredRanges - .map( - (value): BoundedAxisRange => [value.startColumn, value.endColumn] + const mergedRanges = GridUtils.mergeSortedRanges( + [...state.selection.getColumnTickRanges()].sort( + GridUtils.compareRanges ) - .sort(GridUtils.compareRanges); - - const mergedRanges = GridUtils.mergeSortedRanges(sortedRanges); + ); const getTickX = (index: number): number => { if (index <= lastLeft) { @@ -2717,7 +2772,7 @@ export class GridRenderer { scrollBarActiveSelectionTickColor != null ) { // Scrollbar Selection Tick - const { selectedRanges, cursorRow } = state; + const { cursorRow } = state; const { lastTop, rowCount } = metrics; const getTickY = (index: number): number => { @@ -2733,15 +2788,9 @@ export class GridRenderer { context.fillStyle = scrollBarSelectionTickColor; - const filteredRanges = [...selectedRanges].filter( - value => value.startRow != null && value.endRow != null - ) as NoneNullRowRange[]; - - const sortedRanges = filteredRanges - .map((value): BoundedAxisRange => [value.startRow, value.endRow]) - .sort(GridUtils.compareRanges); - - const mergedRanges = GridUtils.mergeSortedRanges(sortedRanges); + const mergedRanges = GridUtils.mergeSortedRanges( + [...state.selection.getRowTickRanges()].sort(GridUtils.compareRanges) + ); for (let i = 0; i < mergedRanges.length; i += 1) { const range = mergedRanges[i]; diff --git a/packages/grid/src/GridRendererTypes.ts b/packages/grid/src/GridRendererTypes.ts index 2027d35739..029009bd8a 100644 --- a/packages/grid/src/GridRendererTypes.ts +++ b/packages/grid/src/GridRendererTypes.ts @@ -2,12 +2,12 @@ import type React from 'react'; import { type VisibleIndex, type Coordinate } from './GridMetrics'; import type GridMetrics from './GridMetrics'; import type GridModel from './GridModel'; -import type GridRange from './GridRange'; import { type GridTheme } from './GridTheme'; import { type DraggingColumn } from './mouse-handlers/GridColumnMoveMouseHandler'; import { type GridSeparator } from './mouse-handlers/GridSeparatorMouseHandler'; import type { CellInputFieldProps } from './CellInputField'; import type { ColumnRestriction } from './GridModel'; +import type { Selection } from './Selection'; // Default font width in pixels if it cannot be retrieved from the context export const DEFAULT_FONT_WIDTH = 10; @@ -90,8 +90,8 @@ export type GridRenderState = { cursorColumn: VisibleIndex | null; cursorRow: VisibleIndex | null; - // Currently selected ranges - selectedRanges: readonly GridRange[]; + // Current selection + selection: Selection; // Currently dragged column/row information draggingColumn: DraggingColumn | null; diff --git a/packages/grid/src/RangedSelection.test.ts b/packages/grid/src/RangedSelection.test.ts new file mode 100644 index 0000000000..70abad682c --- /dev/null +++ b/packages/grid/src/RangedSelection.test.ts @@ -0,0 +1,521 @@ +import GridRange from './GridRange'; +import { + RangedSelection, + isRangedSelection, + assertIsRangedSelection, +} from './RangedSelection'; +import type { GetModel } from './Selection'; + +const COLUMN_COUNT = 10; +const ROW_COUNT = 100; + +function makeGetModel( + columnCount = COLUMN_COUNT, + rowCount = ROW_COUNT +): GetModel { + return () => ({ columnCount, rowCount }) as never; +} + +const getModel = makeGetModel(); + +// ─── factories ─────────────────────────────────────────────────────────────── + +function empty() { + return RangedSelection.empty(getModel); +} + +function single(col: number, row: number) { + return new RangedSelection([GridRange.makeCell(col, row)], getModel); +} + +function range(c1: number, r1: number, c2: number, r2: number) { + return new RangedSelection([new GridRange(c1, r1, c2, r2)], getModel); +} + +function fullRow(row: number) { + return new RangedSelection([new GridRange(null, row, null, row)], getModel); +} + +// ─── isEmpty ───────────────────────────────────────────────────────────────── + +describe('isEmpty', () => { + it('returns true for empty selection', () => { + expect(empty().isEmpty()).toBe(true); + }); + + it('returns false for a single-cell selection', () => { + expect(single(0, 0).isEmpty()).toBe(false); + }); + + it('returns false for a multi-range selection', () => { + const sel = new RangedSelection( + [GridRange.makeCell(0, 0), GridRange.makeCell(1, 1)], + getModel + ); + expect(sel.isEmpty()).toBe(false); + }); +}); + +// ─── isCellSelected ────────────────────────────────────────────────────────── + +describe('isCellSelected', () => { + it('returns false for empty selection', () => { + expect(empty().isCellSelected(0, 0)).toBe(false); + }); + + it('returns true for an exactly matching single-cell range', () => { + expect(single(3, 5).isCellSelected(3, 5)).toBe(true); + }); + + it('returns false for a cell outside the single-cell range', () => { + expect(single(3, 5).isCellSelected(3, 6)).toBe(false); + expect(single(3, 5).isCellSelected(4, 5)).toBe(false); + }); + + it('handles null column bounds (full-row range)', () => { + const sel = fullRow(3); + expect(sel.isCellSelected(0, 3)).toBe(true); + expect(sel.isCellSelected(COLUMN_COUNT - 1, 3)).toBe(true); + expect(sel.isCellSelected(0, 4)).toBe(false); + }); + + it('handles null row bounds (full-column range)', () => { + const sel = new RangedSelection( + [new GridRange(2, null, 2, null)], + getModel + ); + expect(sel.isCellSelected(2, 0)).toBe(true); + expect(sel.isCellSelected(2, ROW_COUNT - 1)).toBe(true); + expect(sel.isCellSelected(3, 0)).toBe(false); + }); + + it('matches cells within a multi-cell range', () => { + const sel = range(1, 2, 3, 4); + expect(sel.isCellSelected(2, 2)).toBe(true); + expect(sel.isCellSelected(3, 4)).toBe(true); + expect(sel.isCellSelected(1, 1)).toBe(false); + expect(sel.isCellSelected(4, 1)).toBe(false); + }); + + it('returns true if any range in a multi-range selection matches', () => { + const sel = new RangedSelection( + [GridRange.makeCell(0, 0), GridRange.makeCell(5, 5)], + getModel + ); + expect(sel.isCellSelected(0, 0)).toBe(true); + expect(sel.isCellSelected(5, 5)).toBe(true); + expect(sel.isCellSelected(1, 1)).toBe(false); + }); +}); + +// ─── isRowSelected ─────────────────────────────────────────────────────────── + +describe('isRowSelected', () => { + it('returns false for empty selection', () => { + expect(empty().isRowSelected(0)).toBe(false); + }); + + it('returns true when null column bounds span the row', () => { + expect(fullRow(3).isRowSelected(3)).toBe(true); + expect(fullRow(3).isRowSelected(4)).toBe(false); + }); + + it('returns true when explicit column bounds cover [0, columnCount-1]', () => { + const sel = range(0, 5, COLUMN_COUNT - 1, 5); + expect(sel.isRowSelected(5)).toBe(true); + }); + + it('returns false when column bounds do not cover the full row', () => { + const sel = range(0, 5, COLUMN_COUNT - 2, 5); + expect(sel.isRowSelected(5)).toBe(false); + }); +}); + +// ─── isValid ───────────────────────────────────────────────────────────────── + +describe('isValid', () => { + it('returns true for empty selection', () => { + expect(empty().isValid(COLUMN_COUNT, ROW_COUNT)).toBe(true); + }); + + it('returns true when all ranges are within bounds', () => { + expect(range(0, 0, 5, 10).isValid(COLUMN_COUNT, ROW_COUNT)).toBe(true); + }); + + it('returns false when a range exceeds columnCount', () => { + expect(range(0, 0, COLUMN_COUNT, 0).isValid(COLUMN_COUNT, ROW_COUNT)).toBe( + false + ); + }); + + it('returns false when a range exceeds rowCount', () => { + expect(range(0, 0, 0, ROW_COUNT).isValid(COLUMN_COUNT, ROW_COUNT)).toBe( + false + ); + }); + + it('returns true for null bounds (unbounded ranges are always valid)', () => { + expect(fullRow(5).isValid(COLUMN_COUNT, ROW_COUNT)).toBe(true); + }); +}); + +// ─── toRanges / toActiveRanges ─────────────────────────────────────────────── + +describe('toRanges and toActiveRanges', () => { + it('return the same array reference as the internal ranges', () => { + const ranges = [GridRange.makeCell(0, 0)]; + const sel = new RangedSelection(ranges, getModel); + expect(sel.toRanges()).toBe(ranges); + expect(sel.toActiveRanges()).toBe(ranges); + }); + + it('return empty array for empty selection', () => { + expect(empty().toRanges()).toHaveLength(0); + expect(empty().toActiveRanges()).toHaveLength(0); + }); +}); + +// ─── getColumnTickRanges ───────────────────────────────────────────────────── + +describe('getColumnTickRanges', () => { + it('returns empty for an empty selection', () => { + expect(empty().getColumnTickRanges()).toHaveLength(0); + }); + + it('returns empty for a full-row range (null column bounds)', () => { + expect(fullRow(5).getColumnTickRanges()).toHaveLength(0); + }); + + it('returns a tick range for each bounded column range', () => { + const sel = new RangedSelection( + [new GridRange(2, 0, 5, 0), new GridRange(7, 0, 9, 0)], + getModel + ); + expect(sel.getColumnTickRanges()).toEqual([ + [2, 5], + [7, 9], + ]); + }); +}); + +// ─── getRowTickRanges ──────────────────────────────────────────────────────── + +describe('getRowTickRanges', () => { + it('returns empty for an empty selection', () => { + expect(empty().getRowTickRanges()).toHaveLength(0); + }); + + it('returns a tick range for each bounded row range', () => { + const sel = new RangedSelection( + [new GridRange(0, 3, 0, 7), new GridRange(0, 10, 0, 15)], + getModel + ); + expect(sel.getRowTickRanges()).toEqual([ + [3, 7], + [10, 15], + ]); + }); +}); + +// ─── withCommittedRanges ─────────────────────────────────────────────────────── + +describe('withCommittedRanges', () => { + it('returns the same instance when given the same ranges reference', () => { + const sel = single(0, 0); + expect(sel.withCommittedRanges(sel.toRanges())).toBe(sel); + }); + + it('returns a new instance with the new ranges', () => { + const sel = single(0, 0); + const newRanges = [GridRange.makeCell(1, 1)]; + const updated = sel.withCommittedRanges(newRanges); + expect(updated).not.toBe(sel); + expect(updated.toRanges()).toBe(newRanges); + }); +}); + +// ─── withMouseGestureRanges ────────────────────────────────────────────────── + +describe('withMouseGestureRanges', () => { + it('behaves identically to withCommittedRanges', () => { + const sel = single(0, 0); + const newRanges = [GridRange.makeCell(2, 2)]; + const viaGesture = sel.withMouseGestureRanges(newRanges); + const viaUpdated = sel.withCommittedRanges(newRanges); + expect(viaGesture.toRanges()).toEqual(viaUpdated.toRanges()); + }); + + it('preserves the gesture anchor', () => { + const sel = single(0, 0).withGestureAnchor(3, 4); + const updated = sel.withMouseGestureRanges([GridRange.makeCell(2, 2)]); + expect(updated.getGestureAnchor()).toEqual({ row: 3, column: 4 }); + }); +}); + +// ─── withGestureAnchor / getGestureAnchor ──────────────────────────────────── + +describe('getGestureAnchor', () => { + it('returns null when no anchor was set', () => { + expect(empty().getGestureAnchor()).toBeNull(); + }); + + it('round-trips row and column through withGestureAnchor', () => { + const sel = empty().withGestureAnchor(7, 3); + expect(sel.getGestureAnchor()).toEqual({ row: 7, column: 3 }); + }); + + it('returns identity when the anchor is unchanged', () => { + const sel = empty().withGestureAnchor(1, 2); + expect(sel.withGestureAnchor(1, 2)).toBe(sel); + }); + + it('clears the anchor when both row and column are null', () => { + const sel = empty().withGestureAnchor(1, 2).withGestureAnchor(null, null); + expect(sel.getGestureAnchor()).toBeNull(); + }); + + it('returns the anchor when only row is set', () => { + expect(empty().withGestureAnchor(5, null).getGestureAnchor()).toEqual({ + row: 5, + column: null, + }); + }); + + it('is cleared by withCommittedRanges (fresh replacement)', () => { + const sel = single(0, 0) + .withGestureAnchor(3, 4) + .withCommittedRanges([GridRange.makeCell(1, 1)]); + expect(sel.getGestureAnchor()).toBeNull(); + }); + + it('is preserved by commitMouseGesture', () => { + const sel = single(1, 1).withGestureAnchor(2, 3); + const committed = sel.commitMouseGesture(empty(), { autoSelectRow: false }); + expect(committed.getGestureAnchor()).toEqual({ row: 2, column: 3 }); + }); + + it('is preserved by trimmed()', () => { + const sel = new RangedSelection( + [GridRange.makeCell(0, 0), GridRange.makeCell(1, 1)], + getModel + ).withGestureAnchor(5, 6); + expect(sel.trimmed().getGestureAnchor()).toEqual({ row: 5, column: 6 }); + }); +}); + +// ─── selectAll ─────────────────────────────────────────────────────────────── + +describe('selectAll', () => { + it('selects all rows with null column bounds', () => { + const sel = empty().selectAll(); + expect(sel.toRanges()).toEqual([ + new GridRange(null, 0, null, ROW_COUNT - 1), + ]); + }); + + it('uses the current model row count', () => { + const customRowCount = 50; + const sel = RangedSelection.empty( + makeGetModel(COLUMN_COUNT, customRowCount) + ); + expect(sel.selectAll().toRanges()).toEqual([ + new GridRange(null, 0, null, customRowCount - 1), + ]); + }); +}); + +// ─── getLastSingleSelectedRow ──────────────────────────────────────────────── + +describe('getLastSingleSelectedRow', () => { + it('returns null for empty selection', () => { + expect(empty().getLastSingleSelectedRow()).toBeNull(); + }); + + it('returns the row for a single-cell selection', () => { + expect(single(3, 7).getLastSingleSelectedRow()).toBe(7); + }); + + it('returns the row for a full-row single-row selection', () => { + expect(fullRow(4).getLastSingleSelectedRow()).toBe(4); + }); + + it('returns null when multiple rows are selected', () => { + expect(range(0, 0, 0, 5).getLastSingleSelectedRow()).toBeNull(); + }); + + it('returns null for multiple ranges', () => { + const sel = new RangedSelection( + [GridRange.makeCell(0, 0), GridRange.makeCell(1, 1)], + getModel + ); + expect(sel.getLastSingleSelectedRow()).toBeNull(); + }); +}); + +// ─── commitMouseGesture ────────────────────────────────────────────────────── + +describe('commitMouseGesture', () => { + it('deselects when committing the same single cell over itself', () => { + const last = single(3, 5); + const current = single(3, 5); + const result = current.commitMouseGesture(last, { autoSelectRow: false }); + expect(result.isEmpty()).toBe(true); + }); + + it('deselects when committing the same single row with autoSelectRow', () => { + const last = fullRow(3); + const current = fullRow(3); + const result = current.commitMouseGesture(last, { autoSelectRow: true }); + expect(result.isEmpty()).toBe(true); + }); + + it('does NOT deselect a single row without autoSelectRow', () => { + const last = fullRow(3); + const current = fullRow(3); + const result = current.commitMouseGesture(last, { autoSelectRow: false }); + expect(result.isEmpty()).toBe(false); + }); + + it('keeps a new single-cell selection when lastCommitted is empty', () => { + const current = single(2, 4); + const result = current.commitMouseGesture(empty(), { + autoSelectRow: false, + }); + expect(result.toRanges()).toEqual(current.toRanges()); + }); + + it('subtracts an overlapping range from a previous range (ctrl+click)', () => { + const outer = range(0, 0, 5, 5); + const inner = range(1, 1, 3, 3); + // inner is already contained in outer; committing inner subtracts it + const combined = new RangedSelection( + [...outer.toRanges(), ...inner.toRanges()], + getModel + ); + const result = combined.commitMouseGesture(outer, { autoSelectRow: false }); + // inner area should be cut out; result should not include (2,2) + expect(result.isCellSelected(2, 2)).toBe(false); + // outer areas outside inner should remain + expect(result.isCellSelected(0, 0)).toBe(true); + }); + + it('consolidates adjacent ranges', () => { + const a = new GridRange(0, 0, 0, 5); + const b = new GridRange(0, 6, 0, 10); + const sel = new RangedSelection([a, b], getModel); + const result = sel.commitMouseGesture(empty(), { autoSelectRow: false }); + const ranges = result.toRanges(); + // Adjacent ranges [0,0-5] and [0,6-10] should consolidate to [0,0-10] + expect(ranges).toHaveLength(1); + expect(ranges[0]).toEqual(new GridRange(0, 0, 0, 10)); + }); +}); + +// ─── clear ─────────────────────────────────────────────────────────────────── + +describe('clear', () => { + it('returns an empty selection', () => { + expect(single(0, 0).clear().isEmpty()).toBe(true); + }); + + it('returns an empty selection from an already-empty selection', () => { + expect(empty().clear().isEmpty()).toBe(true); + }); +}); + +// ─── trimmed ───────────────────────────────────────────────────────────────── + +describe('trimmed', () => { + it('returns the same instance for an empty selection', () => { + const sel = empty(); + expect(sel.trimmed()).toBe(sel); + }); + + it('returns an equivalent selection for a single-range selection', () => { + const sel = single(0, 0); + expect(sel.trimmed().toRanges()).toEqual(sel.toRanges()); + }); + + it('keeps only the last range from a multi-range selection', () => { + const sel = new RangedSelection( + [ + GridRange.makeCell(0, 0), + GridRange.makeCell(3, 3), + GridRange.makeCell(7, 7), + ], + getModel + ); + const trimmed = sel.trimmed(); + expect(trimmed.toRanges()).toHaveLength(1); + expect(trimmed.toRanges()[0]).toEqual(GridRange.makeCell(7, 7)); + }); +}); + +// ─── truncate ──────────────────────────────────────────────────────────────── + +describe('truncate', () => { + it('returns the same instance when already within maxRows', () => { + const sel = range(0, 0, 0, 4); // 5 rows + expect(sel.truncate(10)).toBe(sel); + expect(sel.truncate(5)).toBe(sel); + }); + + it('truncates a single range to the max row count', () => { + const sel = range(0, 0, 0, 9); // 10 rows + const result = sel.truncate(5); + expect(GridRange.rowCount(result.toRanges())).toBe(5); + expect(result.toRanges()[0]).toEqual(new GridRange(0, 0, 0, 4)); + }); + + it('removes entire trailing ranges that exceed maxRows', () => { + const sel = new RangedSelection( + [ + new GridRange(0, 0, 0, 4), // 5 rows + new GridRange(0, 10, 0, 14), // 5 rows → total 10 + ], + getModel + ); + const result = sel.truncate(5); + expect(GridRange.rowCount(result.toRanges())).toBe(5); + expect(result.toRanges()).toHaveLength(1); + }); + + it('partially trims the last range when it straddles the limit', () => { + const sel = new RangedSelection( + [ + new GridRange(0, 0, 0, 2), // 3 rows + new GridRange(0, 10, 0, 14), // 5 rows → total 8 + ], + getModel + ); + const result = sel.truncate(5); // need to trim 3 rows from the second range + expect(GridRange.rowCount(result.toRanges())).toBe(5); + expect(result.toRanges()[1]).toEqual(new GridRange(0, 10, 0, 11)); + }); +}); + +// ─── isRangedSelection ─────────────────────────────────────────────────────── + +describe('isRangedSelection', () => { + it('returns true for a RangedSelection', () => { + expect(isRangedSelection(empty())).toBe(true); + }); + + it('returns false for a non-RangedSelection', () => { + const fakeSelection = { isEmpty: () => true } as never; + expect(isRangedSelection(fakeSelection)).toBe(false); + }); +}); + +// ─── assertIsRangedSelection ───────────────────────────────────────────────── + +describe('assertIsRangedSelection', () => { + it('does not throw for a RangedSelection', () => { + expect(() => assertIsRangedSelection(empty())).not.toThrow(); + }); + + it('throws for a non-RangedSelection', () => { + const fakeSelection = { constructor: { name: 'Fake' } } as never; + expect(() => assertIsRangedSelection(fakeSelection)).toThrow(); + }); +}); diff --git a/packages/grid/src/RangedSelection.ts b/packages/grid/src/RangedSelection.ts new file mode 100644 index 0000000000..6e2a856adc --- /dev/null +++ b/packages/grid/src/RangedSelection.ts @@ -0,0 +1,297 @@ +import { EMPTY_ARRAY, assertNotNaN, assertNotNull } from '@deephaven/utils'; +import GridRange, { type GridRangeIndex } from './GridRange'; +import type { VisibleIndex } from './GridMetrics'; +import { type BoundedAxisRange } from './GridAxisRange'; +import type { + CommitMouseGestureOptions, + GetModel, + Selection, +} from './Selection'; + +/** + * Immutable `Selection` for standard (row-indexed) grids. Each entry in + * `ranges` describes a rectangle of cells; consumers iterate/consolidate + * as needed. + */ +export class RangedSelection implements Selection { + static empty(getModel: GetModel): RangedSelection { + return new RangedSelection(EMPTY_ARRAY, getModel); + } + + constructor( + /** Committed selection rectangles; may be empty for a cleared selection. */ + readonly ranges: readonly GridRange[], + /** + * Deferred lookup for the current `GridModel`. Passed as a closure + * (not a direct reference) so this Selection always reads the model + * currently on `Grid.props.model` — surviving prop swaps without + * holding a stale reference. + */ + private readonly getModel: GetModel, + /** Anchor row for shift-click / keyboard extend; null when no anchor is set. */ + private readonly gestureStartRow: GridRangeIndex = null, + /** Anchor column for shift-click / keyboard extend; null when no anchor is set. */ + private readonly gestureStartColumn: GridRangeIndex = null + ) {} + + isEmpty(): boolean { + return this.ranges.length === 0; + } + + isCellSelected(column: VisibleIndex, row: VisibleIndex): boolean { + for (let i = 0; i < this.ranges.length; i += 1) { + const range = this.ranges[i]; + const rowSelected = + range.startRow === null || + (range.startRow <= row && row <= (range.endRow ?? 0)); + const columnSelected = + range.startColumn === null || + (range.startColumn <= column && column <= (range.endColumn ?? 0)); + if (rowSelected && columnSelected) { + return true; + } + } + return false; + } + + isRowSelected(row: VisibleIndex): boolean { + const { columnCount } = this.getModel(); + for (let i = 0; i < this.ranges.length; i += 1) { + const range = this.ranges[i]; + const rowInRange = + range.startRow === null || + (range.startRow <= row && row <= (range.endRow ?? 0)); + const allColumnsSelected = + range.startColumn === null || + (range.startColumn === 0 && + (range.endColumn ?? -1) === columnCount - 1); + if (rowInRange && allColumnsSelected) { + return true; + } + } + return false; + } + + isValid(columnCount: number, rowCount: number): boolean { + for (let i = 0; i < this.ranges.length; i += 1) { + const range = this.ranges[i]; + if ( + (range.endColumn != null && range.endColumn >= columnCount) || + (range.endRow != null && range.endRow >= rowCount) + ) { + return false; + } + } + return true; + } + + toRanges(): readonly GridRange[] { + return this.ranges; + } + + toActiveRanges(): readonly GridRange[] { + return this.ranges; + } + + getColumnTickRanges(): readonly BoundedAxisRange[] { + const result: BoundedAxisRange[] = []; + for (let i = 0; i < this.ranges.length; i += 1) { + const { startColumn, endColumn } = this.ranges[i]; + if (startColumn != null && endColumn != null) { + result.push([startColumn, endColumn]); + } + } + return result; + } + + getRowTickRanges(): readonly BoundedAxisRange[] { + const result: BoundedAxisRange[] = []; + for (let i = 0; i < this.ranges.length; i += 1) { + const { startRow, endRow } = this.ranges[i]; + if (startRow != null && endRow != null) { + result.push([startRow, endRow]); + } + } + return result; + } + + withCommittedRanges(ranges: readonly GridRange[]): RangedSelection { + if (ranges === this.ranges) return this; + return new RangedSelection(ranges, this.getModel); + } + + // Preserves the gesture anchor so mid-drag range updates don't clobber the shift-click origin. + withMouseGestureRanges( + ranges: readonly GridRange[], + _isReplacing?: boolean + ): RangedSelection { + // Identity check keeps the same object for commitMouseGesture's no-op path. + // This allows Grid.commitSelection to recognize that no changes have occurred. + if (ranges === this.ranges) return this; + return new RangedSelection( + ranges, + this.getModel, + this.gestureStartRow, + this.gestureStartColumn + ); + } + + withGestureAnchor( + row: GridRangeIndex, + column: GridRangeIndex + ): RangedSelection { + if (row === this.gestureStartRow && column === this.gestureStartColumn) { + return this; + } + return new RangedSelection(this.ranges, this.getModel, row, column); + } + + getGestureAnchor(): { + row: GridRangeIndex; + column: GridRangeIndex; + } | null { + if (this.gestureStartRow == null && this.gestureStartColumn == null) { + return null; + } + return { row: this.gestureStartRow, column: this.gestureStartColumn }; + } + + selectAll(): RangedSelection { + const { rowCount } = this.getModel(); + return this.withCommittedRanges([ + new GridRange(null, 0, null, rowCount - 1), + ]); + } + + getLastSingleSelectedRow(): VisibleIndex | null { + const consolidated = GridRange.consolidate(this.ranges); + if (GridRange.rowCount(consolidated) !== 1) return null; + return consolidated[0]?.startRow ?? null; + } + + commitMouseGesture( + lastCommitted: Selection, + { autoSelectRow }: CommitMouseGestureOptions + ): RangedSelection { + const selectedRanges = this.ranges; + // lastCommitted is always a RangedSelection when this method is called + assertIsRangedSelection(lastCommitted); + const lastRanges = lastCommitted.toRanges(); + + if ( + selectedRanges.length === 1 && + (autoSelectRow + ? GridRange.rowCount(selectedRanges) === 1 + : GridRange.cellCount(selectedRanges) === 1) && + GridRange.rangeArraysEqual(selectedRanges, lastRanges) + ) { + return new RangedSelection( + EMPTY_ARRAY, + this.getModel, + this.gestureStartRow, + this.gestureStartColumn + ); + } + + let newRanges = selectedRanges.slice(); + if (newRanges.length > 1) { + const lastRange = newRanges[newRanges.length - 1]; + for (let i = 0; i < newRanges.length - 1; i += 1) { + if (newRanges[i].contains(lastRange)) { + const remainder = newRanges[i].subtract(lastRange); + newRanges.pop(); + newRanges.splice(i, 1); + newRanges = newRanges.concat(remainder); + break; + } + } + newRanges = GridRange.consolidate(newRanges); + } + + const changed = + newRanges.length !== selectedRanges.length || + newRanges.some((r, i) => !r.equals(selectedRanges[i])); + return this.withMouseGestureRanges(changed ? newRanges : selectedRanges); + } + + // eslint-disable-next-line class-methods-use-this + clear(): RangedSelection { + return new RangedSelection(EMPTY_ARRAY, this.getModel); + } + + trimmed(): RangedSelection { + if (this.ranges.length > 0) { + return new RangedSelection( + this.ranges.slice(this.ranges.length - 1), + this.getModel, + this.gestureStartRow, + this.gestureStartColumn + ); + } + return this; + } + + truncate(maxRows: number): RangedSelection { + let rowCount = GridRange.rowCount(this.ranges); + if (rowCount <= maxRows) return this; + const ranges = [...this.ranges]; + while (rowCount > maxRows) { + const lastRow = ranges.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 (rowCount - lastRowSize < maxRows) { + ranges.push( + new GridRange( + lastRow.startColumn, + lastRow.startRow, + lastRow.endColumn, + (lastRow.endRow ?? 0) - (rowCount - maxRows) + ) + ); + break; + } + rowCount -= lastRowSize; + } + return new RangedSelection( + ranges, + this.getModel, + this.gestureStartRow, + this.gestureStartColumn + ); + } +} + +export function isRangedSelection( + selection: Selection +): selection is RangedSelection { + return selection instanceof RangedSelection; +} + +export function assertIsRangedSelection( + selection: Selection +): asserts selection is RangedSelection { + if (!(selection instanceof RangedSelection)) { + throw new Error( + `Expected a RangedSelection but got ${selection.constructor.name}` + ); + } +} + +/** + * Returns `selection.toRanges()` when `selection` is a `RangedSelection`, + * otherwise `EMPTY_ARRAY`. Handy for consumers that only care about the + * range-form projection of a selection (e.g. legacy `selectedRanges` + * callbacks) and want a stable empty array for keyed / null selections. + */ +export function selectionToRanges( + selection: Selection | null | undefined +): readonly GridRange[] { + return selection != null && isRangedSelection(selection) + ? selection.toRanges() + : EMPTY_ARRAY; +} + +export default RangedSelection; diff --git a/packages/grid/src/Selection.ts b/packages/grid/src/Selection.ts new file mode 100644 index 0000000000..5c43a66076 --- /dev/null +++ b/packages/grid/src/Selection.ts @@ -0,0 +1,131 @@ +import type GridRange from './GridRange'; +import type { GridRangeIndex } from './GridRange'; +import type GridModel from './GridModel'; +import type { VisibleIndex } from './GridMetrics'; +import type { BoundedAxisRange } from './GridAxisRange'; + +/** Provides current model data to Selection instances without holding a stale reference. */ +export type GetModel = () => GridModel; + +/** + * Options for `Selection.commitMouseGesture`. + */ +export type CommitMouseGestureOptions = { + /** + * When true, a single-row commit that repeats the previous single-row + * selection is treated as a deselect (matches theme `autoSelectRow` + * behavior). + */ + autoSelectRow: boolean; +}; + +/** + * Immutable value object representing the current selection state of the grid. + * Mutations return new instances; Grid stores the result in React state. + * + * Two write paths cover selection updates: + * - `withCommittedRanges` writes into the **committed** selection state + * (programmatic entry points like `Grid.setSelectedRanges`). + * - `withMouseGestureRanges` writes into the **transient overlay** state + * used for mid-gesture rendering (drag / shift-click on every mouse move). + * + * For `RangedSelection` these look the same because there's no separate + * overlay concept. For `KeyedSelection` they're distinct: overlay ranges + * drive gesture preview only; committed key sets change on `commitMouseGesture`. + */ +export interface Selection extends SelectionQueries, SelectionTransforms {} + +/** Read-only inspection of a `Selection`. Every method is side-effect-free. */ +export interface SelectionQueries { + /** True when the selection contains no cells and no in-progress gesture. */ + isEmpty: () => boolean; + /** True when `(column, row)` is part of the selection. */ + isCellSelected: (column: VisibleIndex, row: VisibleIndex) => boolean; + /** True when the entire row is part of the selection. */ + isRowSelected: (row: VisibleIndex) => boolean; + /** False if any selected range exceeds `columnCount` or `rowCount`. */ + isValid: (columnCount: number, rowCount: number) => boolean; + /** + * Ranges Grid uses for cursor positioning, extend-selection, and keyboard + * navigation. For `RangedSelection` these are the committed ranges; for + * `KeyedSelection` these are the transient overlay ranges (empty after commit). + */ + toActiveRanges: () => readonly GridRange[]; + /** Column `[start, end]` pairs for scrollbar tick rendering. */ + getColumnTickRanges: () => readonly BoundedAxisRange[]; + /** Row `[start, end]` pairs for scrollbar tick rendering. */ + getRowTickRanges: () => readonly BoundedAxisRange[]; + /** + * The single selected visible row, or `null` when zero or multiple rows + * are selected. Drives `gotoRow` sync. + */ + getLastSingleSelectedRow: () => VisibleIndex | null; + /** + * The current `{row, column}` of the gesture anchor, or `null` if none is + * set or the anchor is no longer resolvable (e.g. a keyed anchor whose + * row has scrolled out of the viewport with no row hint fallback). + */ + getGestureAnchor: () => { + row: GridRangeIndex; + column: GridRangeIndex; + } | null; +} + +/** + * Immutable transformations of a `Selection`. Each method returns a new + * `Selection`; the receiver is never modified. + */ +export interface SelectionTransforms { + /** A fresh empty selection with no committed state, overlay, or anchor. */ + clear: () => Selection; + /** + * A new selection keeping only the last committed range (for + * `RangedSelection`) or clearing committed keys (for `KeyedSelection`). + * Called by `Grid.trimSelectedRanges` immediately before shift-based + * extend so the anchor is preserved. + */ + trimmed: () => Selection; + /** + * Replaces the **committed** selection with the given ranges. Clears the + * gesture anchor and any transient overlay state. Programmatic entry point + * used by `Grid.setSelectedRanges`, `setFocusRow`, and + * `moveCursorInDirection`. + */ + withCommittedRanges: (ranges: readonly GridRange[]) => Selection; + /** + * Replaces the **transient overlay** ranges (mid-gesture preview) with + * the given ranges. Called on every mouse-move during a drag / shift-click. + * Preserves the gesture anchor. `commitMouseGesture` later folds the + * overlay into the committed state. + * + * When `isReplacing` is true the caller intends the overlay to replace + * the current committed selection (drag / shift+click). Implementations + * that would otherwise carry previously-committed state (e.g. + * `KeyedSelection.selectedKeys`) drop it. Ignored by `RangedSelection`. + */ + withMouseGestureRanges: ( + ranges: readonly GridRange[], + isReplacing?: boolean + ) => Selection; + /** + * Commits the transient overlay into the committed selection and returns + * the settled selection. Handles consolidation, deselect-on-reclick, and + * subtract logic. Returns `this` (identity) when there is nothing to + * commit, which lets `Grid.commitSelection` short-circuit its setState. + */ + commitMouseGesture: ( + lastCommitted: Selection, + options: CommitMouseGestureOptions + ) => Selection; + /** A new selection covering the entire grid. */ + selectAll: () => Selection; + /** A new selection containing at most `maxRows` rows. */ + truncate: (maxRows: number) => Selection; + /** + * A new selection whose gesture anchor is set to the given cell. The + * anchor is the extend-from position for shift-click and keyboard extend. + * Called from `Grid.beginSelection` on a fresh mouse-down. Passing `null` + * for both `row` and `column` clears the anchor. + */ + withGestureAnchor: (row: GridRangeIndex, column: GridRangeIndex) => Selection; +} diff --git a/packages/grid/src/index.ts b/packages/grid/src/index.ts index c8bf8a6024..5ec3cd37ba 100644 --- a/packages/grid/src/index.ts +++ b/packages/grid/src/index.ts @@ -30,6 +30,13 @@ export { default as ViewportDataGridModel } from './ViewportDataGridModel'; export { default as MockDataBarGridModel } from './MockDataBarGridModel'; export * from './key-handlers'; export * from './mouse-handlers'; +export * from './Selection'; +export { + RangedSelection, + isRangedSelection, + assertIsRangedSelection, + selectionToRanges, +} from './RangedSelection'; export * from './errors'; export * from './EventHandlerResult'; export * from './ThemeContext'; diff --git a/packages/grid/src/key-handlers/SelectionKeyHandler.ts b/packages/grid/src/key-handlers/SelectionKeyHandler.ts index a485e4f009..a73830454a 100644 --- a/packages/grid/src/key-handlers/SelectionKeyHandler.ts +++ b/packages/grid/src/key-handlers/SelectionKeyHandler.ts @@ -96,12 +96,12 @@ class SelectionKeyHandler extends KeyHandler { return true; } case 'Escape': - if (grid.state.selectedRanges.length === 0) return false; + if (grid.state.selection.isEmpty()) return false; grid.clearSelectedRanges(); // consume the event, and stop propagation only if there were selected ranges to clear return { preventDefault: false, stopPropagation: true }; case 'Enter': - if (grid.state.selectedRanges.length > 0) { + if (!grid.state.selection.isEmpty()) { grid.moveCursorInDirection( event.shiftKey ? GridRange.SELECTION_DIRECTION.UP @@ -111,7 +111,7 @@ class SelectionKeyHandler extends KeyHandler { } break; case 'Tab': - if (grid.state.selectedRanges.length > 0) { + if (!grid.state.selection.isEmpty()) { grid.moveCursorInDirection( event.shiftKey ? GridRange.SELECTION_DIRECTION.LEFT diff --git a/packages/grid/src/key-handlers/TreeKeyHandler.ts b/packages/grid/src/key-handlers/TreeKeyHandler.ts index 7eff227138..29a4b2844e 100644 --- a/packages/grid/src/key-handlers/TreeKeyHandler.ts +++ b/packages/grid/src/key-handlers/TreeKeyHandler.ts @@ -2,6 +2,7 @@ import { isExpandableGridModel } from '../ExpandableGridModel'; import type Grid from '../Grid'; import type GridRange from '../GridRange'; +import { isRangedSelection } from '../RangedSelection'; import KeyHandler from '../KeyHandler'; class TreeKeyHandler extends KeyHandler { @@ -18,9 +19,11 @@ class TreeKeyHandler extends KeyHandler { } handleExpandKey(event: KeyboardEvent, grid: Grid): boolean { - const { selectedRanges } = grid.state; - if (selectedRanges.length === 1) { - const range = selectedRanges[0] as GridRange; + // Keyed tables have no expandable rows; ignore the shortcut rather than throwing. + if (!isRangedSelection(grid.state.selection)) return false; + const ranges = grid.state.selection.toRanges(); + if (ranges.length === 1) { + const range = ranges[0] as GridRange; if ( range.startRow === range.endRow && range.startColumn === range.endColumn diff --git a/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.test.ts b/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.test.ts deleted file mode 100644 index 032201af23..0000000000 --- a/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { GridRange } from '../GridRange'; -import GridSelectionMouseHandler from './GridSelectionMouseHandler'; - -describe('getLatestSelection', () => { - it('should return the original selection if the clicked cell is within the original selection', () => { - const originalSelection = [new GridRange(1, 1, 2, 2)]; - const result = GridSelectionMouseHandler.getLatestSelection( - originalSelection, - 1, - 1 - ); - - expect(result).toBe(originalSelection); - }); - - it('should return a new selection with the clicked cell if it is outside the original selection', () => { - const originalSelection = [new GridRange(1, 1, 2, 2)]; - const columnIndex = 3; - const rowIndex = 3; - - const result = GridSelectionMouseHandler.getLatestSelection( - originalSelection, - columnIndex, - rowIndex - ); - - expect(result).toEqual([GridRange.makeCell(columnIndex, rowIndex)]); - }); - - it('should return the original selection if columnIndex is null', () => { - const originalSelection = [new GridRange(1, 1, 2, 2)]; - - const result = GridSelectionMouseHandler.getLatestSelection( - originalSelection, - null, - 1 - ); - - expect(result).toBe(originalSelection); - }); - - it('should return the original selection if rowIndex is null', () => { - const originalSelection = [new GridRange(1, 1, 2, 2)]; - - const result = GridSelectionMouseHandler.getLatestSelection( - originalSelection, - null, - 1 - ); - - expect(result).toBe(originalSelection); - }); -}); diff --git a/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts b/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts index cdb8ef0835..30e2a60b21 100644 --- a/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts +++ b/packages/grid/src/mouse-handlers/GridSelectionMouseHandler.ts @@ -1,37 +1,11 @@ import { type EventHandlerResult } from '../EventHandlerResult'; import type Grid from '../Grid'; import GridMouseHandler, { type GridMouseEvent } from '../GridMouseHandler'; -import GridRange, { type GridRangeIndex } from '../GridRange'; import GridUtils, { type GridPoint } from '../GridUtils'; const DEFAULT_INTERVAL_MS = 100; class GridSelectionMouseHandler extends GridMouseHandler { - /** - * Returns the latest grid selection based on the current grid selection and where the user clicked - * This code is dependent on the behavior of onContextMenu - * @param originalSelection The selection from the current grid state which may be stale - * @param columnIndex The column index where the user clicked - * @param rowIndex The row index where the user clicked - */ - static getLatestSelection( - originalSelection: readonly GridRange[], - columnIndex: GridRangeIndex, - rowIndex: GridRangeIndex - ): readonly GridRange[] { - const clickedInOriginalSelection = GridRange.containsCell( - originalSelection, - columnIndex, - rowIndex - ); - - // If the user clicked in a valid cell outside of the original selection, - // the selection will be changed to just that cell. - return clickedInOriginalSelection || columnIndex == null || rowIndex == null - ? originalSelection - : [GridRange.makeCell(columnIndex, rowIndex)]; - } - private startPoint?: GridPoint; private hasExtendedFloating = false; @@ -224,7 +198,8 @@ class GridSelectionMouseHandler extends GridMouseHandler { column = rightVisible + 1; } } - grid.moveCursorToPosition(column, row, true, true); + // Transient overlay during drag — onUp performs the settled commit. + grid.moveCursorToPosition(column, row, true, true, false, false); } return true; } @@ -262,20 +237,18 @@ class GridSelectionMouseHandler extends GridMouseHandler { 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 - ); + const { row, column } = gridPoint; + const isInRange = + row != null && + column != null && + grid.getSelection().isCellSelected(column, row); - // only change the selected range if the selected cell is not in the selected range - if (!isInRange && gridPoint.row !== null && gridPoint.column !== null) { + // only change the selected range if the clicked cell is not already selected + if (!isInRange && row != null && column != null) { this.startPoint = undefined; this.stopTimer(); grid.clearSelectedRanges(); - grid.moveCursorToPosition(gridPoint.column, gridPoint.row); + grid.moveCursorToPosition(column, row); } return false; @@ -301,10 +274,14 @@ class GridSelectionMouseHandler extends GridMouseHandler { const maxX = deltaX > 0 && column != null ? column : columnCount - 1; const minY = deltaY < 0 && row != null ? row : 0; const maxY = deltaY > 0 && row != null ? row : rowCount - 1; + // Transient overlay during auto-scroll drag — onUp performs the settled commit. grid.moveCursorToPosition( Math.min(Math.max(minX, selectionEndColumn + deltaX), maxX), Math.min(Math.max(minY, selectionEndRow + deltaY), maxY), - true + true, + true, + false, + false ); this.lastTriggerTime = Date.now(); } diff --git a/packages/iris-grid/src/ColumnStatistics.tsx b/packages/iris-grid/src/ColumnStatistics.tsx index e0861aa9b8..683e354487 100644 --- a/packages/iris-grid/src/ColumnStatistics.tsx +++ b/packages/iris-grid/src/ColumnStatistics.tsx @@ -223,8 +223,8 @@ class ColumnStatistics extends Component< {columnIndex != null && isEditableGridModel(model) && model.isEditable && - !model.keyColumnSet.has(column.name) && - !model.valueColumnSet.has(column.name) && ( + !model.inputKeyColumnSet.has(column.name) && + !model.inputValueColumnSet.has(column.name) && (
Not editable diff --git a/packages/iris-grid/src/IrisGrid.test.tsx b/packages/iris-grid/src/IrisGrid.test.tsx index a4688eee15..afe5ec6f69 100644 --- a/packages/iris-grid/src/IrisGrid.test.tsx +++ b/packages/iris-grid/src/IrisGrid.test.tsx @@ -6,7 +6,9 @@ import { TestUtils } from '@deephaven/test-utils'; import { type TypeValue } from '@deephaven/filters'; import { type ExpandableColumnGridModel, + GridRange, isExpandableColumnGridModel, + RangedSelection, } from '@deephaven/grid'; import IrisGrid from './IrisGrid'; import IrisGridTestUtils from './IrisGridTestUtils'; @@ -251,15 +253,29 @@ it('handles reverse key shortcut', () => { it('handles copy key handler', () => { const component = makeComponent(); - component.copyRanges = jest.fn(); + component.copySelection = jest.fn(); keyDown('c', component); - expect(component.copyRanges).not.toHaveBeenCalled(); + expect(component.copySelection).not.toHaveBeenCalled(); keyDown('c', component, { ctrlKey: true }); - expect(component.copyRanges).toHaveBeenCalled(); + // No selection yet — handler should guard and not copy + expect(component.copySelection).not.toHaveBeenCalled(); + + act(() => { + component.setState({ + gridSelection: new RangedSelection( + [new GridRange(0, 0, 0, 0)], + () => component.props.model + ), + }); + }); + + keyDown('c', component, { ctrlKey: true }); + + expect(component.copySelection).toHaveBeenCalled(); }); it('handles value: undefined in setFilterMap, clears column filter', () => { diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index c107fe3d4a..8cd70e626b 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -34,6 +34,7 @@ import { GridRange, type GridRangeIndex, GridUtils, + type GridModel, type KeyHandler, type ModelIndex, type ModelSizeMap, @@ -45,6 +46,8 @@ import { isExpandableGridModel, isDeletableGridModel, isExpandableColumnGridModel, + type Selection, + RangedSelection, } from '@deephaven/grid'; import { dhEye, @@ -153,6 +156,8 @@ import { } from './sidebar'; import { DEFAULT_REGISTRY, IrisGridContext } from './IrisGridContextProvider'; import IrisGridModel from './IrisGridModel'; +import { isKeyedGridModel } from './KeyedGridModel'; +import { KeyedSelection, type GetKeyedModel } from './KeyedSelection'; import IrisGridUtils from './IrisGridUtils'; import CrossColumnSearch from './CrossColumnSearch'; import { @@ -246,6 +251,7 @@ export interface IrisGridContextMenuData { columnIndex: GridRangeIndex; modelRow?: GridRangeIndex; modelColumn: GridRangeIndex; + selection: Selection | null; } export type MouseHandlersProp = readonly ( @@ -328,7 +334,13 @@ export interface IrisGridProps { */ userColumnWidthsByName?: ReadonlyMap; userRowHeights: ReadonlyMap; - onSelectionChanged: (gridRanges: readonly GridRange[]) => void; + /** @deprecated Use onSelectionChange instead. */ + onSelectionChanged?: (gridRanges: readonly GridRange[]) => void; + /** + * Called when the selection changes. + * @param selection The new selection state as a `Selection` object. + */ + onSelectionChange?: (selection: Selection) => void; rollupConfig?: UIRollupConfig; aggregationSettings: AggregationSettings; @@ -421,8 +433,8 @@ export interface IrisGridState { customColumns: readonly ColumnName[]; selectDistinctColumns: readonly ColumnName[]; - // selected range in table - selectedRanges: readonly GridRange[]; + // polymorphic selection object; source of truth + gridSelection: Selection | null; // Current ongoing copy operation copyOperation: CopyOperation | null; @@ -551,6 +563,7 @@ class IrisGrid extends Component { userColumnWidthsByName: undefined, userRowHeights: EMPTY_MAP, onSelectionChanged: (): void => undefined, + onSelectionChange: (): void => undefined, isSelectingColumn: false, isSelectingPartition: false, isStuckToBottom: false, @@ -625,6 +638,7 @@ class IrisGrid extends Component { this.handlePending = this.handlePending.bind(this); this.handlePendingCleared = this.handlePendingCleared.bind(this); this.handleSelectionChanged = this.handleSelectionChanged.bind(this); + this.handleGridSelectionChange = this.handleGridSelectionChange.bind(this); this.handleMovedColumnsChanged = this.handleMovedColumnsChanged.bind(this); this.handleHeaderGroupsChanged = this.handleHeaderGroupsChanged.bind(this); this.handleUpdate = this.handleUpdate.bind(this); @@ -879,8 +893,7 @@ class IrisGrid extends Component { customColumns: [], selectDistinctColumns, - // selected range in table - selectedRanges: [], + gridSelection: null, // Current ongoing copy operation copyOperation: null, @@ -1206,6 +1219,15 @@ class IrisGrid extends Component { { max: 100 } ); + getCachedCreateEmptySelection = memoize( + (model: IrisGridModel) => + isKeyedGridModel(model) + ? (getModel: () => GridModel): Selection => + KeyedSelection.empty(getModel as GetKeyedModel) + : undefined, + { max: 1 } + ); + getCachedAdvancedFilterMenuActions = memoize( ( model: IrisGridModel, @@ -2201,6 +2223,7 @@ class IrisGrid extends Component { movedColumns: readonly MoveOperation[], floatingLeftColumnCount: number, floatingRightColumnCount: number, + model: IrisGridModel, draggingRange?: BoundedAxisRange ): readonly ColumnName[] => { const floatingColumns: ColumnName[] = []; @@ -2226,7 +2249,18 @@ class IrisGrid extends Component { } } - const columnSet = new Set([...alwaysFetchColumns, ...floatingColumns]); + const keyColumnIndices = isKeyedGridModel(model) + ? model.selectionKeyColumnIndices + : EMPTY_ARRAY; + const keyColumns = keyColumnIndices + .map(i => columns[i]?.name) + .filter((n): n is ColumnName => n != null); + + const columnSet = new Set([ + ...alwaysFetchColumns, + ...floatingColumns, + ...keyColumns, + ]); return Object.freeze([...columnSet]); }, @@ -2466,27 +2500,45 @@ class IrisGrid extends Component { formatValues = true, error?: string ): void { - const { model, canCopy } = this.props; + const { model } = this.props; + const bounded = GridRange.boundedRanges( + ranges, + model.columnCount, + model.rowCount + ); + const selection = new RangedSelection(bounded, () => model); + this.copySelection(selection, includeHeaders, formatValues, error); + } + + copySelection( + selection: Selection, + includeHeaders = false, + formatValues = true, + error?: string + ): void { + const { canCopy } = this.props; const { metricCalculator, movedColumns } = this.state; const userColumnWidths = metricCalculator.getUserColumnWidths(); if (canCopy) { + // Skip copy while keyed selection is still resolving — selectedKeyValues is empty. + if ( + selection instanceof KeyedSelection && + selection.pendingRanges.length > 0 + ) { + return; + } const copyOperation = { - ranges: GridRange.boundedRanges( - ranges, - model.columnCount, - model.rowCount - ), + selection, includeHeaders, formatValues, movedColumns, userColumnWidths, error, }; - this.setState({ copyOperation }); } else { - log.error('Attempted copyRanges for user without copy permission.'); + log.error('Attempted copySelection for user without copy permission.'); } } @@ -3679,26 +3731,52 @@ class IrisGrid extends Component { this.setState({ metrics, pendingRowCount }); } + /** @deprecated Use `handleGridSelectionChange` instead. */ handleSelectionChanged(selectedRanges?: readonly GridRange[]): void { assertNotNull(selectedRanges); const { onSelectionChanged } = this.props; + onSelectionChanged?.(selectedRanges); + } + + handleGridSelectionChange(selection: Selection): void { + const { onSelectionChange } = this.props; const { copyOperation } = this.state; - this.setState({ selectedRanges }); + this.setState({ gridSelection: selection }); if (copyOperation != null) { this.setState({ copyOperation: null }); } - - // We get 2 identical ranges here, - // but consolidating in `Grid#moveSelection` causes - // deselection to break, so just consolidate here. - // This will only update the goto row input for row index + const singleRow = selection.getLastSingleSelectedRow(); + if (singleRow != null) { + this.setState({ gotoRow: `${singleRow + 1}` }); + } + onSelectionChange?.(selection); if ( - GridRange.rowCount(GridRange.consolidate(selectedRanges)) === 1 && - selectedRanges[0].startRow != null + selection instanceof KeyedSelection && + selection.pendingRanges.length > 0 ) { - this.setState({ gotoRow: `${selectedRanges[0].startRow + 1}` }); + this.resolveKeyedSelection(selection); + } + } + + /** Resolves a pending shift-click KeyedSelection by fetching key values from the server. */ + async resolveKeyedSelection(pending: KeyedSelection): Promise { + const { model } = this.props; + if (!isKeyedGridModel(model)) return; + const { pendingRanges } = pending; + if (pendingRanges.length === 0) return; + + try { + const keyValues = await model.fetchKeyValuesForRowRanges(pendingRanges); + // Bail if the user changed the selection while we were fetching. + if (this.grid?.getSelection() !== pending) return; + this.grid.setSelection(pending.resolve(keyValues)); + } catch (e) { + log.error('resolveKeyedSelection failed', e); + // Prevent the unusable pending selection from remaining installed indefinitely. + if (this.grid?.getSelection() === pending) { + this.grid.setSelection(pending.clear()); + } } - onSelectionChanged(selectedRanges); } handleMovedColumnsChanged( @@ -5008,7 +5086,7 @@ class IrisGrid extends Component { reverse, customColumns, - selectedRanges, + gridSelection, isTableDownloading, tableDownloadStatus, tableDownloadProgress, @@ -5456,7 +5534,7 @@ class IrisGrid extends Component { onDownload={this.handleDownloadTable} onDownloadStart={this.handleDownloadTableStart} onCancel={this.handleCancelDownloadTable} - selectedRanges={selectedRanges} + selection={gridSelection} key={OptionType.TABLE_EXPORTER} /> ); @@ -5561,6 +5639,7 @@ class IrisGrid extends Component { this.grid = grid; }} isStickyBottom={!isEditableGridModel(model) || !model.isEditable} + createEmptySelection={this.getCachedCreateEmptySelection(model)} isStuckToBottom={isStuckToBottom} isStuckToRight={isStuckToRight} metricCalculator={metricCalculator} @@ -5572,6 +5651,7 @@ class IrisGrid extends Component { onError={this.handleGridError} onViewChanged={this.handleViewChanged} onSelectionChanged={this.handleSelectionChanged} + onSelectionChange={this.handleGridSelectionChange} onMovedColumnsChanged={this.handleMovedColumnsChanged} renderer={this.renderer} cellInputRendererRegistry={ @@ -5606,6 +5686,7 @@ class IrisGrid extends Component { movedColumns, model.floatingLeftColumnCount, model.floatingRightColumnCount, + model, this.grid?.state.draggingColumn?.range )} formatColumns={this.getCachedPreviewFormatColumns( diff --git a/packages/iris-grid/src/IrisGridCopyHandler.test.tsx b/packages/iris-grid/src/IrisGridCopyHandler.test.tsx index a0d1f396ca..96a376fdf5 100644 --- a/packages/iris-grid/src/IrisGridCopyHandler.test.tsx +++ b/packages/iris-grid/src/IrisGridCopyHandler.test.tsx @@ -1,13 +1,13 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { GridTestUtils } from '@deephaven/grid'; +import { GridTestUtils, RangedSelection } from '@deephaven/grid'; import { copyToClipboard } from '@deephaven/utils'; import dh from '@deephaven/jsapi-shim'; import IrisGridTestUtils from './IrisGridTestUtils'; import IrisGridCopyHandler, { type CopyOperation, type CopyHeaderOperation, - type CopyRangesOperation, + type CopySelectionOperation, } from './IrisGridCopyHandler'; import type IrisGridProxyModel from './IrisGridProxyModel'; @@ -42,14 +42,15 @@ function makeDelayedSnapshotFn() { ); } -function makeCopyRangesOperation( +function makeCopySelectionOperation( ranges = GridTestUtils.makeRanges(), includeHeaders = false, movedColumns = [], userColumnWidths = IrisGridTestUtils.makeUserColumnWidths() -): CopyRangesOperation { +): CopySelectionOperation { return { - ranges, + // getModel is not invoked during copy; ranges only need to be iterable + selection: new RangedSelection(ranges, () => ({}) as never), includeHeaders, movedColumns, userColumnWidths, @@ -77,7 +78,7 @@ function makeModel(delayed = false) { function mountCopySelection({ model = makeModel(), - copyOperation = makeCopyRangesOperation(), + copyOperation = makeCopySelectionOperation(), }: { model?: IrisGridProxyModel; copyOperation?: CopyOperation } = {}) { return render( @@ -105,7 +106,7 @@ it('copies column header', async () => { it('copies immediately if less than 10,000 rows of data', async () => { const ranges = GridTestUtils.makeRanges(1, 10000); - const copyOperation = makeCopyRangesOperation(ranges); + const copyOperation = makeCopySelectionOperation(ranges); const model = makeModel(); mountCopySelection({ copyOperation, model }); screen.getByRole('progressbar', { hidden: true }); @@ -121,7 +122,7 @@ it('prompts to copy if more than 10,000 rows of data', async () => { const user = userEvent.setup({ delay: null }); const model = makeModel(true); const ranges = GridTestUtils.makeRanges(1, 10001); - const copyOperation = makeCopyRangesOperation(ranges); + const copyOperation = makeCopySelectionOperation(ranges); mountCopySelection({ copyOperation, model }); const copyBtn = screen.getByText('Copy'); expect( @@ -148,7 +149,7 @@ it('shows click to copy if async copy fails', async () => { mockedCopyToClipboard.mockReturnValueOnce(Promise.reject(error)); const ranges = GridTestUtils.makeRanges(); - const copyOperation = makeCopyRangesOperation(ranges); + const copyOperation = makeCopySelectionOperation(ranges); mountCopySelection({ copyOperation }); await waitFor(() => @@ -174,7 +175,7 @@ it('shows click to copy if async copy fails', async () => { it('retry option available if fetching fails', async () => { const user = userEvent.setup({ delay: null }); const ranges = GridTestUtils.makeRanges(); - const copyOperation = makeCopyRangesOperation(ranges); + const copyOperation = makeCopySelectionOperation(ranges); const model = makeModel(); model.textSnapshot = jest.fn(() => Promise.reject()); @@ -202,7 +203,7 @@ it('shows an error if the copy fails permissions', async () => { mockedCopyToClipboard.mockReturnValueOnce(Promise.reject(error)); const ranges = GridTestUtils.makeRanges(); - const copyOperation = makeCopyRangesOperation(ranges); + const copyOperation = makeCopySelectionOperation(ranges); mountCopySelection({ copyOperation }); await waitFor(() => diff --git a/packages/iris-grid/src/IrisGridCopyHandler.tsx b/packages/iris-grid/src/IrisGridCopyHandler.tsx index 6198aff47d..952435f3ab 100644 --- a/packages/iris-grid/src/IrisGridCopyHandler.tsx +++ b/packages/iris-grid/src/IrisGridCopyHandler.tsx @@ -4,8 +4,10 @@ import { Button, FadeTransition, LoadingSpinner } from '@deephaven/components'; import { GridRange, GridUtils, + isRangedSelection, type ModelSizeMap, type MoveOperation, + type Selection, } from '@deephaven/grid'; import { type CancelablePromise, @@ -15,10 +17,11 @@ import { } from '@deephaven/utils'; import Log from '@deephaven/log'; import type { dh } from '@deephaven/jsapi-types'; -import IrisGridUtils from './IrisGridUtils'; import IrisGridBottomBar from './IrisGridBottomBar'; import './IrisGridCopyHandler.scss'; import type IrisGridModel from './IrisGridModel'; +import { textSnapshotFromSelection } from './IrisGridSelectionUtils'; +import { KeyedSelection } from './KeyedSelection'; const log = Log.module('IrisGridCopyHandler'); @@ -31,8 +34,8 @@ type CommonCopyOperation = { error?: string; }; -export type CopyRangesOperation = CommonCopyOperation & { - ranges: readonly GridRange[]; +export type CopySelectionOperation = CommonCopyOperation & { + selection: Selection; includeHeaders: boolean; formatValues?: boolean; userColumnWidths: ModelSizeMap; @@ -43,13 +46,7 @@ export type CopyHeaderOperation = CommonCopyOperation & { columnDepth: number; }; -export type CopyOperation = CopyRangesOperation | CopyHeaderOperation; - -function isCopyRangesOperation( - copyOperation: CopyOperation -): copyOperation is CopyRangesOperation { - return (copyOperation as CopyRangesOperation).ranges != null; -} +export type CopyOperation = CopySelectionOperation | CopyHeaderOperation; function isCopyHeaderOperation( copyOperation: CopyOperation @@ -96,6 +93,9 @@ class IrisGridCopyHandler extends Component< // Large copy operation, confirmation required CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED', + // Large keyed copy, row count is an estimate + KEYED_CONFIRMATION_REQUIRED: 'KEYED_CONFIRMATION_REQUIRED', + // Fetch is currently in progress for copy ranges operation FETCH_RANGES_IN_PROGRESS: 'FETCH_RANGES_IN_PROGRESS', @@ -131,6 +131,8 @@ class IrisGridCopyHandler extends Component< switch (copyState) { case IrisGridCopyHandler.COPY_STATES.CONFIRMATION_REQUIRED: return `Are you sure you want to copy ${rowCount.toLocaleString()} rows to your clipboard?`; + case IrisGridCopyHandler.COPY_STATES.KEYED_CONFIRMATION_REQUIRED: + return `Keyed selection may be up to ${rowCount.toLocaleString()} rows. Copy to clipboard?`; case IrisGridCopyHandler.COPY_STATES.CLICK_REQUIRED: return `Fetched ${rowCount.toLocaleString()} rows!`; case IrisGridCopyHandler.COPY_STATES.FETCH_ERROR: @@ -205,7 +207,7 @@ class IrisGridCopyHandler extends Component< this.stopCopy(); - const { copyOperation } = this.props; + const { copyOperation, model } = this.props; if (copyOperation == null) { log.debug2('No copy operation set, cancelling out'); this.setState({ isShown: false }); @@ -226,9 +228,11 @@ class IrisGridCopyHandler extends Component< this.setState({ isShown: true, error: undefined }); - if (isCopyRangesOperation(copyOperation)) { - const { ranges } = copyOperation; - const rowCount = GridRange.rowCount(ranges); + if ( + !isCopyHeaderOperation(copyOperation) && + isRangedSelection(copyOperation.selection) + ) { + const rowCount = GridRange.rowCount(copyOperation.selection.toRanges()); this.setState({ rowCount }); if (rowCount > IrisGridCopyHandler.NO_PROMPT_THRESHOLD) { @@ -238,6 +242,23 @@ class IrisGridCopyHandler extends Component< }); return; } + } else if (!isCopyHeaderOperation(copyOperation)) { + const uniqueCount = + copyOperation.selection instanceof KeyedSelection + ? copyOperation.selection.getUniqueRowCount() + : null; + const rowCount = uniqueCount ?? model.rowCount; + if (rowCount > IrisGridCopyHandler.NO_PROMPT_THRESHOLD) { + this.setState({ + rowCount, + buttonState: IrisGridCopyHandler.BUTTON_STATES.COPY, + copyState: + uniqueCount != null + ? IrisGridCopyHandler.COPY_STATES.CONFIRMATION_REQUIRED + : IrisGridCopyHandler.COPY_STATES.KEYED_CONFIRMATION_REQUIRED, + }); + return; + } } this.startFetch(); @@ -331,30 +352,19 @@ class IrisGridCopyHandler extends Component< this.fetchPromise = PromiseUtils.makeCancelable(copyText); } else { const { - ranges, + selection, includeHeaders, userColumnWidths, movedColumns, formatValues, - } = copyOperation; - log.debug('startFetch copyRanges', ranges); + } = copyOperation as CopySelectionOperation; + log.debug('startFetch copySelection', selection); this.setState({ buttonState: IrisGridCopyHandler.BUTTON_STATES.FETCH_IN_PROGRESS, copyState: IrisGridCopyHandler.COPY_STATES.FETCH_RANGES_IN_PROGRESS, }); - const hiddenColumns = IrisGridUtils.getHiddenColumns(userColumnWidths); - let modelRanges = GridUtils.getModelRanges(ranges, movedColumns); - if (hiddenColumns.length > 0) { - const subtractRanges = hiddenColumns.map(GridRange.makeColumn); - modelRanges = GridRange.subtractRangesFromRanges( - modelRanges, - subtractRanges - ); - } - - // Remove the hidden columns from the snapshot const formatValue = formatValues != null && formatValues ? (value: unknown, column: dh.Column) => @@ -362,7 +372,14 @@ class IrisGridCopyHandler extends Component< : (value: unknown) => `${value}`; this.fetchPromise = PromiseUtils.makeCancelable( - model.textSnapshot(modelRanges, includeHeaders, formatValue) + textSnapshotFromSelection( + selection, + model, + includeHeaders, + formatValue, + movedColumns, + userColumnWidths + ) ); } diff --git a/packages/iris-grid/src/IrisGridModel.ts b/packages/iris-grid/src/IrisGridModel.ts index 6597966e64..fde2917cf0 100644 --- a/packages/iris-grid/src/IrisGridModel.ts +++ b/packages/iris-grid/src/IrisGridModel.ts @@ -390,14 +390,14 @@ abstract class IrisGridModel< /** * @returns Names of key columns */ - get keyColumnSet(): Set { + get inputKeyColumnSet(): Set { return EMPTY_SET; } /** * @returns Names of value columns */ - get valueColumnSet(): Set { + get inputValueColumnSet(): Set { return EMPTY_SET; } diff --git a/packages/iris-grid/src/IrisGridSelectionUtils.test.ts b/packages/iris-grid/src/IrisGridSelectionUtils.test.ts new file mode 100644 index 0000000000..5bab969122 --- /dev/null +++ b/packages/iris-grid/src/IrisGridSelectionUtils.test.ts @@ -0,0 +1,336 @@ +import dh from '@deephaven/jsapi-shim'; +import { GridRange, RangedSelection } from '@deephaven/grid'; +import type { ModelSizeMap, MoveOperation } from '@deephaven/grid'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { KeyedSelection, type GetKeyedModel } from './KeyedSelection'; +import { + snapshotFromSelection, + textSnapshotFromSelection, +} from './IrisGridSelectionUtils'; +import IrisGridTestUtils from './IrisGridTestUtils'; + +const irisGridTestUtils = new IrisGridTestUtils(dh); + +const NO_MOVES: readonly MoveOperation[] = []; +const NO_HIDDEN: ModelSizeMap = new Map(); + +// ─── model stub ────────────────────────────────────────────────────────────── + +function makeModel( + columns: DhType.Column[] = irisGridTestUtils.makeColumns(3) +) { + return { + columns, + columnCount: columns.length, + snapshot: jest.fn().mockResolvedValue([]), + textSnapshot: jest.fn().mockResolvedValue(''), + snapshotByKeys: jest.fn().mockResolvedValue([]), + textSnapshotByKeys: jest.fn().mockResolvedValue(''), + // isKeyedGridModel uses selectionKeyColumnIndices + selectionKeyColumnIndices: [0], + } as never; +} + +// ─── selection factories ────────────────────────────────────────────────────── + +function rangedSel(ranges: GridRange[]) { + return new RangedSelection(ranges, () => ({}) as never); +} + +function keyedSel( + keyValues: ReadonlyMap, + invertedSelection = false, + maxRows: number | null = null +) { + const getModel: GetKeyedModel = () => makeModel() as never; + return new KeyedSelection({ + getModel, + selectedKeys: new Set(keyValues.keys()), + invertedSelection, + selectedKeyValues: keyValues, + maxRows, + }); +} + +// ─── snapshotFromSelection ──────────────────────────────────────────────────── + +describe('snapshotFromSelection', () => { + describe('RangedSelection', () => { + it('calls model.snapshot with the exact ranges when no transforms are applied', async () => { + const model = makeModel(); + const ranges = [new GridRange(0, 0, 2, 5)]; + const sel = rangedSel(ranges); + await snapshotFromSelection(sel, model, NO_MOVES, NO_HIDDEN); + expect(model.snapshot).toHaveBeenCalledWith(ranges); + }); + + it('returns the value from model.snapshot', async () => { + const expected = [[1, 2, 3]]; + const model = makeModel(); + model.snapshot.mockResolvedValue(expected); + const result = await snapshotFromSelection( + rangedSel([GridRange.makeCell(0, 0)]), + model, + NO_MOVES, + NO_HIDDEN + ); + expect(result).toBe(expected); + }); + + it('excludes hidden columns from the model ranges', async () => { + const model = makeModel(); + // col 1 is hidden + const hidden: ModelSizeMap = new Map([[1, 0]]); + const sel = rangedSel([new GridRange(0, 0, 2, 5)]); + await snapshotFromSelection(sel, model, NO_MOVES, hidden); + // column 1 should be subtracted; model.snapshot gets two separate ranges + const calledRanges: GridRange[] = model.snapshot.mock.calls[0][0]; + expect( + calledRanges.every(r => r.startColumn !== 1 && r.endColumn !== 1) + ).toBe(true); + // col 0 and col 2 present + expect(calledRanges.some(r => r.startColumn === 0)).toBe(true); + expect(calledRanges.some(r => r.startColumn === 2)).toBe(true); + }); + + it('applies moved-column transforms to produce correct model ranges', async () => { + const model = makeModel(); + // swap columns 0 and 1: visual 0 → model 1, visual 1 → model 0 + const moves: readonly MoveOperation[] = [{ from: 0, to: 1 }]; + // select visual column 0 (maps to model column 1 after the move) + const sel = rangedSel([new GridRange(0, 0, 0, 5)]); + await snapshotFromSelection(sel, model, moves, NO_HIDDEN); + const calledRanges: GridRange[] = model.snapshot.mock.calls[0][0]; + // after the move visual col 0 → model col 1 + expect( + calledRanges.some(r => r.startColumn === 1 && r.endColumn === 1) + ).toBe(true); + }); + }); + + describe('KeyedSelection', () => { + it('calls model.snapshotByKeys with all visible columns when no transforms', async () => { + const columns = irisGridTestUtils.makeColumns(3); + const model = makeModel(columns); + const keyValues = new Map([['[0]', [0]]]); + const sel = keyedSel(keyValues); + await snapshotFromSelection(sel, model, NO_MOVES, NO_HIDDEN); + expect(model.snapshotByKeys).toHaveBeenCalledWith( + columns, + keyValues, + false, + false, + expect.any(Function), + null + ); + }); + + it('passes invertedSelection and maxRows through to snapshotByKeys', async () => { + const model = makeModel(); + const keyValues = new Map(); + const sel = keyedSel(keyValues, true, 500); + await snapshotFromSelection(sel, model, NO_MOVES, NO_HIDDEN); + expect(model.snapshotByKeys).toHaveBeenCalledWith( + expect.any(Array), + keyValues, + true, + false, + expect.any(Function), + 500 + ); + }); + + it('excludes hidden columns from the columns passed to snapshotByKeys', async () => { + const columns = irisGridTestUtils.makeColumns(3); + const model = makeModel(columns); + // hide column 1 + const hidden: ModelSizeMap = new Map([[1, 0]]); + const sel = keyedSel(new Map()); + await snapshotFromSelection(sel, model, NO_MOVES, hidden); + const calledColumns: DhType.Column[] = + model.snapshotByKeys.mock.calls[0][0]; + expect(calledColumns).toHaveLength(2); + expect(calledColumns).not.toContain(columns[1]); + }); + + it('returns the value from model.snapshotByKeys', async () => { + const expected = [[10, 20]]; + const model = makeModel(); + model.snapshotByKeys.mockResolvedValue(expected); + const result = await snapshotFromSelection( + keyedSel(new Map()), + model, + NO_MOVES, + NO_HIDDEN + ); + expect(result).toBe(expected); + }); + + it('throws when the model is not a KeyedGridModel', async () => { + const nonKeyedModel = { + ...makeModel(), + selectionKeyColumnIndices: [], + } as never; + await expect( + snapshotFromSelection( + keyedSel(new Map()), + nonKeyedModel, + NO_MOVES, + NO_HIDDEN + ) + ).rejects.toThrow('KeyedSelection requires a KeyedGridModel'); + }); + }); + + it('throws for an unsupported selection type', async () => { + const fakeSelection = { isEmpty: () => false } as never; + await expect( + snapshotFromSelection(fakeSelection, makeModel(), NO_MOVES, NO_HIDDEN) + ).rejects.toThrow('Unsupported selection type'); + }); +}); + +// ─── textSnapshotFromSelection ──────────────────────────────────────────────── + +describe('textSnapshotFromSelection', () => { + const formatValue = (v: unknown) => String(v); + + describe('RangedSelection', () => { + it('calls model.textSnapshot with model ranges, includeHeaders, and formatValue', async () => { + const model = makeModel(); + const ranges = [new GridRange(0, 0, 2, 5)]; + const sel = rangedSel(ranges); + await textSnapshotFromSelection( + sel, + model, + true, + formatValue, + NO_MOVES, + NO_HIDDEN + ); + expect(model.textSnapshot).toHaveBeenCalledWith( + ranges, + true, + formatValue + ); + }); + + it('forwards includeHeaders=false correctly', async () => { + const model = makeModel(); + await textSnapshotFromSelection( + rangedSel([GridRange.makeCell(0, 0)]), + model, + false, + formatValue, + NO_MOVES, + NO_HIDDEN + ); + expect(model.textSnapshot).toHaveBeenCalledWith( + expect.any(Array), + false, + formatValue + ); + }); + + it('returns the value from model.textSnapshot', async () => { + const model = makeModel(); + model.textSnapshot.mockResolvedValue('a\tb\nc\td'); + const result = await textSnapshotFromSelection( + rangedSel([GridRange.makeCell(0, 0)]), + model, + false, + formatValue, + NO_MOVES, + NO_HIDDEN + ); + expect(result).toBe('a\tb\nc\td'); + }); + + it('excludes hidden columns from model ranges', async () => { + const model = makeModel(); + const hidden: ModelSizeMap = new Map([[1, 0]]); + await textSnapshotFromSelection( + rangedSel([new GridRange(0, 0, 2, 3)]), + model, + false, + formatValue, + NO_MOVES, + hidden + ); + const calledRanges: GridRange[] = model.textSnapshot.mock.calls[0][0]; + expect( + calledRanges.every(r => r.startColumn !== 1 && r.endColumn !== 1) + ).toBe(true); + }); + }); + + describe('KeyedSelection', () => { + it('calls model.textSnapshotByKeys with visible columns and correct args', async () => { + const columns = irisGridTestUtils.makeColumns(3); + const model = makeModel(columns); + const keyValues = new Map([['[5]', [5]]]); + const sel = keyedSel(keyValues, false, 200); + await textSnapshotFromSelection( + sel, + model, + true, + formatValue, + NO_MOVES, + NO_HIDDEN + ); + expect(model.textSnapshotByKeys).toHaveBeenCalledWith( + columns, + keyValues, + false, + true, + formatValue, + 200 + ); + }); + + it('returns the value from model.textSnapshotByKeys', async () => { + const model = makeModel(); + model.textSnapshotByKeys.mockResolvedValue('key\tval'); + const result = await textSnapshotFromSelection( + keyedSel(new Map()), + model, + false, + formatValue, + NO_MOVES, + NO_HIDDEN + ); + expect(result).toBe('key\tval'); + }); + + it('throws when the model is not a KeyedGridModel', async () => { + const nonKeyedModel = { + ...makeModel(), + selectionKeyColumnIndices: [], + } as never; + await expect( + textSnapshotFromSelection( + keyedSel(new Map()), + nonKeyedModel, + false, + formatValue, + NO_MOVES, + NO_HIDDEN + ) + ).rejects.toThrow('KeyedSelection requires a KeyedGridModel'); + }); + }); + + it('throws for an unsupported selection type', async () => { + const fakeSelection = { isEmpty: () => false } as never; + await expect( + textSnapshotFromSelection( + fakeSelection, + makeModel(), + false, + formatValue, + NO_MOVES, + NO_HIDDEN + ) + ).rejects.toThrow('Unsupported selection type'); + }); +}); diff --git a/packages/iris-grid/src/IrisGridSelectionUtils.ts b/packages/iris-grid/src/IrisGridSelectionUtils.ts new file mode 100644 index 0000000000..01e982739d --- /dev/null +++ b/packages/iris-grid/src/IrisGridSelectionUtils.ts @@ -0,0 +1,145 @@ +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { + GridRange, + GridUtils, + isRangedSelection, + type ModelSizeMap, + type MoveOperation, + type Selection, +} from '@deephaven/grid'; +import type IrisGridModel from './IrisGridModel'; +import { isKeyedGridModel } from './KeyedGridModel'; +import { KeyedSelection } from './KeyedSelection'; +import IrisGridUtils from './IrisGridUtils'; + +/** Applies moved-column and hidden-column logic to produce model ranges. */ +function computeModelRanges( + ranges: readonly GridRange[], + movedColumns: readonly MoveOperation[], + userColumnWidths: ModelSizeMap +): readonly GridRange[] { + const hiddenColumns = IrisGridUtils.getHiddenColumns(userColumnWidths); + let modelRanges = GridUtils.getModelRanges(ranges, movedColumns); + if (hiddenColumns.length > 0) { + const subtractRanges = hiddenColumns.map(GridRange.makeColumn); + modelRanges = GridRange.subtractRangesFromRanges( + modelRanges, + subtractRanges + ); + } + return modelRanges; +} + +/** Returns the ordered visible columns after applying moved and hidden column logic. */ +export function computeVisibleColumns( + model: IrisGridModel, + movedColumns: readonly MoveOperation[], + userColumnWidths: ModelSizeMap +): readonly DhType.Column[] { + const allColumnsRange = [new GridRange(0, 0, model.columnCount - 1, 0)]; + const columnRanges = computeModelRanges( + allColumnsRange, + movedColumns, + userColumnWidths + ); + return IrisGridUtils.columnsFromRanges(columnRanges, model.columns); +} + +/** + * Takes a snapshot of the current selection as a 2-D array of raw values. + * No formatValue or includeHeaders — use textSnapshotFromSelection for formatted output. + */ +export async function snapshotFromSelection( + selection: Selection, + model: IrisGridModel, + movedColumns: readonly MoveOperation[], + userColumnWidths: ModelSizeMap +): Promise { + if (isRangedSelection(selection)) { + const modelRanges = computeModelRanges( + selection.toRanges(), + movedColumns, + userColumnWidths + ); + return model.snapshot(modelRanges); + } + + if (selection instanceof KeyedSelection) { + if (!isKeyedGridModel(model)) { + throw new Error('KeyedSelection requires a KeyedGridModel'); + } + const columns = computeVisibleColumns( + model, + movedColumns, + userColumnWidths + ); + return model.snapshotByKeys( + columns, + selection.selectedKeyValues, + selection.invertedSelection, + false, + v => v, + selection.maxRows + ); + } + + throw new Error(`Unsupported selection type for snapshotFromSelection`); +} + +/** + * Takes a snapshot of the current selection as a tab/newline-separated string. + * + * For RangedSelection: uses the existing range-based model.textSnapshot path. + * For KeyedSelection: filters a table copy by the selected keys and snapshots all rows. + * + * @param selection The current grid selection. + * @param model The IrisGrid model. + * @param includeHeaders Whether to prepend a header row. + * @param formatValue Formatter applied to each cell value. + * @param movedColumns Current column move operations (for model-index mapping). + * @param userColumnWidths Used to determine hidden columns. + */ +export async function textSnapshotFromSelection( + selection: Selection, + model: IrisGridModel, + includeHeaders: boolean, + formatValue: ( + value: unknown, + column: DhType.Column, + row?: DhType.Row + ) => string, + movedColumns: readonly MoveOperation[], + userColumnWidths: ModelSizeMap +): Promise { + if (isRangedSelection(selection)) { + const modelRanges = computeModelRanges( + selection.toRanges(), + movedColumns, + userColumnWidths + ); + return model.textSnapshot(modelRanges, includeHeaders, formatValue); + } + + if (selection instanceof KeyedSelection) { + if (!isKeyedGridModel(model)) { + throw new Error('KeyedSelection requires a KeyedGridModel'); + } + const columns = computeVisibleColumns( + model, + movedColumns, + userColumnWidths + ); + return model.textSnapshotByKeys( + columns, + selection.selectedKeyValues, + selection.invertedSelection, + includeHeaders, + formatValue, + selection.maxRows + ); + } + + throw new Error(`Unsupported selection type for textSnapshotFromSelection`); +} + +export default textSnapshotFromSelection; diff --git a/packages/iris-grid/src/IrisGridTableModel.ts b/packages/iris-grid/src/IrisGridTableModel.ts index 1c23305d80..e004130434 100644 --- a/packages/iris-grid/src/IrisGridTableModel.ts +++ b/packages/iris-grid/src/IrisGridTableModel.ts @@ -87,22 +87,22 @@ class IrisGridTableModel return this.table.applyCustomColumns != null; } - getMemoizedKeyColumnSet = memoize( + getMemoizedInputKeyColumnSet = memoize( (inputTableKeys?: readonly ColumnName[]) => new Set(inputTableKeys ?? EMPTY_ARRAY) ); - get keyColumnSet(): Set { - return this.getMemoizedKeyColumnSet(this.inputTable?.keys); + get inputKeyColumnSet(): Set { + return this.getMemoizedInputKeyColumnSet(this.inputTable?.keys); } - getMemoizedValueColumnSet = memoize( + getMemoizedInputValueColumnSet = memoize( (inputTableValues?: readonly ColumnName[]) => new Set(inputTableValues ?? EMPTY_ARRAY) ); - get valueColumnSet(): Set { - return this.getMemoizedValueColumnSet(this.inputTable?.values); + get inputValueColumnSet(): Set { + return this.getMemoizedInputValueColumnSet(this.inputTable?.values); } getMemoizedFrontColumns = memoize( @@ -351,7 +351,7 @@ class IrisGridTableModel ) { return false; } - return !this.isKeyColumn(modelIndex); + return !this.isInputKeyColumn(modelIndex); } isColumnFrozen(modelIndex: ModelIndex): boolean { @@ -365,7 +365,7 @@ class IrisGridTableModel assertNotNull(this.inputTable); const { keyColumns } = this.inputTable; - if (this.keyColumnSet.size === 0) { + if (this.inputKeyColumnSet.size === 0) { throw new Error('No key columns to allow deletion'); } diff --git a/packages/iris-grid/src/IrisGridTableModelTemplate.test.ts b/packages/iris-grid/src/IrisGridTableModelTemplate.test.ts new file mode 100644 index 0000000000..c9b9dff438 --- /dev/null +++ b/packages/iris-grid/src/IrisGridTableModelTemplate.test.ts @@ -0,0 +1,432 @@ +import dh from '@deephaven/jsapi-shim'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { Formatter } from '@deephaven/jsapi-utils'; +import { GridRange } from '@deephaven/grid'; +import IrisGridTableModelTemplate from './IrisGridTableModelTemplate'; +import IrisGridTestUtils from './IrisGridTestUtils'; + +const irisGridTestUtils = new IrisGridTestUtils(dh); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeModel( + columns = irisGridTestUtils.makeColumns(3), + size = 10 +): IrisGridTableModelTemplate { + const table = irisGridTestUtils.makeTable({ columns, size }); + return new IrisGridTableModelTemplate(dh, table as never, new Formatter(dh)); +} + +/** Build a minimal viewport-subscription mock for createViewportSubscription. */ +function makeSubscriptionMock( + rows: { get: (col: DhType.Column) => unknown }[], + offset = 0 +) { + const getViewportData = jest.fn().mockResolvedValue({ rows, offset }); + const close = jest.fn(); + return { getViewportData, close }; +} + +// ─── selectionKeyColumnIndices ──────────────────────────────────────────────── + +describe('selectionKeyColumnIndices', () => { + it('returns [] when getAttribute is absent', () => { + const model = makeModel(); + expect(model.selectionKeyColumnIndices).toEqual([]); + }); + + it('returns [] when keyColumns attribute is empty', () => { + const model = makeModel(); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn(() => ''); + expect(model.selectionKeyColumnIndices).toEqual([]); + }); + + it('returns column indices matching the keyColumns attribute', () => { + const columns = irisGridTestUtils.makeColumns(3); + const model = makeModel(columns); + // columns are named '0', '1', '2' by default + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn((attr: string) => (attr === 'keyColumns' ? '0, 2' : null)); + expect(model.selectionKeyColumnIndices).toEqual([0, 2]); + }); + + it('throws when a named key column is not found', () => { + const model = makeModel(); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn(() => 'nonExistentColumn'); + expect(() => model.selectionKeyColumnIndices).toThrow( + 'Selection key column not found' + ); + }); +}); + +// ─── hasUniqueSelectionKeys ─────────────────────────────────────────────────── + +describe('hasUniqueSelectionKeys', () => { + it('returns false when getAttribute is absent', () => { + expect(makeModel().hasUniqueSelectionKeys).toBe(false); + }); + + it('returns true when uniqueKeys attribute is "true"', () => { + const model = makeModel(); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn((attr: string) => (attr === 'uniqueKeys' ? 'true' : null)); + expect(model.hasUniqueSelectionKeys).toBe(true); + }); + + it('returns false when uniqueKeys attribute is anything other than "true"', () => { + const model = makeModel(); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn(() => 'false'); + expect(model.hasUniqueSelectionKeys).toBe(false); + }); +}); + +// ─── createFilteredByKeysTable ──────────────────────────────────────────────── + +describe('createFilteredByKeysTable', () => { + let model: IrisGridTableModelTemplate; + + beforeEach(() => { + const columns = irisGridTestUtils.makeColumns(3); + model = makeModel(columns); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn((attr: string) => (attr === 'keyColumns' ? '0' : null)); + // Mock applyFilter to avoid waiting for filterchanged event + model.tableUtils.applyFilter = jest.fn().mockResolvedValue(undefined); + }); + + it('copies the table', async () => { + await model.createFilteredByKeysTable(new Map(), false); + expect((model.table as DhType.Table).copy).toHaveBeenCalled(); + }); + + it('calls applyFilter with a never-match filter for an empty non-inverted selection', async () => { + await model.createFilteredByKeysTable(new Map(), false); + expect(model.tableUtils.applyFilter).toHaveBeenCalledTimes(1); + }); + + it('does NOT call applyFilter when keyValues is empty and inverted (select all)', async () => { + await model.createFilteredByKeysTable(new Map(), true); + expect(model.tableUtils.applyFilter).not.toHaveBeenCalled(); + }); + + it('calls applyFilter with the key filter for a non-inverted selection', async () => { + const keyValues = new Map([['[0]', [0]]]); + await model.createFilteredByKeysTable(keyValues, false); + expect(model.tableUtils.applyFilter).toHaveBeenCalledTimes(1); + // filter should be the key filter (non-inverted), not its negation + const [, filter] = (model.tableUtils.applyFilter as jest.Mock).mock + .calls[0]; + expect(filter).toHaveLength(1); + }); + + it('calls applyFilter with a negated filter for an inverted selection', async () => { + const keyValues = new Map([['[1]', [1]]]); + await model.createFilteredByKeysTable(keyValues, true); + expect(model.tableUtils.applyFilter).toHaveBeenCalledTimes(1); + }); + + it('returns the table copy', async () => { + const result = await model.createFilteredByKeysTable(new Map(), false); + // makeTable copies return the same table instance in our test setup + expect(result).toBeDefined(); + }); +}); + +// ─── fetchKeyValuesForRowRanges ─────────────────────────────────────────────── + +describe('fetchKeyValuesForRowRanges', () => { + let model: IrisGridTableModelTemplate; + + beforeEach(() => { + const columns = irisGridTestUtils.makeColumns(3); + model = makeModel(columns); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn((attr: string) => (attr === 'keyColumns' ? '0' : null)); + }); + + /** Installs a viewport subscription that returns one row per index in [first, last]. */ + function installEnvelopeSubscription( + first: number, + last: number + ): ReturnType { + const rows: { get: (col: DhType.Column) => unknown }[] = []; + for (let r = first; r <= last; r += 1) { + const rowIndex = r; + rows.push({ + get: (col: DhType.Column) => (col.index === 0 ? rowIndex : 'x'), + }); + } + const sub = makeSubscriptionMock(rows, first); + ( + model.table as DhType.Table & { createViewportSubscription: jest.Mock } + ).createViewportSubscription = jest.fn(() => sub); + return sub; + } + + it('subscribes to the envelope of the requested ranges', async () => { + installEnvelopeSubscription(1, 100); + await model.fetchKeyValuesForRowRanges([ + new GridRange(null, 1, null, 1), + new GridRange(null, 100, null, 100), + ]); + const tableWithSub = model.table as DhType.Table & { + createViewportSubscription: jest.Mock; + }; + expect(tableWithSub.createViewportSubscription).toHaveBeenCalledWith({ + rows: { first: 1, last: 100 }, + columns: [model.columns[0]], + }); + }); + + it('single contiguous range returns every row in the range', async () => { + installEnvelopeSubscription(5, 7); + const result = await model.fetchKeyValuesForRowRanges([ + new GridRange(null, 5, null, 7), + ]); + expect(result.size).toBe(3); + expect(result.get(JSON.stringify([5]))).toEqual([5]); + expect(result.get(JSON.stringify([6]))).toEqual([6]); + expect(result.get(JSON.stringify([7]))).toEqual([7]); + }); + + it('filters envelope rows that fall between disjoint ranges', async () => { + // Envelope 1..100 covers all intermediate rows; only rows 1 and 100 should + // survive the range-membership filter. + installEnvelopeSubscription(1, 100); + const result = await model.fetchKeyValuesForRowRanges([ + new GridRange(null, 1, null, 1), + new GridRange(null, 100, null, 100), + ]); + expect(result.size).toBe(2); + expect(result.has(JSON.stringify([1]))).toBe(true); + expect(result.has(JSON.stringify([100]))).toBe(true); + }); + + it('normalizes reverse-ordered ranges when computing the envelope', async () => { + installEnvelopeSubscription(1, 100); + await model.fetchKeyValuesForRowRanges([ + new GridRange(null, 100, null, 100), + new GridRange(null, 1, null, 1), + ]); + const tableWithSub = model.table as DhType.Table & { + createViewportSubscription: jest.Mock; + }; + expect(tableWithSub.createViewportSubscription).toHaveBeenCalledWith({ + rows: { first: 1, last: 100 }, + columns: [model.columns[0]], + }); + }); + + it('normalizes a range whose startRow > endRow', async () => { + installEnvelopeSubscription(3, 5); + const result = await model.fetchKeyValuesForRowRanges([ + new GridRange(null, 5, null, 3), + ]); + // Rows 3, 4, 5 should all pass range-membership (low=3, high=5). + expect(result.size).toBe(3); + }); + + it('returns an empty map when ranges is empty', async () => { + const sub = installEnvelopeSubscription(0, 0); + const result = await model.fetchKeyValuesForRowRanges([]); + expect(result.size).toBe(0); + expect(sub.getViewportData).not.toHaveBeenCalled(); + }); + + it('returns an empty map when every range has a null startRow', async () => { + const sub = installEnvelopeSubscription(0, 0); + const result = await model.fetchKeyValuesForRowRanges([ + new GridRange(null, null, null, null), + ]); + expect(result.size).toBe(0); + expect(sub.getViewportData).not.toHaveBeenCalled(); + }); + + it('closes the subscription even if getViewportData throws', async () => { + const sub = installEnvelopeSubscription(0, 1); + sub.getViewportData.mockRejectedValueOnce(new Error('network error')); + await expect( + model.fetchKeyValuesForRowRanges([new GridRange(null, 0, null, 1)]) + ).rejects.toThrow(); + expect(sub.close).toHaveBeenCalled(); + }); +}); + +// ─── snapshotByKeys ─────────────────────────────────────────────────────────── + +describe('snapshotByKeys', () => { + let model: IrisGridTableModelTemplate; + let filteredSubMock: ReturnType; + + beforeEach(() => { + const columns = irisGridTestUtils.makeColumns(3); + model = makeModel(columns, 5); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn((attr: string) => (attr === 'keyColumns' ? '0' : null)); + model.tableUtils.applyFilter = jest.fn().mockResolvedValue(undefined); + + filteredSubMock = makeSubscriptionMock([ + { get: (_col: DhType.Column) => 'a' }, + { get: (_col: DhType.Column) => 'b' }, + ]); + // The filtered table (copy) gets the viewport subscription + const copyTable = { + size: 2, + createViewportSubscription: jest.fn(() => filteredSubMock), + close: jest.fn(), + }; + (model.table as DhType.Table & { copy: jest.Mock }).copy = jest.fn(() => + Promise.resolve(copyTable) + ); + }); + + it('returns [] for an empty non-inverted selection', async () => { + const result = await model.snapshotByKeys(model.columns, new Map(), false); + expect(result).toEqual([]); + }); + + it('returns header row only when includeHeaders=true and empty selection', async () => { + const result = await model.snapshotByKeys( + model.columns, + new Map(), + false, + true + ); + expect(result).toEqual([model.columns.map(c => c.name)]); + }); + + it('returns rows for a non-empty selection', async () => { + const keyValues = new Map([['[0]', [0]]]); + const result = await model.snapshotByKeys(model.columns, keyValues, false); + expect(result).toHaveLength(2); // 2 rows from filteredSubMock + }); + + it('includes a header row when includeHeaders=true', async () => { + const keyValues = new Map([['[0]', [0]]]); + const result = await model.snapshotByKeys( + model.columns, + keyValues, + false, + true + ); + expect(result[0]).toEqual(model.columns.map(c => c.name)); + expect(result).toHaveLength(3); // 1 header + 2 data rows + }); + + it('limits rows to maxRows via the viewport last row', async () => { + const keyValues = new Map([['[0]', [0]]]); + await model.snapshotByKeys( + model.columns, + keyValues, + false, + false, + v => v, + 1 + ); + const copyTable = (model.table as DhType.Table & { copy: jest.Mock }).copy + .mock.results[0].value; + const resolvedCopy = await copyTable; + expect(resolvedCopy.createViewportSubscription).toHaveBeenCalledWith( + expect.objectContaining({ rows: { first: 0, last: 0 } }) + ); + }); + + it('throws when maxRows < 1', async () => { + await expect( + model.snapshotByKeys( + model.columns, + new Map([['[0]', [0]]]), + false, + false, + v => v, + 0 + ) + ).rejects.toThrow('maxRows must be at least 1'); + }); + + it('closes the filtered table in the finally block', async () => { + const keyValues = new Map([['[0]', [0]]]); + await model.snapshotByKeys(model.columns, keyValues, false); + const copyTable = await (model.table as DhType.Table & { copy: jest.Mock }) + .copy.mock.results[0].value; + expect(copyTable.close).toHaveBeenCalled(); + }); + + it('closes the filtered table even when getViewportData throws', async () => { + filteredSubMock.getViewportData.mockRejectedValueOnce( + new Error('viewport error') + ); + const keyValues = new Map([['[0]', [0]]]); + await expect( + model.snapshotByKeys(model.columns, keyValues, false) + ).rejects.toThrow(); + const copyTable = await (model.table as DhType.Table & { copy: jest.Mock }) + .copy.mock.results[0].value; + expect(copyTable.close).toHaveBeenCalled(); + }); +}); + +// ─── textSnapshotByKeys ─────────────────────────────────────────────────────── + +describe('textSnapshotByKeys', () => { + let model: IrisGridTableModelTemplate; + + beforeEach(() => { + model = makeModel(irisGridTestUtils.makeColumns(2), 5); + (model.table as DhType.Table & { getAttribute: jest.Mock }).getAttribute = + jest.fn((attr: string) => (attr === 'keyColumns' ? '0' : null)); + model.tableUtils.applyFilter = jest.fn().mockResolvedValue(undefined); + + const subMock = makeSubscriptionMock([ + { get: (col: DhType.Column) => `r0c${col.index}` }, + { get: (col: DhType.Column) => `r1c${col.index}` }, + ]); + const copyTable = { + size: 2, + createViewportSubscription: jest.fn(() => subMock), + close: jest.fn(), + }; + (model.table as DhType.Table & { copy: jest.Mock }).copy = jest.fn(() => + Promise.resolve(copyTable) + ); + }); + + it('returns tab/newline-delimited text', async () => { + const keyValues = new Map([['[0]', [0]]]); + const result = await model.textSnapshotByKeys( + model.columns, + keyValues, + false, + false, + (v: unknown) => String(v) + ); + // two rows, two columns → "r0c0\tr0c1\nr1c0\tr1c1" + expect(result).toBe('r0c0\tr0c1\nr1c0\tr1c1'); + }); + + it('prepends a header row when includeHeaders=true', async () => { + const keyValues = new Map([['[0]', [0]]]); + const result = await model.textSnapshotByKeys( + model.columns, + keyValues, + false, + true, + (v: unknown) => String(v) + ); + const lines = result.split('\n'); + expect(lines[0]).toBe(model.columns.map(c => c.name).join('\t')); + expect(lines).toHaveLength(3); // 1 header + 2 data rows + }); + + it('returns empty string for an empty non-inverted selection', async () => { + const result = await model.textSnapshotByKeys( + model.columns, + new Map(), + false + ); + expect(result).toBe(''); + }); +}); diff --git a/packages/iris-grid/src/IrisGridTableModelTemplate.ts b/packages/iris-grid/src/IrisGridTableModelTemplate.ts index 16cb65f8dc..ce2dae8803 100644 --- a/packages/iris-grid/src/IrisGridTableModelTemplate.ts +++ b/packages/iris-grid/src/IrisGridTableModelTemplate.ts @@ -29,6 +29,8 @@ import { type SortDescriptor, } from '@deephaven/jsapi-utils'; import IrisGridModel, { type DisplayColumn } from './IrisGridModel'; +import { type KeyedGridModel } from './KeyedGridModel'; +import { serializeKeyValues } from './KeyedSelection'; import AggregationOperation from './sidebar/aggregations/AggregationOperation'; import IrisGridUtils from './IrisGridUtils'; @@ -67,7 +69,7 @@ class IrisGridTableModelTemplate< R extends UIRow = UIRow, > extends IrisGridModel - implements DeletableGridModel, EditableGridModel + implements DeletableGridModel, EditableGridModel, KeyedGridModel { static ROW_BUFFER_PAGES = 1; @@ -242,6 +244,30 @@ class IrisGridTableModelTemplate< this.pendingNewRowCount = 0; } + getMemoizedSelectionKeyColumnIndices = memoize( + (columns: DhType.Column[], raw: unknown): readonly ModelIndex[] => { + if (raw == null || typeof raw !== 'string' || raw.trim() === '') { + return []; + } + return raw.split(',').map(name => { + const idx = columns.findIndex(c => c.name === name.trim()); + if (idx < 0) throw new Error(`Selection key column not found: ${name}`); + return idx; + }); + } + ); + + get selectionKeyColumnIndices(): readonly ModelIndex[] { + return this.getMemoizedSelectionKeyColumnIndices( + this.columns, + (this.table as DhType.Table).getAttribute?.('keyColumns') + ); + } + + get hasUniqueSelectionKeys(): boolean { + return (this.table as DhType.Table).getAttribute?.('uniqueKeys') === 'true'; + } + close(): void { this.table.close(); if (this.totalsTable !== null) { @@ -480,7 +506,7 @@ class IrisGridTableModelTemplate< } get isDeletable(): boolean { - return this.keyColumnSet.size > 0; + return this.inputKeyColumnSet.size > 0; } get isViewportPending(): boolean { @@ -582,7 +608,7 @@ class IrisGridTableModelTemplate< textForCell(x: ModelIndex, y: ModelIndex): string { const text = this.textValueForCell(x, y); - if (text == null && this.isKeyColumn(x)) { + if (text == null && this.isInputKeyColumn(x)) { const pendingRow = this.pendingRow(y); if (pendingRow != null && this.pendingDataMap.has(pendingRow)) { // Asterisk to show a value is required for a key column on a row that has some data entered @@ -644,7 +670,7 @@ class IrisGridTableModelTemplate< value ); } - } else if (this.isPendingRow(y) && this.isKeyColumn(x)) { + } else if (this.isPendingRow(y) && this.isInputKeyColumn(x)) { assertNotNull(theme.errorTextColor); return theme.errorTextColor; } @@ -1542,6 +1568,218 @@ class IrisGridTableModelTemplate< return data.map(row => row.join('\t')).join('\n'); } + /** + * Builds a filter condition matching any row whose key columns equal one of the provided key value sets. + * + * @param keyValues A map of key column names to their corresponding values + * @param keyColumns The key columns to filter by + * @returns A filter condition matching the provided key values, or null if no filter is needed + */ + private buildKeyFilter( + keyValues: ReadonlyMap, + keyColumns: readonly DhType.Column[] + ): DhType.FilterCondition | null { + // Return null if there are no key values to filter by + if (keyValues.size === 0) return null; + + // Create an AND filter for each set of key values + const keyFilters: DhType.FilterCondition[] = []; + keyValues.forEach(values => { + const colFilters = values.map((val, i) => { + const col = keyColumns[i]; + return this.tableUtils.makeNullableEqFilter(col, val); + }); + keyFilters.push( + colFilters.length === 1 + ? colFilters[0] + : colFilters[0].and(...colFilters.slice(1)) + ); + }); + + // Combine the key filters with OR logic + return keyFilters.length === 1 + ? keyFilters[0] + : keyFilters[0].or(...keyFilters.slice(1)); + } + + async createFilteredByKeysTable( + keyValues: ReadonlyMap, + invertedSelection: boolean + ): Promise { + if (TableUtils.isTreeTable(this.table)) { + throw new Error( + 'createFilteredByKeysTable is not supported on tree tables' + ); + } + const keyColumns = this.selectionKeyColumnIndices.map(i => this.columns[i]); + const keyFilter = this.buildKeyFilter(keyValues, keyColumns); + const copy = await (this.table as DhType.Table).copy(); + try { + if (keyFilter == null && !invertedSelection) { + // Empty non-inverted selection means zero rows match; filter to nothing so + // callers (e.g. CSV exporter) see size=0 rather than the full unfiltered table. + const neverMatch = keyColumns[0] + .filter() + .isNull() + .and(keyColumns[0].filter().isNull().not()); + await this.tableUtils.applyFilter(copy, [neverMatch]); + } else if (keyFilter != null) { + const filter = invertedSelection ? [keyFilter.not()] : [keyFilter]; + await this.tableUtils.applyFilter(copy, filter); + } + // Fall-through when keyFilter == null && invertedSelection: all rows selected. + // Skip applyFilter — an empty filter array on a fresh copy produces no + // filterchanged event, causing applyFilter to time out. + return copy; + } catch (err) { + // applyFilter can reject or time out; the copy hasn't been returned to any + // caller yet, so close it here to avoid leaking a JS API table. + copy.close(); + throw err; + } + } + + async fetchKeyValuesForRowRanges( + ranges: readonly GridRange[] + ): Promise> { + // Compute the bounding envelope covering every range so the server round + // trip is one viewport subscription regardless of how many disjoint ranges + // were selected. Handles reverse-ordered and out-of-order input. + let minStart: number | null = null; + let maxEnd: number | null = null; + for (let i = 0; i < ranges.length; i += 1) { + const { startRow, endRow } = ranges[i]; + if (startRow == null) continue; // eslint-disable-line no-continue + const rEnd = endRow ?? startRow; + const low = Math.min(startRow, rEnd); + const high = Math.max(startRow, rEnd); + if (minStart === null || low < minStart) minStart = low; + if (maxEnd === null || high > maxEnd) maxEnd = high; + } + if (minStart === null || maxEnd === null) return new Map(); + + const keyColumns = this.selectionKeyColumnIndices.map(i => this.columns[i]); + // Use a secondary viewport subscription on the live table to avoid a copy/filter round-trip. + const sub = (this.table as DhType.Table).createViewportSubscription({ + rows: { first: minStart, last: maxEnd }, + columns: keyColumns, + }); + try { + const data = await sub.getViewportData(); + const result = new Map(); + // Filter to only rows the caller actually requested — the envelope may + // include rows between disjoint ranges that must not be selected. + data.rows.forEach((row: DhType.Row, i: number) => { + const rowIndex = data.offset + i; + if (!IrisGridTableModelTemplate.rowInAnyRange(rowIndex, ranges)) return; + const values = keyColumns.map(col => row.get(col)); + result.set(serializeKeyValues(values), values); + }); + return result; + } finally { + sub.close(); + } + } + + private static rowInAnyRange( + rowIndex: number, + ranges: readonly GridRange[] + ): boolean { + for (let i = 0; i < ranges.length; i += 1) { + const { startRow, endRow } = ranges[i]; + if (startRow == null) continue; // eslint-disable-line no-continue + const rEnd = endRow ?? startRow; + const low = Math.min(startRow, rEnd); + const high = Math.max(startRow, rEnd); + if (rowIndex >= low && rowIndex <= high) return true; + } + return false; + } + + /** + * Implementation of snapshotByKeys. + * This works by filtering the table based on the key values and then taking a snapshot of the entire filtered table. + */ + async snapshotByKeys( + columns: readonly DhType.Column[], + keyValues: ReadonlyMap, + invertedSelection: boolean, + includeHeaders = false, + formatValue: (value: unknown, column: DhType.Column) => unknown = v => v, + maxRows: number | null = null + ): Promise { + if (maxRows != null && maxRows < 1) { + throw new Error(`maxRows must be at least 1, got ${maxRows}`); + } + if (TableUtils.isTreeTable(this.table)) { + throw new Error('snapshotByKeys is not supported on tree tables'); + } + + const keyColumns = this.selectionKeyColumnIndices.map(i => this.columns[i]); + const keyFilter = this.buildKeyFilter(keyValues, keyColumns); + if (keyFilter == null && !invertedSelection) { + // Empty normal selection — nothing to copy + return includeHeaders ? [columns.map(c => c.name)] : []; + } + + const filteredTable = await this.createFilteredByKeysTable( + keyValues, + invertedSelection + ); + try { + const result: unknown[][] = []; + if (includeHeaders) { + result.push(columns.map(c => c.name)); + } + if (filteredTable.size > 0) { + const lastRow = + maxRows != null + ? Math.min(filteredTable.size - 1, maxRows - 1) + : filteredTable.size - 1; + const sub = filteredTable.createViewportSubscription({ + rows: { first: 0, last: lastRow }, + columns: [...columns], + }); + try { + const data = await sub.getViewportData(); + result.push( + ...data.rows.map((rowData: DhType.Row) => + columns.map(col => formatValue(rowData.get(col), col)) + ) + ); + } finally { + sub.close(); + } + } + return result; + } finally { + filteredTable.close(); + } + } + + async textSnapshotByKeys( + columns: readonly DhType.Column[], + keyValues: ReadonlyMap, + invertedSelection: boolean, + includeHeaders = false, + formatValue: ( + value: unknown, + column: DhType.Column, + row?: DhType.Row + ) => string = v => `${v}`, + maxRows: number | null = null + ): Promise { + const data = await this.snapshotByKeys( + columns, + keyValues, + invertedSelection, + includeHeaders, + formatValue, + maxRows + ); + return data.map(row => row.join('\t')).join('\n'); + } + async valuesTable( columns: DhType.Column | readonly DhType.Column[] ): Promise { @@ -1638,19 +1876,19 @@ class IrisGridTableModelTemplate< ) { return false; } - return !this.isKeyColumn(modelIndex); + return !this.isInputKeyColumn(modelIndex); } isColumnSortable(modelIndex: ModelIndex): boolean { return this.columns[modelIndex].isSortable ?? true; } - isKeyColumn(x: ModelIndex): boolean { - return this.keyColumnSet.has(this.columns[x].name); + isInputKeyColumn(x: ModelIndex): boolean { + return this.inputKeyColumnSet.has(this.columns[x].name); } - isValueColumn(x: ModelIndex): boolean { - return this.valueColumnSet.has(this.columns[x].name); + isInputValueColumn(x: ModelIndex): boolean { + return this.inputValueColumnSet.has(this.columns[x].name); } isRowMovable(): boolean { @@ -1674,26 +1912,29 @@ class IrisGridTableModelTemplate< const isPendingRange = this.isPendingRow(range.startRow) && this.isPendingRow(range.endRow); - let isKeyColumnInRange = false; + let isInputKeyColumnInRange = false; assertNotNull(range.startColumn); // Check if any of the columns in grid range are key columns const bound = range.endColumn ?? this.table.size; for (let column = range.startColumn; column <= bound; column += 1) { - const isKey = this.isKeyColumn(column); - const isValue = this.isValueColumn(column); + const isKey = this.isInputKeyColumn(column); + const isValue = this.isInputValueColumn(column); if (!isKey && !isValue) { // If any column is not a key or value column, range is not editable return false; } if (isKey) { - isKeyColumnInRange = true; + isInputKeyColumnInRange = true; break; } } if ( - !(isPendingRange || (this.keyColumnSet.size !== 0 && !isKeyColumnInRange)) + !( + isPendingRange || + (this.inputKeyColumnSet.size !== 0 && !isInputKeyColumnInRange) + ) ) { return false; } diff --git a/packages/iris-grid/src/KeyedGridModel.ts b/packages/iris-grid/src/KeyedGridModel.ts new file mode 100644 index 0000000000..e91a25e7cd --- /dev/null +++ b/packages/iris-grid/src/KeyedGridModel.ts @@ -0,0 +1,86 @@ +import type { dh as DhType } from '@deephaven/jsapi-types'; +import type { GridRange, ModelIndex } from '@deephaven/grid'; + +/** Model that exposes key-column metadata for selection purposes. */ +export interface KeyedGridModel { + /** Model column indices forming the row key for selection purposes. Empty for non-keyed tables. */ + readonly selectionKeyColumnIndices: readonly ModelIndex[]; + /** True if each key uniquely identifies at most one row. */ + readonly hasUniqueSelectionKeys: boolean; + /** Current viewport row bounds; used to clamp gesture-key enumeration to visible rows. */ + readonly viewport: { top: number; bottom: number } | null; + + /** + * Snapshots rows matching the given key values. + * For invertedSelection=true, snapshots all rows EXCEPT those matching the keys. + * For invertedSelection=true with empty keyValues, snapshots the entire table. + * + * @param columns The columns to include in the snapshot. + * @param keyValues A map of key column names to their corresponding values. + * @param invertedSelection Whether to invert the selection. + * @param includeHeaders Whether to include the headers in the snapshot. + * @param formatValue Function for formatting the raw value into a string. + * @returns A promise that resolves to a 2D array of the snapshot data. + */ + snapshotByKeys: ( + columns: readonly DhType.Column[], + keyValues: ReadonlyMap, + invertedSelection: boolean, + includeHeaders?: boolean, + formatValue?: (value: unknown, column: DhType.Column) => unknown, + maxRows?: number | null + ) => Promise; + + /** + * Text version of snapshotByKeys: columns tab-separated, rows newline-separated. + * @param columns The columns to include in the snapshot. + * @param keyValues A map of key column names to their corresponding values. + * @param invertedSelection Whether to invert the selection. + * @param includeHeaders Whether to include the headers in the snapshot. + * @param formatValue Function for formatting the raw value into a string. + * @returns A promise that resolves to a string representation of the snapshot. + */ + textSnapshotByKeys: ( + columns: readonly DhType.Column[], + keyValues: ReadonlyMap, + invertedSelection: boolean, + includeHeaders?: boolean, + formatValue?: ( + value: unknown, + column: DhType.Column, + row?: DhType.Row + ) => string, + maxRows?: number | null + ) => Promise; + + /** + * Returns a filtered copy of the table containing only the rows identified by + * `keyValues` (or all rows except those, when `invertedSelection` is true). + * Ownership transfers to the caller; pass to `TableSaver` and it will close it. + */ + createFilteredByKeysTable: ( + keyValues: ReadonlyMap, + invertedSelection: boolean + ) => Promise; + + /** + * Fetches key values for the union of `ranges`. Used to resolve pending + * shift-click / programmatic selections that span out-of-viewport rows. + * Implementations may fetch a bounding envelope and filter internally; the + * returned map contains only rows within `ranges`. + */ + fetchKeyValuesForRowRanges: ( + ranges: readonly GridRange[] + ) => Promise>; +} + +/** + * Checks if the given model is a KeyedGridModel. + * @param model The model to check. + * @returns True if the model is a KeyedGridModel, false otherwise. + */ +export function isKeyedGridModel(model: unknown): model is KeyedGridModel { + const indices = (model as unknown as KeyedGridModel) + .selectionKeyColumnIndices; + return indices != null && indices.length > 0; +} diff --git a/packages/iris-grid/src/KeyedSelection.test.ts b/packages/iris-grid/src/KeyedSelection.test.ts new file mode 100644 index 0000000000..7bbcc7aece --- /dev/null +++ b/packages/iris-grid/src/KeyedSelection.test.ts @@ -0,0 +1,550 @@ +import { GridRange } from '@deephaven/grid'; +import { KeyedSelection, type GetKeyedModel } from './KeyedSelection'; + +// ─── model stub ────────────────────────────────────────────────────────────── +// Row N has a single key column whose value equals N, so key = JSON.stringify([N]). + +const COLUMN_COUNT = 5; +const ROW_COUNT = 100; + +const mockModel = { + selectionKeyColumnIndices: [0] as readonly number[], + hasUniqueSelectionKeys: true, + columnCount: COLUMN_COUNT, + rowCount: ROW_COUNT, + valueForCell: (_col: number, row: number) => row, + viewport: { top: 0, bottom: ROW_COUNT - 1 }, +}; + +const getKeyedModel: GetKeyedModel = () => mockModel as never; + +// ─── key helpers ───────────────────────────────────────────────────────────── + +function keyOf(row: number): string { + return JSON.stringify([row]); +} + +function keyValuesOf(row: number): [string, readonly unknown[]] { + return [keyOf(row), [row]]; +} + +// ─── factories ─────────────────────────────────────────────────────────────── + +function empty() { + return KeyedSelection.empty(getKeyedModel); +} + +/** A selection that contains exactly row 5. */ +function singleRow(row = 5) { + const key = keyOf(row); + return new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set([key]), + lastSingleRow: row, + selectedKeyValues: new Map([[key, [row]]]), + }); +} + +/** A selection that contains rows 3 and 7. */ +function multiRow() { + return new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set([keyOf(3), keyOf(7)]), + selectedKeyValues: new Map([keyValuesOf(3), keyValuesOf(7)]), + }); +} + +/** An inverted (select-all) selection with no exclusions. */ +function allRows() { + return new KeyedSelection({ + getModel: getKeyedModel, + invertedSelection: true, + }); +} + +// ─── isEmpty ───────────────────────────────────────────────────────────────── + +describe('isEmpty', () => { + it('returns true when selectedKeys is empty and no overlay', () => { + expect(empty().isEmpty()).toBe(true); + }); + + it('returns false when selectedKeys is non-empty', () => { + expect(singleRow().isEmpty()).toBe(false); + }); + + it('returns false for inverted selection (pendingRanges path)', () => { + expect(allRows().isEmpty()).toBe(false); + }); + + it('returns false when pendingRanges is non-empty', () => { + const pending = new KeyedSelection({ + getModel: getKeyedModel, + pendingRanges: [new GridRange(null, 0, null, 10)], + }); + expect(pending.isEmpty()).toBe(false); + }); +}); + +// ─── isRowSelected / isCellSelected ────────────────────────────────────────── + +describe('isRowSelected', () => { + it('returns true for a row whose key is in selectedKeys', () => { + expect(singleRow(5).isRowSelected(5)).toBe(true); + }); + + it('returns false for a row not in selectedKeys', () => { + expect(singleRow(5).isRowSelected(6)).toBe(false); + }); + + it('returns true for any row in an inverted selection with no exclusions', () => { + const all = allRows(); + expect(all.isRowSelected(0)).toBe(true); + expect(all.isRowSelected(50)).toBe(true); + }); + + it('returns false for a row that is excluded in an inverted selection', () => { + const excludeRow5 = new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set([keyOf(5)]), + invertedSelection: true, + }); + expect(excludeRow5.isRowSelected(5)).toBe(false); + expect(excludeRow5.isRowSelected(6)).toBe(true); + }); + + it('returns true for a row in the gesture overlay', () => { + const withOverlay = new KeyedSelection({ + getModel: getKeyedModel, + overlayRanges: [new GridRange(null, 3, null, 3)], + }); + expect(withOverlay.isRowSelected(3)).toBe(true); + expect(withOverlay.isRowSelected(4)).toBe(false); + }); +}); + +describe('isCellSelected', () => { + it('delegates to isRowSelected (column is irrelevant)', () => { + const sel = singleRow(7); + expect(sel.isCellSelected(0, 7)).toBe(true); + expect(sel.isCellSelected(COLUMN_COUNT - 1, 7)).toBe(true); + expect(sel.isCellSelected(0, 8)).toBe(false); + }); +}); + +// ─── getLastSingleSelectedRow ───────────────────────────────────────────────── + +describe('getLastSingleSelectedRow', () => { + it('returns the row for a single-key selection with a lastSingleRow', () => { + expect(singleRow(5).getLastSingleSelectedRow()).toBe(5); + }); + + it('returns null for a multi-key selection', () => { + expect(multiRow().getLastSingleSelectedRow()).toBeNull(); + }); + + it('returns null for an inverted selection', () => { + expect(allRows().getLastSingleSelectedRow()).toBeNull(); + }); + + it('returns null when selectedKeys.size !== 1', () => { + expect(empty().getLastSingleSelectedRow()).toBeNull(); + }); +}); + +// ─── selectAll ──────────────────────────────────────────────────────────────── + +describe('selectAll', () => { + it('produces an inverted selection with no exclusions', () => { + const all = empty().selectAll(); + expect(all.invertedSelection).toBe(true); + expect(all.selectedKeys.size).toBe(0); + }); + + it('selects all rows', () => { + const all = empty().selectAll(); + expect(all.isRowSelected(0)).toBe(true); + expect(all.isRowSelected(ROW_COUNT - 1)).toBe(true); + }); +}); + +// ─── clear ──────────────────────────────────────────────────────────────────── + +describe('clear', () => { + it('produces an empty selection', () => { + expect(singleRow().clear().isEmpty()).toBe(true); + }); + + it('clears an inverted selection', () => { + expect(allRows().clear().isEmpty()).toBe(true); + }); +}); + +// ─── trimmed ───────────────────────────────────────────────────────────────── + +describe('trimmed', () => { + it('returns an empty selection (always clears keys for shift-click reset)', () => { + expect(singleRow().trimmed().isEmpty()).toBe(true); + expect(multiRow().trimmed().isEmpty()).toBe(true); + }); +}); + +// ─── withCommittedRanges ──────────────────────────────────────────────────────── + +describe('withCommittedRanges', () => { + afterEach(() => { + mockModel.viewport = { top: 0, bottom: ROW_COUNT - 1 }; + }); + + it('selects a row that is not already selected', () => { + const sel = empty().withCommittedRanges([new GridRange(null, 3, null, 3)]); + expect(sel.isRowSelected(3)).toBe(true); + }); + + it('replaces the selection — keeps a row already selected (no toggle)', () => { + const sel = singleRow(3).withCommittedRanges([ + new GridRange(null, 3, null, 3), + ]); + expect(sel.isRowSelected(3)).toBe(true); + }); + + it('returns an empty selection for empty ranges', () => { + const sel = singleRow(); + expect(sel.withCommittedRanges([]).isEmpty()).toBe(true); + }); + + it('resolves synchronously when all rows are in the viewport', () => { + mockModel.viewport = { top: 0, bottom: 10 }; + const sel = empty().withCommittedRanges([new GridRange(null, 2, null, 5)]); + expect(sel.pendingRanges).toHaveLength(0); + expect(sel.selectedKeys.size).toBe(4); + for (let r = 2; r <= 5; r += 1) { + expect(sel.isRowSelected(r)).toBe(true); + } + }); + + it('defers to async resolution when any row is out of viewport', () => { + // Programmatic entry (e.g. Grid.setFocusRow jumping to row 50) must not + // synchronously call valueForCell for rows outside the current viewport — + // it returns undefined and collapses every off-screen row to the same + // phantom [null,...] key. + mockModel.viewport = { top: 0, bottom: 10 }; + const sel = empty().withCommittedRanges([ + new GridRange(null, 50, null, 50), + ]); + expect(sel.pendingRanges).toHaveLength(1); + expect(sel.pendingRanges[0].startRow).toBe(50); + expect(sel.pendingRanges[0].endRow).toBe(50); + expect(sel.selectedKeys.size).toBe(0); + }); + + it('preserves multiple non-contiguous ranges in pendingRanges', () => { + // Programmatic selection of rows 1 and 100 must NOT collapse into a + // 1..100 envelope — otherwise resolveKeyedSelection would fetch and + // select every intermediate row. + mockModel.viewport = { top: 0, bottom: 10 }; + const sel = empty().withCommittedRanges([ + new GridRange(null, 1, null, 1), + new GridRange(null, 100, null, 100), + ]); + expect(sel.pendingRanges).toHaveLength(2); + expect(sel.pendingRanges[0].startRow).toBe(1); + expect(sel.pendingRanges[1].startRow).toBe(100); + }); + + it('preserves reverse-ordered ranges without inverting endpoints', () => { + // A reverse-order input must not create a malformed + // GridRange(startRow=100, endRow=1) envelope; each input range is kept + // as-is and resolveKeyedSelection handles ordering. + mockModel.viewport = { top: 0, bottom: 10 }; + const sel = empty().withCommittedRanges([ + new GridRange(null, 100, null, 100), + new GridRange(null, 1, null, 1), + ]); + expect(sel.pendingRanges).toHaveLength(2); + expect(sel.pendingRanges[0].startRow).toBe(100); + expect(sel.pendingRanges[1].startRow).toBe(1); + }); +}); + +// ─── withMouseGestureRanges ─────────────────────────────────────────────────── + +describe('withMouseGestureRanges', () => { + it('stores overlay ranges and keeps selectedKeys unchanged', () => { + const sel = singleRow(2).withMouseGestureRanges([ + new GridRange(null, 5, null, 5), + ]); + // Row 2 still in selectedKeys + expect(sel.selectedKeys.has(keyOf(2))).toBe(true); + // Row 5 in gestureKeys via overlay + expect(sel.isRowSelected(5)).toBe(true); + }); + + it('drops selectedKeys / invertedSelection when isReplacing is true', () => { + // Simulates a drag step from a committed single-key selection: the overlay + // should replace rather than merge with the prior committed keys. + const sel = singleRow(2).withMouseGestureRanges( + [new GridRange(null, 5, null, 5)], + true + ); + expect(sel.selectedKeys.size).toBe(0); + expect(sel.selectedKeyValues.size).toBe(0); + expect(sel.invertedSelection).toBe(false); + // Overlay still previews via gestureKeys. + expect(sel.isRowSelected(5)).toBe(true); + }); +}); + +// ─── truncate ──────────────────────────────────────────────────────────────── + +describe('truncate', () => { + it('stores maxRows in the new instance', () => { + const sel = singleRow().truncate(500); + expect(sel.maxRows).toBe(500); + }); + + it('returns the same instance when maxRows is already set to the same value', () => { + const sel = singleRow().truncate(500); + expect(sel.truncate(500)).toBe(sel); + }); + + it('does not remove keys (resolution happens server-side via maxRows)', () => { + const sel = multiRow().truncate(1); + expect(sel.selectedKeys.size).toBe(2); + expect(sel.maxRows).toBe(1); + }); +}); + +// ─── resolve ───────────────────────────────────────────────────────────────── + +describe('resolve', () => { + it('clears pendingRanges and commits the provided key values', () => { + const pending = new KeyedSelection({ + getModel: getKeyedModel, + pendingRanges: [new GridRange(null, 0, null, 4)], + }); + const resolved = pending.resolve(new Map([keyValuesOf(0), keyValuesOf(2)])); + expect(resolved.pendingRanges).toHaveLength(0); + expect(resolved.isRowSelected(0)).toBe(true); + expect(resolved.isRowSelected(2)).toBe(true); + expect(resolved.isRowSelected(1)).toBe(false); + }); + + it('merges endpointKeyData for keys missing from the fetched map', () => { + const endpoints = new Map([keyValuesOf(0), keyValuesOf(4)]); + const pending = new KeyedSelection({ + getModel: getKeyedModel, + pendingRanges: [new GridRange(null, 0, null, 4)], + endpointKeyData: endpoints, + }); + // Simulate a fetch that missed both endpoints because of drift. + const resolved = pending.resolve(new Map([keyValuesOf(1), keyValuesOf(3)])); + expect(resolved.isRowSelected(0)).toBe(true); + expect(resolved.isRowSelected(1)).toBe(true); + expect(resolved.isRowSelected(3)).toBe(true); + expect(resolved.isRowSelected(4)).toBe(true); + expect(resolved.selectedKeys.size).toBe(4); + }); + + it('prefers fetched values over endpointKeyData when a key is in both', () => { + const endpoints = new Map([ + [keyOf(0), ['stale']], + ]); + const pending = new KeyedSelection({ + getModel: getKeyedModel, + pendingRanges: [new GridRange(null, 0, null, 0)], + endpointKeyData: endpoints, + }); + const resolved = pending.resolve(new Map([[keyOf(0), ['fresh']]])); + expect(resolved.selectedKeyValues.get(keyOf(0))).toEqual(['fresh']); + }); +}); + +// ─── getUniqueRowCount ──────────────────────────────────────────────────────── + +describe('getUniqueRowCount', () => { + it('returns selectedKeys.size for a normal selection with unique keys', () => { + expect(singleRow().getUniqueRowCount()).toBe(1); + expect(multiRow().getUniqueRowCount()).toBe(2); + expect(empty().getUniqueRowCount()).toBe(0); + }); + + it('returns rowCount - exclusions for an inverted selection', () => { + // all rows selected → rowCount - 0 exclusions + expect(allRows().getUniqueRowCount()).toBe(ROW_COUNT); + + // exclude row 5 → rowCount - 1 + const excludeOne = new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set([keyOf(5)]), + invertedSelection: true, + }); + expect(excludeOne.getUniqueRowCount()).toBe(ROW_COUNT - 1); + }); + + it('returns null when pendingRanges is non-empty', () => { + const pending = new KeyedSelection({ + getModel: getKeyedModel, + pendingRanges: [new GridRange(null, 0, null, 9)], + }); + expect(pending.getUniqueRowCount()).toBeNull(); + }); + + it('returns null when hasUniqueSelectionKeys is false', () => { + const nonUniqueModel = { ...mockModel, hasUniqueSelectionKeys: false }; + const getModel: GetKeyedModel = () => nonUniqueModel as never; + const sel = new KeyedSelection({ + getModel, + selectedKeys: new Set([keyOf(0)]), + selectedKeyValues: new Map([keyValuesOf(0)]), + }); + expect(sel.getUniqueRowCount()).toBeNull(); + }); +}); + +// ─── withGestureAnchor / getGestureAnchor ──────────────────────────────────── + +describe('getGestureAnchor', () => { + const originalValueForCell = mockModel.valueForCell; + + afterEach(() => { + mockModel.viewport = { top: 0, bottom: ROW_COUNT - 1 }; + mockModel.valueForCell = originalValueForCell; + }); + + it('returns null when no anchor was set', () => { + expect(empty().getGestureAnchor()).toBeNull(); + }); + + it('returns the current viewport row of the anchor key after ticks', () => { + // Anchor captured at row 5 with key [5]. Then the table "ticks": the row + // at position 5 now has key [42] (arbitrary), and the anchor key [5] has + // moved to row 7. + const sel = empty().withGestureAnchor(5, 0); + mockModel.valueForCell = (_col, row) => { + if (row === 5) return 42; + if (row === 7) return 5; + return row; + }; + expect(sel.getGestureAnchor()).toEqual({ row: 7, column: null }); + }); + + it('falls back to the row hint when the anchor key is out of viewport', () => { + const sel = empty().withGestureAnchor(5, 0); + mockModel.viewport = { top: 10, bottom: 20 }; + expect(sel.getGestureAnchor()).toEqual({ row: 5, column: null }); + }); + + it('prefers the viewport row closest to the row hint for non-unique keys', () => { + // Anchor was clicked at row 10 with key [10]. After a tick, row 10 no + // longer holds that key but rows 2 and 15 do (non-unique keys). Distance + // to the hint: row 2 → 8, row 15 → 5, so row 15 wins. + const sel = empty().withGestureAnchor(10, 0); + mockModel.valueForCell = (_col, row) => { + if (row === 2 || row === 15) return 10; + if (row === 10) return 999; + return row; + }; + expect(sel.getGestureAnchor()).toEqual({ row: 15, column: null }); + }); +}); + +// ─── commitMouseGesture (multi-row → pending or fast path) ──────────────────── + +describe('commitMouseGesture', () => { + afterEach(() => { + mockModel.viewport = { top: 0, bottom: ROW_COUNT - 1 }; + }); + + it('commits a multi-row overlay synchronously when fully in viewport', () => { + const withOverlay = new KeyedSelection({ + getModel: getKeyedModel, + overlayRanges: [new GridRange(null, 0, null, 5)], + }); + const result = withOverlay.commitMouseGesture(empty(), { + autoSelectRow: false, + }); + expect(result.pendingRanges).toHaveLength(0); + for (let r = 0; r <= 5; r += 1) { + expect(result.isRowSelected(r)).toBe(true); + } + expect(result.selectedKeys.size).toBe(6); + }); + + it('returns a pending selection when overlay extends beyond viewport', () => { + mockModel.viewport = { top: 0, bottom: 2 }; + const withOverlay = new KeyedSelection({ + getModel: getKeyedModel, + overlayRanges: [new GridRange(null, 0, null, 5)], + }); + const result = withOverlay.commitMouseGesture(empty(), { + autoSelectRow: false, + }); + expect(result.pendingRanges).toHaveLength(1); + expect(result.pendingRanges[0].startRow).toBe(0); + expect(result.pendingRanges[0].endRow).toBe(5); + }); + + it('commits a single-row overlay synchronously', () => { + const withOverlay = new KeyedSelection({ + getModel: getKeyedModel, + overlayRanges: [new GridRange(null, 3, null, 3)], + }); + const result = withOverlay.commitMouseGesture(empty(), { + autoSelectRow: false, + }); + expect(result.pendingRanges).toHaveLength(0); + expect(result.isRowSelected(3)).toBe(true); + }); + + it('deselects a single row when it was the entire previous selection', () => { + const last = singleRow(3); + const withOverlay = new KeyedSelection({ + getModel: getKeyedModel, + overlayRanges: [new GridRange(null, 3, null, 3)], + }); + const result = withOverlay.commitMouseGesture(last, { + autoSelectRow: false, + }); + expect(result.isRowSelected(3)).toBe(false); + }); + + it('replaces prior committed keys on drag (isReplacing=true via withMouseGestureRanges)', () => { + // Reproduces the drag bug: prior commit selected key 2; drag extends to + // row 5. Without isReplacing the ctrl+click toggle path fires and + // fragments the selection. With isReplacing, the overlay replaces the + // prior committed keys wholesale. + const priorCommit = singleRow(2); + const dragOverlay = priorCommit.withMouseGestureRanges( + [new GridRange(null, 2, null, 5)], + true + ); + const result = dragOverlay.commitMouseGesture(priorCommit, { + autoSelectRow: false, + }); + for (let r = 2; r <= 5; r += 1) { + expect(result.isRowSelected(r)).toBe(true); + } + expect(result.selectedKeys.size).toBe(4); + }); +}); + +// ─── withToggledRow ─────────────────────────────────────────────────────────── + +describe('withToggledRow', () => { + it('adds a row that was not selected', () => { + const sel = empty().withToggledRow(4); + expect(sel.isRowSelected(4)).toBe(true); + }); + + it('removes a row that was already selected', () => { + const sel = singleRow(4).withToggledRow(4); + expect(sel.isRowSelected(4)).toBe(false); + }); + + it('preserves other selected rows when toggling a new one', () => { + const sel = singleRow(2).withToggledRow(4); + expect(sel.isRowSelected(2)).toBe(true); + expect(sel.isRowSelected(4)).toBe(true); + }); +}); diff --git a/packages/iris-grid/src/KeyedSelection.ts b/packages/iris-grid/src/KeyedSelection.ts new file mode 100644 index 0000000000..d3acf756a0 --- /dev/null +++ b/packages/iris-grid/src/KeyedSelection.ts @@ -0,0 +1,636 @@ +import { EMPTY_ARRAY, EMPTY_MAP } from '@deephaven/utils'; +import { + type BoundedAxisRange, + type CommitMouseGestureOptions, + type GridRange, + type GridRangeIndex, + type ModelIndex, + type Selection, + type VisibleIndex, +} from '@deephaven/grid'; +import type IrisGridModel from './IrisGridModel'; +import type { KeyedGridModel } from './KeyedGridModel'; + +export type GetKeyedModel = () => IrisGridModel & KeyedGridModel; + +/** + * Serializes key-column values to a stable string for use as a Map/Set key. + * JSON.stringify encodes NaN, Infinity, and -Infinity as null, so we + * substitute sentinel strings to preserve their distinct identities. + */ +export function serializeKeyValues(values: readonly unknown[]): string { + return JSON.stringify(values, (_key, value) => { + if (typeof value === 'number') { + if (Number.isNaN(value)) return '__NaN__'; + if (value === Infinity) return '__Infinity__'; + if (value === -Infinity) return '__-Infinity__'; + } + return value; + }); +} + +/** + * Configuration for a `KeyedSelection`. `getModel` is required; every + * other field defaults to an "empty" value (`null`, an empty set/map, or + * `false` where appropriate). + */ +export type KeyedSelectionOptions = { + /** + * Deferred lookup for the current `KeyedGridModel`. Passed as a closure + * (not a direct reference) so this Selection always reads the model + * currently on `Grid.props.model` — surviving prop swaps without holding + * a stale reference. + */ + getModel: GetKeyedModel; + /** + * Serialized keys that identify the committed selection. Interpreted as + * an inclusion set by default; as an exclusion set when + * `invertedSelection` is true. + */ + selectedKeys?: ReadonlySet; + /** + * Ranges from the in-progress mouse gesture. Cleared on commit; used to + * render an overlay before the gesture settles. + */ + overlayRanges?: readonly GridRange[]; + /** + * When true, `selectedKeys` is an exclusion set: all rows are selected + * EXCEPT those in the set. + */ + invertedSelection?: boolean; + /** + * Last committed single-row position; best-effort, may be stale after + * table ticks. Consumed by `getLastSingleSelectedRow` (drives gotoRow). + */ + lastSingleRow?: VisibleIndex | null; + /** + * Raw key-column values for each committed key; used for server-side + * filter construction (e.g. `buildKeyFilter`). + */ + selectedKeyValues?: ReadonlyMap; + /** + * When non-null, limits snapshot results to this many rows via the + * viewport subscription. + */ + maxRows?: number | null; + /** + * Ranges whose key values are being resolved asynchronously by + * `IrisGrid.resolveKeyedSelection`. Empty array means no resolution is in progress. + */ + pendingRanges?: readonly GridRange[]; + /** + * Serialized key of the shift-click / drag anchor. Drift-immune across + * ticks: `getGestureAnchor` scans the viewport for the row currently + * holding this key. + */ + anchorKey?: string | null; + /** Raw key-column values captured with the anchor at click time. */ + anchorValues?: readonly unknown[] | null; + /** + * Anchor row at click time. Fallback for `getGestureAnchor` when the + * anchor key has scrolled out of the viewport. + */ + anchorRow?: GridRangeIndex; + /** + * Endpoint key data captured at click time; merged into `resolve()` so + * shift-click endpoints survive fetch-time drift. + */ + endpointKeyData?: ReadonlyMap; +}; + +/** + * Immutable `Selection` for keyed tables: identifies rows by their + * serialized key-column values rather than raw row indices, so the + * selection survives ticks that shuffle row positions. + * + * Rows sharing the same key highlight together (see `isRowSelected`). + * When `invertedSelection` is true, `selectedKeys` is treated as an + * exclusion set (all rows selected except those keys). + */ +export class KeyedSelection implements Selection { + static empty(getModel: GetKeyedModel): KeyedSelection { + return new KeyedSelection({ getModel }); + } + + private readonly getModel: GetKeyedModel; + + readonly selectedKeys: ReadonlySet; + + private readonly overlayRanges: readonly GridRange[]; + + readonly invertedSelection: boolean; + + private readonly lastSingleRow: VisibleIndex | null; + + readonly selectedKeyValues: ReadonlyMap; + + readonly maxRows: number | null; + + readonly pendingRanges: readonly GridRange[]; + + private readonly anchorKey: string | null; + + private readonly anchorValues: readonly unknown[] | null; + + private readonly anchorRow: GridRangeIndex; + + readonly endpointKeyData: ReadonlyMap; + + /** Keys derived from the current overlay range's viewport-visible rows. */ + private readonly gestureKeys: ReadonlySet; + + constructor(options: KeyedSelectionOptions) { + this.getModel = options.getModel; + this.selectedKeys = options.selectedKeys ?? new Set(); + this.overlayRanges = options.overlayRanges ?? EMPTY_ARRAY; + this.invertedSelection = options.invertedSelection ?? false; + this.lastSingleRow = options.lastSingleRow ?? null; + this.selectedKeyValues = options.selectedKeyValues ?? EMPTY_MAP; + this.maxRows = options.maxRows ?? null; + this.pendingRanges = options.pendingRanges ?? EMPTY_ARRAY; + this.anchorKey = options.anchorKey ?? null; + this.anchorValues = options.anchorValues ?? null; + this.anchorRow = options.anchorRow ?? null; + this.endpointKeyData = options.endpointKeyData ?? EMPTY_MAP; + + // Enumerate only viewport-visible rows so gesture key lookup stays O(1) + // and construction is O(viewport) regardless of total table size. + if (this.overlayRanges.length === 0) { + this.gestureKeys = new Set(); + } else { + const model = this.getModel(); + const viewTop = model.viewport?.top ?? 0; + const viewBottom = model.viewport?.bottom ?? 0; + const keys = new Set(); + for (let i = 0; i < this.overlayRanges.length; i += 1) { + const { startRow, endRow } = this.overlayRanges[i]; + if (startRow == null) continue; // eslint-disable-line no-continue + const last = endRow ?? startRow; + const clampedStart = Math.max(startRow, viewTop); + const clampedEnd = Math.min(last, viewBottom); + for (let r = clampedStart; r <= clampedEnd; r += 1) { + keys.add(this.getRowKeyData(r).key); + } + } + this.gestureKeys = keys; + } + } + + /** + * Returns a copy of this selection with the given fields overridden. + * Unspecified fields carry through; pass `null` (or an empty + * set/map/false) to explicitly clear a field. + */ + private copyWith(overrides: Partial): KeyedSelection { + return new KeyedSelection({ + getModel: this.getModel, + selectedKeys: this.selectedKeys, + overlayRanges: this.overlayRanges, + invertedSelection: this.invertedSelection, + lastSingleRow: this.lastSingleRow, + selectedKeyValues: this.selectedKeyValues, + maxRows: this.maxRows, + pendingRanges: this.pendingRanges, + anchorKey: this.anchorKey, + anchorValues: this.anchorValues, + anchorRow: this.anchorRow, + endpointKeyData: this.endpointKeyData, + ...overrides, + }); + } + + /** Returns both the serialized key and the raw values for a visible row. */ + private getRowKeyData(row: VisibleIndex): { + key: string; + values: readonly unknown[]; + } { + const model = this.getModel(); + const values = model.selectionKeyColumnIndices.map((col: ModelIndex) => + model.valueForCell(col, row) + ); + return { key: serializeKeyValues(values), values }; + } + + isEmpty(): boolean { + // Inverted selection means all rows are selected — never empty. + if (this.invertedSelection) return false; + // Pending resolution means a selection is in progress — not empty. + if (this.pendingRanges.length > 0) return false; + return this.selectedKeys.size === 0 && this.gestureKeys.size === 0; + } + + // Keyed selection is always full-row; column is irrelevant + isCellSelected(_column: VisibleIndex, row: VisibleIndex): boolean { + return this.isRowSelected(row); + } + + isRowSelected(row: VisibleIndex): boolean { + const { key } = this.getRowKeyData(row); + if (this.invertedSelection) return !this.selectedKeys.has(key); + // Include gesture preview keys so key-siblings highlight on mousedown without waiting for commit. + return this.selectedKeys.has(key) || this.gestureKeys.has(key); + } + + // eslint-disable-next-line class-methods-use-this + isValid(_columnCount: number, _rowCount: number): boolean { + return true; + } + + getLastSingleSelectedRow(): VisibleIndex | null { + if (this.invertedSelection || this.selectedKeys.size !== 1) return null; + return this.lastSingleRow; + } + + toActiveRanges(): readonly GridRange[] { + return this.overlayRanges; + } + + // eslint-disable-next-line class-methods-use-this + getColumnTickRanges(): readonly BoundedAxisRange[] { + // Keyed selection does not support column-specific tick ranges. + return EMPTY_ARRAY; + } + + // eslint-disable-next-line class-methods-use-this + getRowTickRanges(): readonly BoundedAxisRange[] { + // Keyed selection does not support row-specific tick ranges. + return EMPTY_ARRAY; + } + + // Drops selectedKeys / selectedKeyValues / invertedSelection on `isReplacing` + // so drag and shift-click commit with the overlay as a fresh selection. + // Drops selectedKeys / selectedKeyValues / invertedSelection on `isReplacing` + // so drag and shift-click commit with the overlay as a fresh selection. + withMouseGestureRanges( + ranges: readonly GridRange[], + isReplacing = false + ): KeyedSelection { + if (isReplacing) { + return this.copyWith({ + selectedKeys: new Set(), + overlayRanges: ranges, + invertedSelection: false, + lastSingleRow: null, + selectedKeyValues: EMPTY_MAP, + pendingRanges: EMPTY_ARRAY, + }); + } + return this.copyWith({ + overlayRanges: ranges, + lastSingleRow: null, + pendingRanges: EMPTY_ARRAY, + }); + } + + withGestureAnchor( + row: GridRangeIndex, + _column: GridRangeIndex + ): KeyedSelection { + // Keyed selections are full-row; ignore column. + let nextKey: string | null = null; + let nextValues: readonly unknown[] | null = null; + if (row != null) { + const { key, values } = this.getRowKeyData(row); + nextKey = key; + nextValues = values; + } + if (nextKey === this.anchorKey && row === this.anchorRow) return this; + return this.copyWith({ + anchorKey: nextKey, + anchorValues: nextValues, + anchorRow: row, + }); + } + + getGestureAnchor(): { + row: GridRangeIndex; + column: GridRangeIndex; + } | null { + if (this.anchorKey == null && this.anchorRow == null) return null; + if (this.anchorKey != null) { + const viewportRow = this.findKeyInViewport(this.anchorKey); + if (viewportRow != null) return { row: viewportRow, column: null }; + } + // Anchor key is not in the viewport; fall back to the click-time row hint. + return { row: this.anchorRow, column: null }; + } + + /** + * Returns the visible row of `key`, or `null` if it's not in the viewport. + * When multiple rows share the key (non-unique key columns), prefers the one + * closest to `anchorRow` so we track the row the user actually clicked. + */ + private findKeyInViewport(key: string): VisibleIndex | null { + const model = this.getModel(); + const viewTop = model.viewport?.top ?? 0; + const viewBottom = model.viewport?.bottom ?? 0; + const hint = this.anchorRow; + let best: VisibleIndex | null = null; + let bestDist = Infinity; + for (let r = viewTop; r <= viewBottom; r += 1) { + if (this.getRowKeyData(r).key !== key) continue; // eslint-disable-line no-continue + if (hint == null) return r; + const dist = Math.abs(r - hint); + if (dist < bestDist) { + best = r; + bestDist = dist; + } + } + return best; + } + + commitMouseGesture( + lastCommitted: Selection, + _options: CommitMouseGestureOptions + ): KeyedSelection { + if (this.overlayRanges.length === 0) return this; + + // Scan ranges for endpoints and total row count + let first: VisibleIndex | null = null; + let lastRow: VisibleIndex = 0; + let rowCount = 0; + for (let i = 0; i < this.overlayRanges.length; i += 1) { + const { startRow, endRow } = this.overlayRanges[i]; + if (startRow == null) continue; // eslint-disable-line no-continue + const rEnd = endRow ?? startRow; + if (first === null) first = startRow; + lastRow = rEnd; + rowCount += rEnd - startRow + 1; + } + if (first === null || rowCount === 0) return this; + + const next = new Set(this.selectedKeys); + + if (this.selectedKeys.size > 0 || this.invertedSelection) { + // Ctrl+click path: clearSelectedRanges was not called, so selectedKeys still + // holds the previous committed keys. Toggle each overlay row individually. + const nextKeyValues = new Map(this.selectedKeyValues); + for (let i = 0; i < this.overlayRanges.length; i += 1) { + const { startRow, endRow } = this.overlayRanges[i]; + if (startRow == null) continue; // eslint-disable-line no-continue + const rEnd = endRow ?? startRow; + for (let r = startRow; r <= rEnd; r += 1) { + const { key: k, values } = this.getRowKeyData(r); + if (lastCommitted.isRowSelected(r)) { + if (this.invertedSelection) { + next.add(k); + nextKeyValues.set(k, values); + } else { + next.delete(k); + nextKeyValues.delete(k); + } + } else if (this.invertedSelection) { + next.delete(k); + nextKeyValues.delete(k); + } else { + next.add(k); + nextKeyValues.set(k, values); + } + } + } + return this.copyWith({ + selectedKeys: next, + overlayRanges: EMPTY_ARRAY, + lastSingleRow: null, + selectedKeyValues: nextKeyValues, + pendingRanges: EMPTY_ARRAY, + }); + } + + // Regular click path: clearSelectedRanges emptied selectedKeys first. + // Multi-row shift selections may span out-of-viewport rows where valueForCell + // returns null. Defer those to async resolution in IrisGrid. + // Assumes shift+click/drag emits one contiguous overlay range; `first`/`lastRow` + // are that range's endpoints and drive the anchor/target endpoint capture below. + if (rowCount > 1) { + const model = this.getModel(); + const viewTop = model.viewport?.top ?? 0; + const viewBottom = model.viewport?.bottom ?? 0; + + // Fast path: entire range is in the viewport, so we can enumerate keys + // synchronously and skip the async fetch race entirely. + if (first >= viewTop && lastRow <= viewBottom) { + const nextKeyValues = new Map(); + for (let r = first; r <= lastRow; r += 1) { + const { key: k, values } = this.getRowKeyData(r); + nextKeyValues.set(k, values); + } + return this.copyWith({ + selectedKeys: new Set(nextKeyValues.keys()), + overlayRanges: EMPTY_ARRAY, + invertedSelection: false, + lastSingleRow: null, + selectedKeyValues: nextKeyValues, + pendingRanges: EMPTY_ARRAY, + }); + } + + // Slow path: async resolve. Endpoints are needed even if the fetch is + // preempted by ticks: the anchor is drift-immune (cached at click time); + // the target is the just-clicked row (must be in the viewport) — + // identify it as the endpoint that isn't the anchor's current position. + const endpoints = new Map(); + if (this.anchorKey != null && this.anchorValues != null) { + endpoints.set(this.anchorKey, this.anchorValues); + } + const anchorNow = this.getGestureAnchor()?.row; + const targetRow = anchorNow === lastRow ? first : lastRow; + const { key: tKey, values: tValues } = this.getRowKeyData(targetRow); + endpoints.set(tKey, tValues); + + return this.copyWith({ + selectedKeys: new Set(), + invertedSelection: false, + lastSingleRow: null, + selectedKeyValues: EMPTY_MAP, + pendingRanges: this.overlayRanges, + endpointKeyData: endpoints, + }); + } + + // Single-row path (rowCount === 1): first === lastRow. + const row = first; + const { key: k, values } = this.getRowKeyData(row); + const nextKeyValues = new Map(this.selectedKeyValues); + // Deselect only when the clicked row was the entire previous committed selection. + const wasEntireSelection = + lastCommitted instanceof KeyedSelection && + !lastCommitted.invertedSelection && + lastCommitted.selectedKeys.size === 1 && + lastCommitted.selectedKeys.has(k); + if (wasEntireSelection) { + next.delete(k); + nextKeyValues.delete(k); + } else { + next.add(k); + nextKeyValues.set(k, values); + } + // Store the single committed row so getLastSingleSelectedRow() works for gotoRow sync. + const singleRow = next.size === 1 ? row : null; + return this.copyWith({ + selectedKeys: next, + overlayRanges: EMPTY_ARRAY, + invertedSelection: false, + lastSingleRow: singleRow, + selectedKeyValues: nextKeyValues, + pendingRanges: EMPTY_ARRAY, + }); + } + + clear(): KeyedSelection { + return new KeyedSelection({ getModel: this.getModel }); + } + + // Shift+click needs a clean slate so the range replaces rather than extends the old keys. + // Anchor is preserved so shift+click's extend reads it after the trim. + trimmed(): KeyedSelection { + return this.copyWith({ + selectedKeys: new Set(), + overlayRanges: EMPTY_ARRAY, + invertedSelection: false, + lastSingleRow: null, + selectedKeyValues: EMPTY_MAP, + maxRows: null, + pendingRanges: EMPTY_ARRAY, + endpointKeyData: EMPTY_MAP, + }); + } + + // Always returns non-inverted; switching to a new selection exits inverted mode. + withCommittedRanges(ranges: readonly GridRange[]): KeyedSelection { + // Replacement semantics: discard previous selection and select exactly these rows. + if (ranges.length === 0) { + return new KeyedSelection({ getModel: this.getModel }); + } + + // Compute the true min/max across every range so the viewport check is + // correct even when input ranges arrive out of order or non-contiguous + // (e.g. plugin-driven programmatic selection). + let minRow: VisibleIndex | null = null; + let maxRow: VisibleIndex | null = null; + for (let i = 0; i < ranges.length; i += 1) { + const { startRow, endRow } = ranges[i]; + if (startRow == null) continue; // eslint-disable-line no-continue + const rEnd = endRow ?? startRow; + const low = Math.min(startRow, rEnd); + const high = Math.max(startRow, rEnd); + if (minRow === null || low < minRow) minRow = low; + if (maxRow === null || high > maxRow) maxRow = high; + } + if (minRow === null || maxRow === null) { + return new KeyedSelection({ getModel: this.getModel }); + } + + const model = this.getModel(); + const viewTop = model.viewport?.top ?? 0; + const viewBottom = model.viewport?.bottom ?? 0; + + // Fast path: every range fits in the viewport, so valueForCell answers + // synchronously with real values. + if (minRow >= viewTop && maxRow <= viewBottom) { + const next = new Set(); + const nextKeyValues = new Map(); + for (let i = 0; i < ranges.length; i += 1) { + const { startRow, endRow } = ranges[i]; + if (startRow == null) continue; // eslint-disable-line no-continue + const rEnd = endRow ?? startRow; + const low = Math.min(startRow, rEnd); + const high = Math.max(startRow, rEnd); + for (let r = low; r <= high; r += 1) { + const { key: k, values } = this.getRowKeyData(r); + next.add(k); + nextKeyValues.set(k, values); + } + } + return new KeyedSelection({ + getModel: this.getModel, + selectedKeys: next, + selectedKeyValues: nextKeyValues, + }); + } + + // Slow path: any row outside the viewport would resolve to a phantom + // all-null key via undefined valueForCell reads. Defer to async key fetch; + // IrisGrid's onSelectionChange handler picks up pendingRanges. + return new KeyedSelection({ + getModel: this.getModel, + pendingRanges: ranges, + }); + } + + // Sets invertedSelection=true with an empty exclusion set (all rows selected). + // eslint-disable-next-line class-methods-use-this + selectAll(): KeyedSelection { + return new KeyedSelection({ + getModel: this.getModel, + invertedSelection: true, + }); + } + + truncate(maxRows: number): KeyedSelection { + if (maxRows === this.maxRows) return this; + return this.copyWith({ maxRows }); + } + + /** Builds a fully-resolved selection from async-fetched key values, clearing pendingRanges. */ + resolve(keyValues: ReadonlyMap): KeyedSelection { + // Guarantee the click-time endpoints survive: fetches over a ticking table + // can miss the anchor / target rows if they scrolled between click and reply. + const merged = new Map(keyValues); + this.endpointKeyData.forEach((values, key) => { + if (!merged.has(key)) merged.set(key, values); + }); + return this.copyWith({ + selectedKeys: new Set(merged.keys()), + overlayRanges: EMPTY_ARRAY, + invertedSelection: false, + lastSingleRow: null, + selectedKeyValues: merged, + maxRows: null, + pendingRanges: EMPTY_ARRAY, + endpointKeyData: EMPTY_MAP, + }); + } + + /** + * Returns the exact committed row count when each key maps to one row, + * or null when the count is unknown (non-unique keys or pending resolution). + * For inverted selections the count is approximate on ticking tables. + */ + getUniqueRowCount(): number | null { + if ( + this.pendingRanges.length > 0 || + !this.getModel().hasUniqueSelectionKeys + ) { + return null; + } + if (this.invertedSelection) { + return this.getModel().rowCount - this.selectedKeys.size; + } + return this.selectedKeys.size; + } + + /** Returns a new selection with the given row's key toggled. */ + withToggledRow(row: VisibleIndex): KeyedSelection { + const { key, values } = this.getRowKeyData(row); + const next = new Set(this.selectedKeys); + const nextKeyValues = new Map(this.selectedKeyValues); + if (next.has(key)) { + next.delete(key); + nextKeyValues.delete(key); + } else { + next.add(key); + nextKeyValues.set(key, values); + } + return this.copyWith({ + selectedKeys: next, + overlayRanges: EMPTY_ARRAY, + lastSingleRow: null, + selectedKeyValues: nextKeyValues, + pendingRanges: EMPTY_ARRAY, + endpointKeyData: EMPTY_MAP, + }); + } +} + +export default KeyedSelection; diff --git a/packages/iris-grid/src/index.ts b/packages/iris-grid/src/index.ts index 778df1182b..14fd148129 100644 --- a/packages/iris-grid/src/index.ts +++ b/packages/iris-grid/src/index.ts @@ -38,6 +38,9 @@ export { default as IrisGridUtils } from './IrisGridUtils'; export * from './IrisGridUtils'; export * from './IrisGridMetricCalculator'; export * from './IrisGridRenderer'; +export * from './KeyedGridModel'; +export { KeyedSelection } from './KeyedSelection'; +export type { GetKeyedModel } from './KeyedSelection'; export * from './IrisGridCacheUtils'; export { default as IrisGridCellRendererUtils } from './IrisGridCellRendererUtils'; export { default as CellDropdownField } from './CellDropdownField'; diff --git a/packages/iris-grid/src/key-handlers/CopyKeyHandler.ts b/packages/iris-grid/src/key-handlers/CopyKeyHandler.ts index d255b7f08c..6c90f5cb4e 100644 --- a/packages/iris-grid/src/key-handlers/CopyKeyHandler.ts +++ b/packages/iris-grid/src/key-handlers/CopyKeyHandler.ts @@ -1,7 +1,7 @@ /* eslint class-methods-use-this: "off" */ import { type KeyboardEvent } from 'react'; import { ContextActionUtils } from '@deephaven/components'; -import { KeyHandler } from '@deephaven/grid'; +import { isRangedSelection, KeyHandler } from '@deephaven/grid'; import type IrisGrid from '../IrisGrid'; import IrisGridUtils from '../IrisGridUtils'; @@ -15,17 +15,22 @@ class CopyKeyHandler extends KeyHandler { } onDown(event: KeyboardEvent): boolean { - const { selectedRanges } = this.irisGrid.state; if (event.key === 'c' && ContextActionUtils.isModifierKeyDown(event)) { - if (IrisGridUtils.isValidSnapshotRanges(selectedRanges)) { - this.irisGrid.copyRanges(selectedRanges); - } else { - this.irisGrid.copyRanges( - selectedRanges, - false, - false, - 'Invalid copy ranges' - ); + const { gridSelection } = this.irisGrid.state; + if (gridSelection != null && !gridSelection.isEmpty()) { + if ( + isRangedSelection(gridSelection) && + !IrisGridUtils.isValidSnapshotRanges(gridSelection.toRanges()) + ) { + this.irisGrid.copySelection( + gridSelection, + false, + false, + 'Invalid copy ranges' + ); + } else { + this.irisGrid.copySelection(gridSelection); + } } return true; } diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.test.tsx b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.test.tsx index 92a7367865..7c19e3649d 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.test.tsx +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.test.tsx @@ -5,7 +5,6 @@ import { type Grid, type GridMetrics, type GridPoint, - GridSelectionMouseHandler, type ModelIndex, } from '@deephaven/grid'; import { type dh } from '@deephaven/jsapi-types'; @@ -82,9 +81,6 @@ describe('onContextMenu modelRow prop', () => { const mockDh = createMockProxy(); beforeEach(() => { - jest - .spyOn(GridSelectionMouseHandler, 'getLatestSelection') - .mockReturnValue([]); jest.spyOn(ContextActions, 'triggerMenu').mockImplementation(() => null); }); diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx index 166f7e2061..f65a052a79 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx @@ -23,13 +23,15 @@ import { type GridPoint, GridRange, GridRenderer, - GridSelectionMouseHandler, isDeletableGridModel, isEditableGridModel, isExpandableColumnGridModel, isExpandableGridModel, + isRangedSelection, type ModelIndex, parseValueFromText, + RangedSelection, + type Selection, } from '@deephaven/grid'; import type { dh as DhType } from '@deephaven/jsapi-types'; import { @@ -47,8 +49,6 @@ import { ClipboardPermissionsDeniedError, ClipboardUnavailableError, TextUtils, - assertNotEmpty, - assertNotNaN, assertNotNull, copyToClipboard, readFromClipboard, @@ -62,8 +62,20 @@ import { import './IrisGridContextMenuHandler.scss'; import SHORTCUTS from '../IrisGridShortcuts'; import type IrisGrid from '../IrisGrid'; +import type IrisGridModel from '../IrisGridModel'; +import { + snapshotFromSelection, + computeVisibleColumns, +} from '../IrisGridSelectionUtils'; import { type QuickFilter } from '../CommonTypes'; import { isPartitionedGridModel } from '../PartitionedGridModel'; +import { isKeyedGridModel, type KeyedGridModel } from '../KeyedGridModel'; +import { + KeyedSelection, + type GetKeyedModel, + serializeKeyValues, +} from '../KeyedSelection'; +import IrisGridUtils from '../IrisGridUtils'; const log = Log.module('IrisGridContextMenuHandler'); @@ -426,7 +438,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { assertNotNull(modelRow); const sourceCell = model.sourceForCell(modelColumn, modelRow); const { column: sourceColumn, row: sourceRow } = sourceCell; - const { selectedRanges } = irisGrid.state; + const { gridSelection } = irisGrid.state; const column = columns[sourceColumn]; @@ -539,18 +551,18 @@ class IrisGridContextMenuHandler extends GridMouseHandler { } if (isEditableGridModel(model) && model.isEditable) { - // selectedRanges is updated by GridSelectionMouseHandler in the same cycle so can't access the updated value here + // gridSelection is updated by GridSelectionMouseHandler in the same cycle so can't access the updated value here // so need to handle cases where a cell is right clicked without highlighting it first - const canPasteInOriginalRange = selectedRanges.every(range => - model.isEditableRange(range) - ); + const canPasteInOriginalRange = + gridSelection != null && + isRangedSelection(gridSelection) && + gridSelection.toRanges().every(range => model.isEditableRange(range)); // To account for how when a cell outside of a selection is right clicked, that selection gets cleared - const isCellInOriginalRange = GridRange.containsCell( - selectedRanges, - columnIndex, - rowIndex - ); + const isCellInOriginalRange = + rowIndex != null && + columnIndex != null && + (gridSelection?.isCellSelected(columnIndex, rowIndex) ?? false); const canPasteInCell = model.isEditableRange( GridRange.makeCell(columnIndex, rowIndex) @@ -619,94 +631,80 @@ class IrisGridContextMenuHandler extends GridMouseHandler { // 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 { row: rowIndex, column: columnIndex } = 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 (column == null || rowIndex == null || columnIndex == null) return []; if (!model.isFilterable(sourceColumn)) return []; - const { quickFilters } = irisGrid.state; + const { quickFilters, movedColumns } = irisGrid.state; + const userColumnWidths = + irisGrid.state.metricCalculator.getUserColumnWidths(); + // Read directly from Grid state to avoid the React-async lag between grid.state.selection and irisGrid.state.gridSelection + const gridSelection = irisGrid.grid?.getSelection() ?? null; 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) + const makeSingleCellSelection = (): RangedSelection => + new RangedSelection( + [new GridRange(columnIndex, rowIndex, columnIndex, rowIndex)], + () => model ); - } - // - 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; - } + let effectiveSelection: Selection; + if (gridSelection == null || gridSelection.isEmpty()) { + effectiveSelection = makeSingleCellSelection(); + } else if (gridSelection.isCellSelected(columnIndex, rowIndex)) { + effectiveSelection = gridSelection.truncate(MAX_MULTISELECT_ROWS); } 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), - ]; + effectiveSelection = makeSingleCellSelection(); } - // 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); + const snapshot = await snapshotFromSelection( + effectiveSelection, + model, + movedColumns, + userColumnWidths + ); + + // Locate sourceColumn in the exact ordered column list used by the snapshot. + // KeyedSelection snapshots use visual (moved) order; RangedSelection snapshots use model order. + let snapshotColumnIndex: number; + if (effectiveSelection instanceof KeyedSelection) { + const visibleColumns = computeVisibleColumns( + model, + movedColumns, + userColumnWidths + ); + snapshotColumnIndex = visibleColumns.findIndex( + col => model.getColumnIndexByName(col.name) === sourceColumn + ); + } else { + const hiddenColumns = IrisGridUtils.getHiddenColumns(userColumnWidths); + snapshotColumnIndex = + sourceColumn - hiddenColumns.filter(h => h < sourceColumn).length; + } // 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 + // single-column snapshot (single-cell selection path) 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]); + } else if (snapshot[i][snapshotColumnIndex] != null) { + snapshotValues.add(snapshot[i][snapshotColumnIndex]); } } // if snapshotValues is empty here, it means all of the snapshot's values were null/undefined @@ -927,14 +925,52 @@ class IrisGridContextMenuHandler extends GridMouseHandler { isFilterBarShown, quickFilters, advancedFilters, - selectedRanges: stateSelectedRanges, + gridSelection, } = irisGrid.state; - const selectedRanges = GridSelectionMouseHandler.getLatestSelection( - stateSelectedRanges, - columnIndex, - rowIndex - ); + // If the clicked cell is in the current selection keep it; otherwise treat as a single-cell selection. + const clickedInSelection = + rowIndex != null && + columnIndex != null && + (gridSelection?.isCellSelected(columnIndex, rowIndex) ?? false); + let effectiveSelection: Selection | null; + if (clickedInSelection) { + effectiveSelection = gridSelection; + } else if (rowIndex != null && columnIndex != null) { + if (isKeyedGridModel(model) && modelRow != null) { + // Construct a committed single-row KeyedSelection from the model's key columns. + const getModel = () => model as IrisGridModel & KeyedGridModel; + const keyIndices = model.selectionKeyColumnIndices; + const values = keyIndices.map(i => model.valueForCell(i, modelRow)); + const key = serializeKeyValues(values); + const keyValues = new Map([[key, values]]); + effectiveSelection = new KeyedSelection({ + getModel: getModel as GetKeyedModel, + selectedKeys: new Set([key]), + lastSingleRow: rowIndex, + selectedKeyValues: keyValues, + }); + } else if (isEditableGridModel(model) && model.isEditable) { + // Input tables: single-cell selection (editable rows are cell-granular). + effectiveSelection = new RangedSelection( + [GridRange.makeCell(columnIndex, rowIndex)], + () => model + ); + } else { + // Regular tables: full-row selection so all visible columns are included. + effectiveSelection = new RangedSelection( + [new GridRange(null, rowIndex, null, rowIndex)], + () => model + ); + } + } else { + effectiveSelection = null; + } + // Only ranged selections support row-range operations (delete). + const effectiveRanges = + effectiveSelection != null && isRangedSelection(effectiveSelection) + ? effectiveSelection.toRanges() + : []; assertNotNull(metrics); @@ -962,6 +998,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { columnIndex, modelRow, modelColumn, + selection: effectiveSelection, }) ); } @@ -1007,18 +1044,22 @@ 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)); + actions.push(this.getCellFilterActions(modelColumn, gridPoint)); } // blank space context menu options - if (canCopy && selectedRanges.length > 0) { + if ( + canCopy && + effectiveSelection != null && + !effectiveSelection.isEmpty() + ) { actions.push({ title: 'Copy Selection', shortcut: GLOBAL_SHORTCUTS.COPY, group: IrisGridContextMenuHandler.GROUP_COPY, order: 30, action: () => { - irisGrid.copyRanges(selectedRanges); + irisGrid.copySelection(effectiveSelection); }, }); @@ -1027,7 +1068,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { group: IrisGridContextMenuHandler.GROUP_COPY, order: 40, action: () => { - irisGrid.copyRanges(selectedRanges, true); + irisGrid.copySelection(effectiveSelection, true); }, }); } @@ -1035,17 +1076,17 @@ class IrisGridContextMenuHandler extends GridMouseHandler { if ( isEditableGridModel(model) && model.isEditable && - selectedRanges.length > 0 && + effectiveRanges.length > 0 && isDeletableGridModel(model) && model.isDeletable ) { actions.push({ title: 'Delete Selected Rows', group: IrisGridContextMenuHandler.GROUP_EDIT, - disabled: !model.isDeletableRanges(selectedRanges), + disabled: !model.isDeletableRanges(effectiveRanges), order: 50, action: () => { - this.irisGrid.deleteRanges(selectedRanges); + this.irisGrid.deleteRanges(effectiveRanges); }, }); } diff --git a/packages/iris-grid/src/sidebar/TableCsvExporter.test.tsx b/packages/iris-grid/src/sidebar/TableCsvExporter.test.tsx index ef740e9afd..432cb90a8c 100644 --- a/packages/iris-grid/src/sidebar/TableCsvExporter.test.tsx +++ b/packages/iris-grid/src/sidebar/TableCsvExporter.test.tsx @@ -3,8 +3,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import dh from '@deephaven/jsapi-shim'; import { ThemeProvider } from '@deephaven/components'; +import type { dh as DhType } from '@deephaven/jsapi-types'; import TableCsvExporter from './TableCsvExporter'; import IrisGridTestUtils from '../IrisGridTestUtils'; +import { KeyedSelection, type GetKeyedModel } from '../KeyedSelection'; +import type IrisGridModel from '../IrisGridModel'; const irisGridTestUtils = new IrisGridTestUtils(dh); const COLUMN_NAMES = ['A', 'B', 'C', 'D']; @@ -30,7 +33,7 @@ function makeTableCsvExporterWrapper({ onDownloadStart = jest.fn(), onDownload = jest.fn(), onCancel = jest.fn(), - selectedRanges = [], + selection = null, userColumnWidths = IrisGridTestUtils.makeUserColumnWidths(), movedColumns = [], model = irisGridTestUtils.makeModel(TABLE), @@ -47,7 +50,7 @@ function makeTableCsvExporterWrapper({ onDownloadStart={onDownloadStart} onDownload={onDownload} onCancel={onCancel} - selectedRanges={selectedRanges} + selection={selection} userColumnWidths={userColumnWidths} movedColumns={movedColumns} model={model} @@ -91,3 +94,222 @@ it('cancels download when something goes wrong', async () => { expect(onDownload).not.toHaveBeenCalled(); await waitFor(() => expect(onCancel).toHaveBeenCalled()); }); + +// ─── KeyedSelection export path ────────────────────────────────────────────── + +const KEYED_MODEL_STUB = { + selectionKeyColumnIndices: [0] as readonly number[], + hasUniqueSelectionKeys: true, + columnCount: COLUMN_NAMES.length, + rowCount: 100, + valueForCell: () => null, + viewport: null, +}; +const getKeyedModel: GetKeyedModel = () => KEYED_MODEL_STUB as never; + +/** Minimal frozen-table stub covering the calls handleDownloadClick makes on it. */ +function makeFrozenTableStub( + size: number +): DhType.Table & { close: jest.Mock; setViewport: jest.Mock } { + const subscription = { + getViewportData: jest.fn().mockResolvedValue({}), + } as unknown as DhType.TableViewportSubscription; + return { + size, + close: jest.fn(), + setViewport: jest.fn(() => subscription), + } as unknown as DhType.Table & { close: jest.Mock; setViewport: jest.Mock }; +} + +/** Minimal staging-table stub for `createFilteredByKeysTable` return value. */ +function makeStagingTableStub( + frozenTable: DhType.Table +): DhType.Table & { close: jest.Mock; freeze: jest.Mock } { + return { + close: jest.fn(), + freeze: jest.fn().mockResolvedValue(frozenTable), + } as unknown as DhType.Table & { close: jest.Mock; freeze: jest.Mock }; +} + +/** + * Returns a model whose type asserts as `IrisGridModel & KeyedGridModel`, with + * enough surface for the exporter's KeyedSelection branch and initial render. + */ +function makeKeyedModel( + overrides: Partial<{ + createFilteredByKeysTable: jest.Mock; + export: jest.Mock; + }> = {} +): IrisGridModel { + return { + dh, + rowCount: 100, + columnCount: COLUMN_NAMES.length, + selectionKeyColumnIndices: [0], + hasUniqueSelectionKeys: true, + createFilteredByKeysTable: jest.fn(), + export: jest.fn(), + ...overrides, + } as unknown as IrisGridModel; +} + +async function pickSelectedRowsThenDownload( + user: ReturnType +): Promise { + await user.click(screen.getByTestId('radio-csv-exporter-only-selected')); + await user.click(screen.getByRole('button', { name: 'Download' })); +} + +it('filters, freezes, and hands off frozenTable for a KeyedSelection', async () => { + const user = userEvent.setup(); + const frozenTable = makeFrozenTableStub(3); + const stagingTable = makeStagingTableStub(frozenTable); + const createFilteredByKeysTable = jest.fn().mockResolvedValue(stagingTable); + const model = makeKeyedModel({ createFilteredByKeysTable }); + + const selectedKeyValues = new Map([ + ['[1]', [1]], + ['[2]', [2]], + ['[3]', [3]], + ]); + const selection = new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set(selectedKeyValues.keys()), + selectedKeyValues, + }); + + const onDownload = jest.fn(); + const onDownloadStart = jest.fn(); + const onCancel = jest.fn(); + makeTableCsvExporterWrapper({ + model, + selection, + onDownload, + onDownloadStart, + onCancel, + }); + + await pickSelectedRowsThenDownload(user); + + expect(onDownloadStart).toHaveBeenCalled(); + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1)); + expect(createFilteredByKeysTable).toHaveBeenCalledWith( + selectedKeyValues, + false + ); + expect(stagingTable.freeze).toHaveBeenCalled(); + expect(onDownload).toHaveBeenCalledWith( + expect.any(String), + frozenTable, + expect.anything(), + expect.any(Array), + expect.any(Array), + expect.any(Boolean), + expect.any(Boolean) + ); + // Staging table is always closed; frozenTable is now owned by TableSaver. + expect(stagingTable.close).toHaveBeenCalled(); + expect(frozenTable.close).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); +}); + +it('passes invertedSelection through to createFilteredByKeysTable', async () => { + const user = userEvent.setup(); + const frozenTable = makeFrozenTableStub(97); + const stagingTable = makeStagingTableStub(frozenTable); + const createFilteredByKeysTable = jest.fn().mockResolvedValue(stagingTable); + const model = makeKeyedModel({ createFilteredByKeysTable }); + + const selectedKeyValues = new Map([ + ['[1]', [1]], + ['[2]', [2]], + ['[3]', [3]], + ]); + const selection = new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set(selectedKeyValues.keys()), + selectedKeyValues, + invertedSelection: true, + }); + + const onDownload = jest.fn(); + makeTableCsvExporterWrapper({ model, selection, onDownload }); + await pickSelectedRowsThenDownload(user); + + await waitFor(() => expect(onDownload).toHaveBeenCalled()); + expect(createFilteredByKeysTable).toHaveBeenCalledWith( + selectedKeyValues, + true + ); +}); + +it('cancels and closes both staging tables when the filter yields zero rows', async () => { + const user = userEvent.setup(); + const frozenTable = makeFrozenTableStub(0); + const stagingTable = makeStagingTableStub(frozenTable); + const createFilteredByKeysTable = jest.fn().mockResolvedValue(stagingTable); + const model = makeKeyedModel({ createFilteredByKeysTable }); + + const selection = new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set(['[1]']), + selectedKeyValues: new Map([['[1]', [1]]]), + }); + + const onDownload = jest.fn(); + const onCancel = jest.fn(); + makeTableCsvExporterWrapper({ model, selection, onDownload, onCancel }); + await pickSelectedRowsThenDownload(user); + + await waitFor(() => expect(onCancel).toHaveBeenCalled()); + expect(onDownload).not.toHaveBeenCalled(); + // Neither table was handed off — both must close. + expect(stagingTable.close).toHaveBeenCalled(); + expect(frozenTable.close).toHaveBeenCalled(); +}); + +it('cancels and closes the staging table when freeze rejects', async () => { + const user = userEvent.setup(); + const stagingTable = makeStagingTableStub(makeFrozenTableStub(3)); + stagingTable.freeze.mockRejectedValue(new Error('freeze failed')); + const createFilteredByKeysTable = jest.fn().mockResolvedValue(stagingTable); + const model = makeKeyedModel({ createFilteredByKeysTable }); + + const selection = new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set(['[1]']), + selectedKeyValues: new Map([['[1]', [1]]]), + }); + + const onDownload = jest.fn(); + const onCancel = jest.fn(); + makeTableCsvExporterWrapper({ model, selection, onDownload, onCancel }); + await pickSelectedRowsThenDownload(user); + + await waitFor(() => expect(onCancel).toHaveBeenCalled()); + expect(onDownload).not.toHaveBeenCalled(); + expect(stagingTable.close).toHaveBeenCalled(); +}); + +it('cancels and closes the staging table when createFilteredByKeysTable rejects', async () => { + const user = userEvent.setup(); + const createFilteredByKeysTable = jest + .fn() + .mockRejectedValue(new Error('filter failed')); + const model = makeKeyedModel({ createFilteredByKeysTable }); + + const selection = new KeyedSelection({ + getModel: getKeyedModel, + selectedKeys: new Set(['[1]']), + selectedKeyValues: new Map([['[1]', [1]]]), + }); + + const onDownload = jest.fn(); + const onCancel = jest.fn(); + makeTableCsvExporterWrapper({ model, selection, onDownload, onCancel }); + await pickSelectedRowsThenDownload(user); + + await waitFor(() => expect(onCancel).toHaveBeenCalled()); + expect(onDownload).not.toHaveBeenCalled(); + expect(createFilteredByKeysTable).toHaveBeenCalled(); +}); diff --git a/packages/iris-grid/src/sidebar/TableCsvExporter.tsx b/packages/iris-grid/src/sidebar/TableCsvExporter.tsx index 2ef196d0ac..0d26057931 100644 --- a/packages/iris-grid/src/sidebar/TableCsvExporter.tsx +++ b/packages/iris-grid/src/sidebar/TableCsvExporter.tsx @@ -15,8 +15,10 @@ import { import { GridRange, GridUtils, + isRangedSelection, type ModelSizeMap, type MoveOperation, + type Selection, } from '@deephaven/grid'; import { vsWarning } from '@deephaven/icons'; import type { dh as DhType } from '@deephaven/jsapi-types'; @@ -26,6 +28,8 @@ import './TableCsvExporter.scss'; import Log from '@deephaven/log'; import type IrisGridModel from '../IrisGridModel'; import IrisGridUtils from '../IrisGridUtils'; +import { KeyedSelection } from '../KeyedSelection'; +import { isKeyedGridModel } from '../KeyedGridModel'; const log = Log.module('TableCsvExporter'); interface TableCsvExporterProps { @@ -48,7 +52,7 @@ interface TableCsvExporterProps { useUnformattedValues: boolean ) => void; onCancel: () => void; - selectedRanges: readonly GridRange[]; + selection: Selection | null; } interface TableCsvExporterState { @@ -97,9 +101,13 @@ class TableCsvExporter extends Component< tableDownloadStatus: '', tableDownloadProgress: 0, tableDownloadEstimatedTime: null, - selectedRanges: [], + selection: null, }; + static formatRowCount(count: number): string { + return count.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); + } + static getDateString(dh: typeof DhType): string { return dh.i18n.DateTimeFormat.format( TableCsvExporter.FILENAME_DATE_FORMAT, @@ -142,32 +150,67 @@ class TableCsvExporter extends Component< }; } - getSnapshotRanges(): GridRange[] { - const { model, selectedRanges } = this.props; + /** Returns the exact selected row count when known, or null for unknown-size keyed selections. */ + get selectedRowCount(): number | null { + const { selection } = this.props; + if ( + selection != null && + isRangedSelection(selection) && + !selection.isEmpty() + ) { + return GridRange.rowCount(selection.toRanges()); + } + if (selection instanceof KeyedSelection) { + return selection.getUniqueRowCount(); + } + return null; + } + + getSnapshotRanges(keyedTableSize: number): GridRange[] { + const { model, selection } = this.props; const { downloadRowOption, customizedDownloadRowOption, customizedDownloadRows, } = this.state; const { rowCount, columnCount } = model; - let snapshotRanges = [] as GridRange[]; + const snapshotRanges: GridRange[] = []; switch (downloadRowOption) { case TableCsvExporter.DOWNLOAD_ROW_OPTIONS.ALL_ROWS: snapshotRanges.push(new GridRange(0, 0, columnCount - 1, rowCount - 1)); break; case TableCsvExporter.DOWNLOAD_ROW_OPTIONS.SELECTED_ROWS: - snapshotRanges = selectedRanges - .map(range => ({ - ...range, - startColumn: 0, - endColumn: columnCount - 1, - })) - .sort((rangeA, rangeB) => { - if (rangeA.startRow != null && rangeB.startRow != null) { - return rangeA.startRow - rangeB.startRow; - } - return 0; - }) as GridRange[]; + if (selection != null && isRangedSelection(selection)) { + snapshotRanges.push( + ...selection + .toRanges() + .map( + range => + new GridRange( + 0, + range.startRow, + columnCount - 1, + range.endRow + ) + ) + .sort((a, b) => { + if (a.startRow != null && b.startRow != null) { + return a.startRow - b.startRow; + } + return 0; + }) + ); + } else if (selection instanceof KeyedSelection) { + // keyed: the frozenTable is already filtered; snapshot all its rows. + // Guard against zero — a ticking table can remove all selected rows before export. + if (keyedTableSize > 0) { + snapshotRanges.push( + new GridRange(0, 0, columnCount - 1, keyedTableSize - 1) + ); + } + } else { + throw new Error('Unsupported selection type for snapshot ranges.'); + } break; case TableCsvExporter.DOWNLOAD_ROW_OPTIONS.CUSTOMIZED_ROWS: switch (customizedDownloadRowOption) { @@ -221,9 +264,20 @@ class TableCsvExporter extends Component< } async handleDownloadClick(): Promise { - const { model, isDownloading, onDownloadStart, onDownload, onCancel } = - this.props; - const { fileName, includeColumnHeaders, useUnformattedValues } = this.state; + const { + model, + selection, + isDownloading, + onDownloadStart, + onDownload, + onCancel, + } = this.props; + const { + fileName, + includeColumnHeaders, + useUnformattedValues, + downloadRowOption, + } = this.state; if (isDownloading) { onCancel(); @@ -231,35 +285,65 @@ class TableCsvExporter extends Component< } this.resetDownloadState(); + if (!this.validateOptionInput()) return; - const snapshotRanges = this.getSnapshotRanges(); - const modelRanges = this.getModelRanges(snapshotRanges); - if (this.validateOptionInput()) { - onDownloadStart(); - try { - const frozenTable = await model.export(); - const tableSubscription = frozenTable.setViewport(0, 0); - await tableSubscription.getViewportData(); - onDownload( - fileName, - frozenTable, - tableSubscription, - snapshotRanges, - modelRanges, - includeColumnHeaders, - useUnformattedValues + const isKeyedSelectedRows = + downloadRowOption === + TableCsvExporter.DOWNLOAD_ROW_OPTIONS.SELECTED_ROWS && + selection instanceof KeyedSelection && + isKeyedGridModel(model); + + onDownloadStart(); + let filteredTable: DhType.Table | null = null; + let frozenTable: DhType.Table | null = null; + let handedOff = false; + try { + if (isKeyedSelectedRows) { + filteredTable = await model.createFilteredByKeysTable( + selection.selectedKeyValues, + selection.invertedSelection ); - } catch (error) { - log.error('CSV download failed', error); - - this.setState({ - errorMessage: ( -

- {`${error}`} -

- ), - }); + // freeze to static snapshot; TableSaver closes frozenTable in finishDownload/cancelDownload + frozenTable = await filteredTable.freeze(); + } else { + frozenTable = await model.export(); + } + const snapshotRanges = this.getSnapshotRanges(frozenTable.size); + if (snapshotRanges.length === 0) { + // All selected rows were removed from the ticking table before export. onCancel(); + return; + } + const modelRanges = this.getModelRanges(snapshotRanges); + const tableSubscription = frozenTable.setViewport(0, 0); + await tableSubscription.getViewportData(); + onDownload( + fileName, + frozenTable, + tableSubscription, + snapshotRanges, + modelRanges, + includeColumnHeaders, + useUnformattedValues + ); + handedOff = true; + } catch (error) { + log.error('CSV download failed', error); + this.setState({ + errorMessage: ( +

+ {`${error}`} +

+ ), + }); + onCancel(); + } finally { + // filteredTable is only a staging table used to produce frozenTable; always close it. + filteredTable?.close(); + // frozenTable is owned by TableSaver only after onDownload runs; close on early + // return or any throw before handoff. + if (!handedOff) { + frozenTable?.close(); } } } @@ -297,13 +381,13 @@ class TableCsvExporter extends Component< } validateOptionInput(): boolean { - const { selectedRanges } = this.props; + const { selection } = this.props; const { downloadRowOption, customizedDownloadRows } = this.state; if ( downloadRowOption === TableCsvExporter.DOWNLOAD_ROW_OPTIONS.SELECTED_ROWS && - selectedRanges.length === 0 + (selection == null || selection.isEmpty()) ) { this.setState({ errorMessage: ( @@ -340,7 +424,6 @@ class TableCsvExporter extends Component< isDownloading, tableDownloadProgress, tableDownloadEstimatedTime, - selectedRanges, tableDownloadStatus, } = this.props; const { @@ -373,9 +456,7 @@ class TableCsvExporter extends Component< > All Rows - {`(${rowCount - .toString() - .replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,')} rows)`} + {`(${TableCsvExporter.formatRowCount(rowCount)} rows)`} Only Selected Rows - {selectedRanges.length > 0 - ? `(${GridRange.rowCount(selectedRanges) - .toString() - .replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,')} rows)` + {this.selectedRowCount != null + ? `(${TableCsvExporter.formatRowCount( + this.selectedRowCount + )} rows)` : null} diff --git a/packages/plugin/src/TablePlugin.ts b/packages/plugin/src/TablePlugin.ts index af7882155b..4568ceddd6 100644 --- a/packages/plugin/src/TablePlugin.ts +++ b/packages/plugin/src/TablePlugin.ts @@ -5,7 +5,7 @@ import type { IrisGridContextMenuData, IrisGridTableModelTemplate, } from '@deephaven/iris-grid'; -import { type GridRange } from '@deephaven/grid'; +import { type GridRange, type Selection } from '@deephaven/grid'; import type { ResolvableContextAction } from '@deephaven/components'; import type { dh } from '@deephaven/jsapi-types'; @@ -43,9 +43,15 @@ export interface TablePluginProps { /** * The currently selected ranges in the table. + * @deprecated Use `selection` instead. */ selectedRanges: readonly GridRange[] | undefined; + /** + * The current grid selection, including keyed selections for tables with key columns. + */ + selection?: Selection | null; + /** * Notify of a state change in the plugin state. Will be saved with the panel data. * Should be an object that can be serialized to JSON. diff --git a/tests/docker-scripts/data/app.d/common_tables.py b/tests/docker-scripts/data/app.d/common_tables.py index 6802386515..b930952005 100644 --- a/tests/docker-scripts/data/app.d/common_tables.py +++ b/tests/docker-scripts/data/app.d/common_tables.py @@ -6,6 +6,9 @@ simple_table = empty_table(100).update(["x=i", "y=Math.sin(i)", "z=Math.cos(i)"]) +# 5 unique Key values (0-4), each repeated 4 times across 20 rows +keyed_table = empty_table(20).update(["Key=i%5", "Value=i"]).with_attributes({"keyColumns": "Key"}) + column_groups = [ {"name": "YandZ", "children": ["y", "z"]}, {"name": "All", "children": ["x", "YandZ"], "color": "white"}, diff --git a/tests/table.spec.ts b/tests/table.spec.ts index 518aa87f6a..e11d01c549 100644 --- a/tests/table.spec.ts +++ b/tests/table.spec.ts @@ -50,6 +50,90 @@ test('can make a non-contiguous table row selection', async ({ page }) => { await expect(page.locator('.iris-grid-panel .iris-grid')).toHaveScreenshot(); }); +test('clicking a row in a keyed table selects all rows with the same key', async ({ + page, +}) => { + await gotoPage(page, ''); + await openTable(page, 'keyed_table'); + + const grid = page.locator('.iris-grid-panel .iris-grid'); + const gridLocation = await grid.boundingBox(); + expect(gridLocation).not.toBeNull(); + if (gridLocation === null) return; + + const rowHeight = 19; + const columnHeaderHeight = 30; + + // Click row 0 (Key=0). Rows 0, 5, 10, 15 all share Key=0 and should be selected. + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeaderHeight + 0.5 * rowHeight + ); + + await expect(page.locator('.iris-grid-panel .iris-grid')).toHaveScreenshot(); +}); + +test('ctrl+clicking rows in a keyed table selects multiple key groups', async ({ + page, +}) => { + await gotoPage(page, ''); + await openTable(page, 'keyed_table'); + + const grid = page.locator('.iris-grid-panel .iris-grid'); + const gridLocation = await grid.boundingBox(); + expect(gridLocation).not.toBeNull(); + if (gridLocation === null) return; + + const rowHeight = 19; + const columnHeaderHeight = 30; + + // Click row 0 (Key=0), then ctrl+click row 1 (Key=1). + // All rows for Key=0 and Key=1 should be selected. + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeaderHeight + 0.5 * rowHeight + ); + await page.keyboard.down('Control'); + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeaderHeight + 1.5 * rowHeight + ); + await page.keyboard.up('Control'); + + await expect(page.locator('.iris-grid-panel .iris-grid')).toHaveScreenshot(); +}); + +test('shift+clicking rows in a keyed table selects the range of keys', async ({ + page, +}) => { + await gotoPage(page, ''); + await openTable(page, 'keyed_table'); + + const grid = page.locator('.iris-grid-panel .iris-grid'); + const gridLocation = await grid.boundingBox(); + expect(gridLocation).not.toBeNull(); + if (gridLocation === null) return; + + const rowHeight = 19; + const columnHeaderHeight = 30; + + // Click row 0 (Key=0), then shift+click row 2 (Key=2). + // The shift-click extend picks up keys 0, 1, 2, so every row with those + // keys should be highlighted (rows 0,1,2,5,6,7,10,11,12,15,16,17). + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeaderHeight + 0.5 * rowHeight + ); + await page.keyboard.down('Shift'); + await page.mouse.click( + gridLocation.x + 1, + gridLocation.y + 1 + columnHeaderHeight + 2.5 * rowHeight + ); + await page.keyboard.up('Shift'); + + await expect(page.locator('.iris-grid-panel .iris-grid')).toHaveScreenshot(); +}); + test('can open a table with column header groups', async ({ page }) => { await gotoPage(page, ''); await openTable(page, 'simple_table_header_group'); diff --git a/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-chromium-linux.png b/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-chromium-linux.png new file mode 100644 index 0000000000..13347cce31 Binary files /dev/null and b/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-chromium-linux.png differ diff --git a/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-firefox-linux.png b/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-firefox-linux.png new file mode 100644 index 0000000000..c46c8d7a0c Binary files /dev/null and b/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-firefox-linux.png differ diff --git a/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-webkit-linux.png b/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-webkit-linux.png new file mode 100644 index 0000000000..6804e33bbd Binary files /dev/null and b/tests/table.spec.ts-snapshots/clicking-a-row-in-a-keyed-table-selects-all-rows-with-the-same-key-1-webkit-linux.png differ diff --git a/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-chromium-linux.png b/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-chromium-linux.png new file mode 100644 index 0000000000..cf5f8806ee Binary files /dev/null and b/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-chromium-linux.png differ diff --git a/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-firefox-linux.png b/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-firefox-linux.png new file mode 100644 index 0000000000..15cd1609a8 Binary files /dev/null and b/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-firefox-linux.png differ diff --git a/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-webkit-linux.png b/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-webkit-linux.png new file mode 100644 index 0000000000..3829c3c442 Binary files /dev/null and b/tests/table.spec.ts-snapshots/ctrl-clicking-rows-in-a-keyed-table-selects-multiple-key-groups-1-webkit-linux.png differ diff --git a/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-chromium-linux.png b/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-chromium-linux.png new file mode 100644 index 0000000000..22991e22e2 Binary files /dev/null and b/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-chromium-linux.png differ diff --git a/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-firefox-linux.png b/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-firefox-linux.png new file mode 100644 index 0000000000..3bf3e8fbbf Binary files /dev/null and b/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-firefox-linux.png differ diff --git a/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-webkit-linux.png b/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-webkit-linux.png new file mode 100644 index 0000000000..991868da5b Binary files /dev/null and b/tests/table.spec.ts-snapshots/shift-clicking-rows-in-a-keyed-table-selects-the-range-of-keys-1-webkit-linux.png differ diff --git a/tests/utils.ts b/tests/utils.ts index 19cdf9fe11..3990e5a7ba 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -22,6 +22,7 @@ type TableNames = | 'simple_table' | 'simple_table_header_group' | 'simple_table_header_group_hide' + | 'keyed_table' | 'double_and_string' | 'ordered_int_and_offset' | 'trig_table'