Skip to content
Open
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
148 changes: 108 additions & 40 deletions plugins/ui/src/js/src/elements/UITable/UITable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ const ALWAYS_FETCH_COLUMN_LIMIT = 500;

const EMPTY_OBJECT = Object.freeze({});

/**
* Returns a reference to `value` that only changes when its JSON contents
* change, so an equal-but-new object from an unrelated re-render doesn't
* look like a real prop change to IrisGrid.
*/
function useStableValue<T>(value: T): T {
const json = JSON.stringify(value);
const ref = useRef({ json, value });
if (ref.current.json !== json) {
ref.current = { json, value };
}
return ref.current.value;
}

/**
* Hook to throw an error during a render cycle so it is caught by the error boundary.
* Useful to throw from async or callbacks that occur outside the render cycle.
Expand Down Expand Up @@ -166,6 +180,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 +216,9 @@ export function UITable({
onRowDoublePress,
onSelectionChange,
quickFilters,
controlledQuickFilters,
sorts,
controlledSorts,
aggregations,
aggregationsPosition = 'bottom',
alwaysFetchColumns: alwaysFetchColumnsProp,
Expand Down Expand Up @@ -379,17 +421,47 @@ 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);

// Stabilize the raw controlled values so an unrelated re-render can't hand
// us a new-but-equal-content object/array (see `useStableValue` above).
const stableControlledSorts = useStableValue(controlledSorts);
const stableControlledQuickFilters = useStableValue(controlledQuickFilters);

// 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 (
stableControlledSorts === undefined ||
utils == null ||
columns.length === 0
) {
return undefined;
}
log.debug('Hydrating controlled sorts', stableControlledSorts);
return utils.hydrateSort(columns, stableControlledSorts);
}, [stableControlledSorts, utils, columns]);

const hydratedControlledQuickFilters = useMemo(
() =>
hydrateUITableQuickFilters(
stableControlledQuickFilters,
model,
columns,
utils
),
[stableControlledQuickFilters, model, columns, utils]
);

// 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);
// 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 +477,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 +627,8 @@ export function UITable({
mouseHandlers,
alwaysFetchColumns,
showSearchBar,
quickFilters: hydratedQuickFilters,
sorts: hydratedControlledSorts,
quickFilters: hydratedControlledQuickFilters,
isFilterBarShown: showQuickFilters,
reverse,
density,
Expand Down Expand Up @@ -610,7 +677,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