Skip to content
Open
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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 27 additions & 4 deletions plugins/ui/src/deephaven/ui/components/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,21 @@ class table(Element):
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.
sorts: The sorts to apply to the table.
These are UI-controlled sorts (similar to reverse) rather than engine-transformed table data.
User changes to the sort state are persisted and restored on reload.
quick_filters: The initial quick filters to apply to the table. User changes
are retained and persisted when the table is reloaded. Dictionary of
column name to filter value.
controlled_quick_filters: The quick filters to update programmatically.
Whenever this value changes, it is re-applied to the table and replaces
any quick filters the user changed in the UI. Cannot be used with
`quick_filters`. Dictionary of column name to filter value.
sorts: The initial sorts to apply to the table. User changes are retained and
persisted when the table is reloaded. These are UI-controlled sorts
(similar to reverse) rather than engine-transformed table data.
Accepts a column name, TableSort, or list containing column names and TableSort instances.
controlled_sorts: The sorts to update programmatically. Whenever this value
changes, it is re-applied to the table and replaces any sorts the user
changed in the UI. Cannot be used with `sorts`. Accepts the same values
as `sorts`.
show_quick_filters: Whether to show the quick filter bar by default.
aggregations: An aggregation or list of aggregations to apply to the table. These will be shown as a floating row at the bottom of the table by default.
aggregations_position: The position to show the aggregations. One of "top" or "bottom". "bottom" by default.
Expand Down Expand Up @@ -376,7 +386,9 @@ def __init__(
on_selection_change: SelectionChangeCallback | None = None,
always_fetch_columns: ColumnName | list[ColumnName] | bool | None = None,
quick_filters: dict[ColumnName, QuickFilterExpression] | None = None,
controlled_quick_filters: dict[ColumnName, QuickFilterExpression] | None = None,
sorts: TableSortLike | list[TableSortLike] | None = None,
controlled_sorts: TableSortLike | list[TableSortLike] | None = None,
show_quick_filters: bool = False,
aggregations: TableAgg | list[TableAgg] | None = None,
aggregations_position: Literal["top", "bottom"] | None = None,
Expand Down Expand Up @@ -444,9 +456,20 @@ def __init__(
if format_ is not None:
_validate_table_format(format_, table)

if quick_filters is not None and controlled_quick_filters is not None:
raise ValueError(
"ui.table quick_filters and controlled_quick_filters cannot both be set"
)

if sorts is not None and controlled_sorts is not None:
raise ValueError("ui.table sorts and controlled_sorts cannot both be set")

if sorts is not None:
props["sorts"] = _normalize_table_sorts(sorts)

if controlled_sorts is not None:
props["controlled_sorts"] = _normalize_table_sorts(controlled_sorts)

props["table"] = resolve(table) if isinstance(table, str) else table
del props["self"]
self._props = props
Expand Down
124 changes: 84 additions & 40 deletions plugins/ui/src/js/src/elements/UITable/UITable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,32 @@ function useUITableModel({
return model;
}

/**
* Hydrate the `quick_filters` dict from the server into the quick filter map
* IrisGrid expects. Returns undefined if the filters or grid are not ready.
*/
function hydrateUITableQuickFilters(
quickFilters: Record<string, string> | undefined,
model: UITableModel | undefined,
columns: readonly DhType.Column[],
utils: IrisGridUtils | null
): ReturnType<IrisGridUtils['hydrateQuickFilters']> | undefined {
if (quickFilters === undefined || utils == null || model == null) {
return undefined;
}
log.debug('Hydrating filters', quickFilters);

const dehydratedQuickFilters: DehydratedQuickFilter[] = [];
Object.entries(quickFilters).forEach(([columnName, filter]) => {
const columnIndex = model.getColumnIndexByName(columnName);
if (columnIndex !== undefined) {
dehydratedQuickFilters.push([columnIndex, { text: filter }]);
}
});

return utils.hydrateQuickFilters(columns, dehydratedQuickFilters);
}

export function UITable({
format_: formatProp = EMPTY_ARRAY as unknown as FormattingRule[],
onCellPress,
Expand All @@ -176,7 +202,9 @@ export function UITable({
onRowDoublePress,
onSelectionChange,
quickFilters,
controlledQuickFilters,
sorts,
controlledSorts,
aggregations,
aggregationsPosition = 'bottom',
alwaysFetchColumns: alwaysFetchColumnsProp,
Expand Down Expand Up @@ -379,17 +407,37 @@ export function UITable({
[memoizedStateFn, model, setDehydratedState]
);

// Initial sorts are captured once at mount so later re-renders never push
// a new `sorts` reference into IrisGrid (which would call updateSorts and
// clobber the user's interactive sort changes).
// Capture the user-owned `sorts`/`quickFilters` once on mount. These provide
// the initial values only when there is no persisted client state; after
// that, the user's own changes take over.
const initialSortsRef = useRef(sorts);
const initialQuickFiltersRef = useRef(quickFilters);

// The controlled values are live IrisGrid props. They are deliberately named
// separately so changing the existing user-owned props remains non-breaking.
const hydratedControlledSorts = useMemo(() => {
if (
controlledSorts === undefined ||
utils == null ||
columns.length === 0
) {
return undefined;
}
log.debug('Hydrating controlled sorts', controlledSorts);
return utils.hydrateSort(columns, controlledSorts);
}, [controlledSorts, utils, columns]);

// Lock the initial hydrated state to a stable value the first time model+utils
// are available. Recomputing it would change the `sorts` (and other) prop
// identities and cause IrisGrid to overwrite user changes on every re-render.
const lockedInitialHydratedStateRef = useRef<
Partial<IrisGridProps> | undefined
>(undefined);
const hydratedControlledQuickFilters = useMemo(
() =>
hydrateUITableQuickFilters(controlledQuickFilters, model, columns, utils),
[controlledQuickFilters, model, columns, utils]
);

// Lock the initial state once the model is ready. Recomputing it would pass
// new prop identities into IrisGrid and overwrite interactive changes.
const initialHydratedStateRef = useRef<Partial<IrisGridProps> | undefined>(
undefined
);
const initialHydratedStateComputedRef = useRef(false);
if (
!initialHydratedStateComputedRef.current &&
Expand All @@ -405,40 +453,34 @@ export function UITable({
}
: undefined;
const initialSorts = initialSortsRef.current;
const seededSorts =
persisted == null && initialSorts !== undefined && columns !== undefined
const initialQuickFilters = initialQuickFiltersRef.current;
const hydratedInitialSorts =
initialSorts !== undefined && columns.length > 0
? utils.hydrateSort(columns, initialSorts)
: undefined;
const hydratedInitialQuickFilters = hydrateUITableQuickFilters(
initialQuickFilters,
model,
columns,
utils
);
if (persisted != null) {
lockedInitialHydratedStateRef.current = persisted;
} else if (seededSorts !== undefined) {
lockedInitialHydratedStateRef.current = { sorts: seededSorts };
}
}
const initialHydratedState = lockedInitialHydratedStateRef.current;

const hydratedQuickFilters = useMemo(() => {
if (
quickFilters !== undefined &&
utils &&
model !== undefined &&
columns !== undefined
initialHydratedStateRef.current = persisted;
} else if (
hydratedInitialSorts !== undefined ||
hydratedInitialQuickFilters !== undefined
) {
log.debug('Hydrating filters', quickFilters);

const dehydratedQuickFilters: DehydratedQuickFilter[] = [];

Object.entries(quickFilters).forEach(([columnName, filter]) => {
const columnIndex = model.getColumnIndexByName(columnName);
if (columnIndex !== undefined) {
dehydratedQuickFilters.push([columnIndex, { text: filter }]);
}
});

return utils.hydrateQuickFilters(columns, dehydratedQuickFilters);
initialHydratedStateRef.current = {
...(hydratedInitialSorts !== undefined
? { sorts: hydratedInitialSorts }
: {}),
...(hydratedInitialQuickFilters !== undefined
? { quickFilters: hydratedInitialQuickFilters }
: {}),
};
}
return undefined;
}, [quickFilters, model, columns, utils]);
}
const initialHydratedState = initialHydratedStateRef.current;

// Get any format values that match column names
// Assume the format value is derived from the column
Expand Down Expand Up @@ -561,7 +603,8 @@ export function UITable({
mouseHandlers,
alwaysFetchColumns,
showSearchBar,
quickFilters: hydratedQuickFilters,
sorts: hydratedControlledSorts,
quickFilters: hydratedControlledQuickFilters,
isFilterBarShown: showQuickFilters,
reverse,
density,
Expand Down Expand Up @@ -610,7 +653,8 @@ export function UITable({
alwaysFetchColumns,
showSearchBar,
showQuickFilters,
hydratedQuickFilters,
hydratedControlledSorts,
hydratedControlledQuickFilters,
reverse,
density,
settings,
Expand Down
2 changes: 2 additions & 0 deletions plugins/ui/src/js/src/elements/UITable/UITableUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ export type UITableProps = StyleProps & {
onSelectionChange?: (selectedRows: RowDataMap[]) => void;
alwaysFetchColumns?: string | string[] | boolean;
quickFilters?: Record<string, string>;
controlledQuickFilters?: Record<string, string>;
sorts?: DehydratedSort[];
controlledSorts?: DehydratedSort[];
aggregations?: UIAggregation | UIAggregation[];
aggregationsPosition?: 'top' | 'bottom';
showSearch: boolean;
Expand Down
92 changes: 92 additions & 0 deletions plugins/ui/test/deephaven/ui/test_ui_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,42 @@ def test_quick_filters(self):
},
)

def test_controlled_quick_filters(self):
import deephaven.ui as ui

t = ui.table(self.source, controlled_quick_filters={"X": "X > 1"})

self.expect_render(
t,
{
"controlledQuickFilters": {"X": "X > 1"},
},
)

def test_quick_filter_modes_are_exclusive(self):
import deephaven.ui as ui

self.assertRaises(
ValueError,
lambda: ui.table(
self.source,
quick_filters={"X": "X > 1"},
controlled_quick_filters={"Y": "Y < 2"},
),
)

t = ui.table(
self.source,
controlled_quick_filters={"X": "X > 1", "Y": "Y < 2"},
)

self.expect_render(
t,
{
"controlledQuickFilters": {"X": "X > 1", "Y": "Y < 2"},
},
)

def test_show_quick_filters(self):
import deephaven.ui as ui

Expand Down Expand Up @@ -263,6 +299,62 @@ def test_sorts_list(self):
},
)

def test_controlled_sorts(self):
import deephaven.ui as ui

t = ui.table(self.source, controlled_sorts="X")

self.expect_render(
t,
{
"controlledSorts": [
{
"column": "X",
"direction": "ASC",
"isAbs": False,
}
]
},
)

def test_sort_modes_are_exclusive(self):
import deephaven.ui as ui

self.assertRaises(
ValueError,
lambda: ui.table(
self.source,
sorts="X",
controlled_sorts="Y",
),
)

t = ui.table(
self.source,
controlled_sorts=[
"X",
ui.TableSort(column="Y", direction="DESC", is_abs=True),
],
)

self.expect_render(
t,
{
"controlledSorts": [
{
"column": "X",
"direction": "ASC",
"isAbs": False,
},
{
"column": "Y",
"direction": "DESC",
"isAbs": True,
},
]
},
)

def test_sorts_invalid_direction(self):
import deephaven.ui as ui

Expand Down
Loading
Loading