Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions plugins/ui/docs/components/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ t_top = ui.table(

## Events

You can listen for different user events on a `ui.table`. There is both a `press` and `double_press` event for `row`, `cell`, and `column`. These events typically correspond to a click or double click on the table. The event payloads include table data related to the event. For `row` and `column` events, the corresponding data within the viewport will be sent to the event handler. The viewport is typically the visible area ± a window equal to the visible area (e.g., if rows 5-10 are visible, rows 0-15 will be in the viewport).
### Press Events

You can listen for different user press events on a `ui.table`. There is both a `press` and `double_press` event for `row`, `cell`, and `column`. These events typically correspond to a click or double click on the table. The event payloads include table data related to the event. For `row` and `column` events, the corresponding data within the viewport will be sent to the event handler. The viewport is typically the visible area ± a window equal to the visible area (e.g., if rows 5-10 are visible, rows 0-15 will be in the viewport).

Note that there is no row index in event data because the row index is not a safe way to reference a row between the client and server since the user could have manipulated the table, resulting in a different client order.

Expand All @@ -223,6 +225,27 @@ t = ui.table(
)
```

### Selection Event

The `on_selection_change` event is triggered when the user selects or deselects a row. The event data will contain all selected rows within the viewport as a list of dictionaries keyed by column name. There are a few caveats to the selection event.

1. The event will **only** send data from columns in the `always_fetch_columns` prop.
2. The event will **only** send data from rows that are visible in the viewport.
3. The event will **not** be triggered if a ticking table row is replaced or shifted. This may cause what the user sees after row shifts to differ from the selection event data.

With these caveats in mind, it is highly recommended that the `on_selection_change` event be used only with static tables. It is also recommended to only use this event for relatively small actions where you can see all selected rows at once.

```python
from deephaven import ui
import deephaven.plot.express as dx

t = ui.table(
dx.data.stocks(),
on_selection_change=lambda data: print(f"Selection: {data}"),
always_fetch_columns=["Sym", "Exchange"],
)
```

## Context menu

Items can be added to the bottom of the `ui.table` context menu (right-click menu) by using the `context_menu` or `context_header_menu` props. The `context_menu` prop adds items to the cell context menu, while the `context_header_menu` prop adds items to the column header context menu. You can pass either a single dictionary for a single item or a list of dictionaries for multiple items.
Expand Down Expand Up @@ -391,7 +414,7 @@ Deephaven only fetches data for visible rows and columns within a window around

The `always_fetch_columns` prop takes a single column name, a list of column names, or a boolean to always fetch all columns. The data for these columns is included in row event data (e.g. `on_row_press`) and context menu callbacks.

> [!WARNING]
> [!WARNING]
> Setting `always_fetch_columns` to `True` will fetch all columns and can be slow for tables with many columns.

This example shows how to use `always_fetch_columns` to always fetch the `Sym` column for a row press event. Without the `always_fetch_columns` prop, the press callback will fail because the `Sym` column is not fetched when hidden.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"file":"components/table.md","objects":{"t":{"type":"deephaven.ui.Element","data":{"document":{"__dhElemName":"deephaven.ui.elements.UITable","props":{"table":{"__dhObid":0},"onSelectionChange":{"__dhCbid":"cb0"},"alwaysFetchColumns":["Sym","Exchange"],"showQuickFilters":false,"showGroupingColumn":true,"showSearch":false,"reverse":false}},"state":"{}"}}}}
9 changes: 9 additions & 0 deletions plugins/ui/src/deephaven/ui/components/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
QuickFilterExpression,
RowPressCallback,
ResolvableContextMenuItem,
SelectionChangeCallback,
)
from .._internal import dict_to_react_props, RenderContext

Expand Down Expand Up @@ -144,6 +145,8 @@ class table(Element):
The callback is invoked with the column name.
on_column_double_press: The callback function to run when a column is double clicked.
The callback is invoked with the column name.
on_selection_change: The callback function to run when the selection changes.
The callback is invoked with the selected rows with data from the columns in `always_fetch_columns`.
always_fetch_columns: The columns to always fetch from the server regardless of if they are in the viewport.
If True, all columns will always be fetched. This may make tables with many columns slow.
quick_filters: The quick filters to apply to the table. Dictionary of column name to filter value.
Expand Down Expand Up @@ -230,6 +233,7 @@ def __init__(
on_cell_double_press: CellPressCallback | None = None,
on_column_press: ColumnPressCallback | None = None,
on_column_double_press: ColumnPressCallback | None = None,
on_selection_change: SelectionChangeCallback | None = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should throw if on_selection_change is set and always_fetch_columns is not set. Right now it just returns empty data if you don't specify always_fetch_columns. Though I guess in theory that would still tell you want the size of the selection is, and maybe that's all the want to know?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya I added it to the Python side. Throws immediately if not wrapped in a component. If in a component, it will propagate to the client as a render error so the user can see something in that case still.

Did spot a weird case where I ran working code followed by code that threw and the panel just disappeared. I'll investigate a bit more and file a ticket

always_fetch_columns: ColumnName | list[ColumnName] | bool | None = None,
quick_filters: dict[ColumnName, QuickFilterExpression] | None = None,
show_quick_filters: bool = False,
Expand Down Expand Up @@ -289,6 +293,11 @@ def __init__(
right: DimensionValue | None = None,
z_index: int | None = None,
) -> None:
if on_selection_change is not None and always_fetch_columns is None:
raise ValueError(
"ui.table on_selection_change requires always_fetch_columns to be set"
)

props = locals()
del props["self"]
self._props = props
Expand Down
1 change: 1 addition & 0 deletions plugins/ui/src/deephaven/ui/types/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ class SliderChange(TypedDict):
RowPressCallback = Callable[[RowDataMap], None]
CellPressCallback = Callable[[CellData], None]
ColumnPressCallback = Callable[[ColumnName], None]
SelectionChangeCallback = Callable[[List[RowDataMap]], None]
AggregationOperation = Literal[
"COUNT",
"COUNT_DISTINCT",
Expand Down
52 changes: 38 additions & 14 deletions plugins/ui/src/js/src/elements/UITable/UITable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ import { useApi } from '@deephaven/jsapi-bootstrap';
import type { dh as DhType } from '@deephaven/jsapi-types';
import Log from '@deephaven/log';
import { getSettings, RootState } from '@deephaven/redux';
import { GridMouseHandler, GridState } from '@deephaven/grid';
import { GridMouseHandler, GridRange, GridState } from '@deephaven/grid';
import { EMPTY_ARRAY, ensureArray } from '@deephaven/utils';
import { useDebouncedCallback } from '@deephaven/react-hooks';
import { usePersistentState } from '@deephaven/plugin';
import {
DatabarConfig,
FormattingRule,
getAggregationOperation,
getSelectionDataMap,
UITableProps,
} from './UITableUtils';
import UITableMouseHandler from './UITableMouseHandler';
Expand Down Expand Up @@ -159,6 +161,7 @@ export function UITable({
onColumnDoublePress,
onRowPress,
onRowDoublePress,
onSelectionChange,
quickFilters,
sorts,
aggregations,
Expand Down Expand Up @@ -374,13 +377,8 @@ export function UITable({
return alwaysFetch;
}, [format, columns]);

const alwaysFetchColumnsArray = useMemo(
() => [...ensureArray(alwaysFetchColumnsProp), ...formatColumnSources],
[alwaysFetchColumnsProp, formatColumnSources]
);

const alwaysFetchColumns = useMemo(() => {
if (alwaysFetchColumnsArray[0] === true) {
const alwaysFetchColumnsPropArray = useMemo(() => {
if (alwaysFetchColumnsProp === true) {
if (columns.length > ALWAYS_FETCH_COLUMN_LIMIT) {
throwError(
`Table has ${columns.length} columns, which is too many to always fetch. ` +
Expand All @@ -391,14 +389,16 @@ export function UITable({
}
return columns.map(column => column.name);
}
if (alwaysFetchColumnsArray[0] === false) {
if (alwaysFetchColumnsProp === false || alwaysFetchColumnsProp == null) {
return [];
}
return alwaysFetchColumnsArray.filter(
// This v is string can be removed when we're on a newer TS version. 5.7 infers this properly at least
(v): v is string => typeof v === 'string'
);
}, [alwaysFetchColumnsArray, columns, throwError]);
return [...ensureArray(alwaysFetchColumnsProp)];
}, [alwaysFetchColumnsProp, columns, throwError]);

const alwaysFetchColumns = useMemo(
() => [...alwaysFetchColumnsPropArray, ...formatColumnSources],
[alwaysFetchColumnsPropArray, formatColumnSources]
);

const mouseHandlers = useMemo(
() =>
Expand Down Expand Up @@ -512,6 +512,29 @@ export function UITable({

const initialIrisGridServerProps = useRef(irisGridServerProps);

const handleSelectionChanged = useCallback(
async (ranges: readonly GridRange[]) => {
if (model == null || irisGrid == null || onSelectionChange == null) {
return;
}

const selected = getSelectionDataMap(
ranges,
model,
irisGrid,
alwaysFetchColumnsPropArray
);

onSelectionChange(selected);
},
[irisGrid, model, onSelectionChange, alwaysFetchColumnsPropArray]
);

const debouncedHandleSelectionChanged = useDebouncedCallback(
handleSelectionChanged,
250
);

/**
* We want to set the props based on a combination of server state and client state.
* If the server state is the same as its initial state, then we are rehydrating and
Expand Down Expand Up @@ -561,6 +584,7 @@ export function UITable({
ref={ref => setIrisGrid(ref)}
model={model}
onStateChange={onStateChange}
onSelectionChanged={debouncedHandleSelectionChanged}
// eslint-disable-next-line react/jsx-props-no-spreading
{...mergedIrisGridProps}
inputFilters={inputFilters}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ import {
import type { dh as DhType } from '@deephaven/jsapi-types';
import { type ColumnName } from '@deephaven/jsapi-utils';
import { ensureArray } from '@deephaven/utils';
import { RowDataMap, type UITableProps } from './UITableUtils';
import { getRowDataMap, RowDataMap, type UITableProps } from './UITableUtils';
import { getIcon } from '../utils/IconElementUtils';
import { ELEMENT_PREFIX, ElementPrefix } from '../model/ElementConstants';
import { getRowDataMap } from './UITableMouseHandler';

interface UIContextItemParams {
value: unknown;
Expand Down
50 changes: 1 addition & 49 deletions plugins/ui/src/js/src/elements/UITable/UITableMouseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,9 @@ import {
EventHandlerResult,
GridMouseHandler,
GridPoint,
isExpandableGridModel,
type ModelIndex,
} from '@deephaven/grid';
import { IrisGridModel, type IrisGridType } from '@deephaven/iris-grid';
import { CellData, RowDataMap, UITableProps } from './UITableUtils';

function getCellData(
columnIndex: ModelIndex,
rowIndex: ModelIndex,
model: IrisGridModel
): CellData {
const column = model.columns[columnIndex];
const { type } = column;
const value = model.valueForCell(columnIndex, rowIndex);
const text = model.textForCell(columnIndex, rowIndex);
return {
value,
text,
type,
};
}

/**
* Get the data map for the given row
* @param rowIndex Row to get the data map for
* @returns Data map for the row
*/
export function getRowDataMap(
rowIndex: ModelIndex,
model: IrisGridModel
): RowDataMap {
const { columns, groupedColumns } = model;
const dataMap: RowDataMap = {};
for (let i = 0; i < columns.length; i += 1) {
const column = columns[i];
const { name } = column;
const isExpandable =
isExpandableGridModel(model) && model.isRowExpandable(rowIndex);
const isGrouped = groupedColumns.find(c => c.name === name) != null;
const cellData = getCellData(i, rowIndex, model);
// If the cellData.value is undefined, that means we don't have any data for that column (i.e. the column is not visible), don't send it back
if (cellData.value !== undefined) {
dataMap[name] = {
...cellData,
isGrouped,
isExpandable,
};
}
}
return dataMap;
}
import { getCellData, getRowDataMap, UITableProps } from './UITableUtils';

/**
* Mouse handler for UITable. Will call the appropriate callbacks when a cell, row, or column is clicked or double clicked with the data structure expected.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ import {
type ColumnName,
type DehydratedSort,
AggregationOperation,
type IrisGridModel,
IrisGridType,
} from '@deephaven/iris-grid';
import {
BoundedGridRange,
GridRange,
isExpandableGridModel,
type ModelIndex,
} from '@deephaven/grid';
import { assertNotNull } from '@deephaven/utils';
import {
ELEMENT_KEY,
type ElementNode,
Expand Down Expand Up @@ -71,6 +80,7 @@ export type UITableProps = StyleProps & {
onRowDoublePress?: (rowData: RowDataMap) => void;
onColumnPress?: (columnName: ColumnName) => void;
onColumnDoublePress?: (columnName: ColumnName) => void;
onSelectionChange?: (selectedRows: RowDataMap[]) => void;
alwaysFetchColumns?: string | string[] | boolean;
quickFilters?: Record<string, string>;
sorts?: DehydratedSort[];
Expand Down Expand Up @@ -127,3 +137,103 @@ export function getAggregationOperation(agg: string): AggregationOperation {

return operation;
}

export function getCellData(
columnIndex: ModelIndex,
rowIndex: ModelIndex,
model: IrisGridModel
): CellData {
const column = model.columns[columnIndex];
const { type } = column;
const value = model.valueForCell(columnIndex, rowIndex);
const text = model.textForCell(columnIndex, rowIndex);
return {
value,
text,
type,
};
}

/**
* Get the data map for the given row
* @param rowIndex Row to get the data map for
* @param model The IrisGridModel to get the data from
* @param columnNames Optional array of column names to filter the data map.
* If not provided, all columns in the viewport will be included.
* @returns Data map for the row
*/
export function getRowDataMap(
rowIndex: ModelIndex,
model: IrisGridModel,
columnNames?: string[]
): RowDataMap {
const { columns, groupedColumns } = model;
const columnNamesSet = new Set(columnNames);
const dataMap: RowDataMap = {};
for (let i = 0; i < columns.length; i += 1) {
const column = columns[i];
const { name } = column;
if (columnNames == null || columnNamesSet.has(name)) {
const isExpandable =
isExpandableGridModel(model) && model.isRowExpandable(rowIndex);
const isGrouped = groupedColumns.find(c => c.name === name) != null;
const cellData = getCellData(i, rowIndex, model);
// If the cellData.value is undefined, that means we don't have any data for that column (i.e. the column is not visible), don't send it back
if (cellData.value !== undefined) {
dataMap[name] = {
...cellData,
isGrouped,
isExpandable,
};
}
}
}

return dataMap;
}

export function getSelectionDataMap(
ranges: readonly GridRange[],
model: IrisGridModel,
irisGrid: IrisGridType,
columnNames?: string[]
): RowDataMap[] {
const dataMaps: RowDataMap[] = [];

const boundedSortedRanges = (
GridRange.boundedRanges(
GridRange.consolidate(ranges),
model.columnCount,
model.rowCount
) as BoundedGridRange[]
).sort((a, b) => a.startRow - b.startRow); // Ensure we're in ascending order by row

const { metrics } = irisGrid.state;
assertNotNull(metrics);
const { top, bottomViewport } = metrics;

for (let i = 0; i < boundedSortedRanges.length; i += 1) {
const visibleRange = GridRange.intersection(
boundedSortedRanges[i],
GridRange.makeNormalized(null, top, null, bottomViewport)
) as BoundedGridRange;

if (visibleRange == null) {
// eslint-disable-next-line no-continue
continue;
}

for (
let row = visibleRange.startRow;
row <= visibleRange.endRow;
row += 1
) {
const modelRow = irisGrid.getModelRow(row);
if (modelRow != null) {
const rowDataMap = getRowDataMap(modelRow, model, columnNames);
dataMaps.push(rowDataMap);
}
}
}
return dataMaps;
}
Loading
Loading