Skip to content
Open
23 changes: 20 additions & 3 deletions plugins/ui/src/deephaven/ui/components/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,22 @@ 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.
quick_filters: The quick filters to apply to the table. Server-owned:
updating this value re-applies it and replaces any quick filters the
user has changed in the UI. Dictionary of column name to filter value.
default_quick_filters: The initial quick filters to apply to the table.
User-owned: this sets the initial value, then the user owns it from
there, and their changes are persisted and restored on reload.
Ignored if `quick_filters` is provided.
Dictionary of column name to filter value.
sorts: The sorts to apply to the table. Server-owned: updating this value
re-applies it and replaces any sorts the user has changed in the UI.
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.
Accepts a column name, TableSort, or list containing column names and TableSort instances.
default_sorts: The initial sorts to apply to the table. User-owned: this
sets the initial value, then the user owns it from there, and their
changes are persisted and restored on reload.
Ignored if `sorts` is provided.
Accepts a column name, TableSort, or list containing column names and TableSort instances.
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.
Expand Down Expand Up @@ -376,7 +388,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,
default_quick_filters: dict[ColumnName, QuickFilterExpression] | None = None,
sorts: TableSortLike | list[TableSortLike] | None = None,
default_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 @@ -447,6 +461,9 @@ def __init__(
if sorts is not None:
props["sorts"] = _normalize_table_sorts(sorts)

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

props["table"] = resolve(table) if isinstance(table, str) else table
del props["self"]
self._props = props
Expand Down
1 change: 1 addition & 0 deletions plugins/ui/src/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@internationalized/date": "^3.5.5",
"classnames": "^2.5.1",
"fast-deep-equal": "^3.1.3",
"fast-json-patch": "^3.1.1",
"json-rpc-2.0": "^1.6.0",
"memoizee": "^0.4.17",
Expand Down
152 changes: 101 additions & 51 deletions plugins/ui/src/js/src/elements/UITable/UITable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ import {
type GridState,
} from '@deephaven/grid';
import { EMPTY_ARRAY, ensureArray } from '@deephaven/utils';
import { useDebouncedCallback } from '@deephaven/react-hooks';
import { useDebouncedCallback, useIsEqualMemo } from '@deephaven/react-hooks';
import deepEqual from 'fast-deep-equal';
import {
type FormattingRule,
getAggregationOperation,
Expand Down Expand Up @@ -166,6 +167,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 +203,9 @@ export function UITable({
onRowDoublePress,
onSelectionChange,
quickFilters,
defaultQuickFilters,
sorts,
defaultSorts,
aggregations,
aggregationsPosition = 'bottom',
alwaysFetchColumns: alwaysFetchColumnsProp,
Expand Down Expand Up @@ -379,66 +408,85 @@ 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).
const initialSortsRef = useRef(sorts);
// Server-owned `sorts`/`quickFilters` are applied as live IrisGrid props and
// re-applied whenever their value changes. We stabilize them by content so an
// unrelated re-render (new reference, identical content) does not re-apply and
// replace changes the user made in the UI.
const stableSorts = useIsEqualMemo(sorts, deepEqual);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This shouldn't be necessary - we use patching in the deephaven.ui render so it should be a stable object unless there's actually changes. If that's not the case, there's something else that needs to be fixed in the renderer.
We added patching in: #1313

const hydratedSorts = useMemo(() => {
if (stableSorts === undefined || utils == null || columns.length === 0) {
return undefined;
}
log.debug('Hydrating sorts', stableSorts);
return utils.hydrateSort(columns, stableSorts);
}, [stableSorts, utils, columns]);

const stableQuickFilters = useIsEqualMemo(quickFilters, deepEqual);
const hydratedQuickFilters = useMemo(
() => hydrateUITableQuickFilters(stableQuickFilters, model, columns, utils),
[stableQuickFilters, model, columns, utils]
);

// User-owned `defaultSorts`/`defaultQuickFilters` are hydrated once (after the
// columns load) and used as the initial grid state. Changes the user makes
// afterwards are kept by IrisGrid and persisted via onStateChange.
const hydratedDefaultSortsRef = useRef<IrisGridProps['sorts'] | undefined>(
undefined
);
if (
hydratedDefaultSortsRef.current === undefined &&
utils != null &&
defaultSorts !== undefined &&
columns.length > 0
) {
hydratedDefaultSortsRef.current = utils.hydrateSort(columns, defaultSorts);
}
const hydratedDefaultSorts = hydratedDefaultSortsRef.current;

// 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
const hydratedDefaultQuickFiltersRef = useRef<
ReturnType<IrisGridUtils['hydrateQuickFilters']> | undefined
>(undefined);
const initialHydratedStateComputedRef = useRef(false);
if (
!initialHydratedStateComputedRef.current &&
hydratedDefaultQuickFiltersRef.current === undefined &&
defaultQuickFilters !== undefined &&
model != null &&
utils != null
columns.length > 0
) {
initialHydratedStateComputedRef.current = true;
const persisted =
initialState.current != null
? {
...utils.hydrateIrisGridState(model, initialState.current),
...IrisGridUtils.hydrateGridState(model, initialState.current),
}
: undefined;
const initialSorts = initialSortsRef.current;
const seededSorts =
persisted == null && initialSorts !== undefined && columns !== undefined
? utils.hydrateSort(columns, initialSorts)
: undefined;
if (persisted != null) {
lockedInitialHydratedStateRef.current = persisted;
} else if (seededSorts !== undefined) {
lockedInitialHydratedStateRef.current = { sorts: seededSorts };
}
hydratedDefaultQuickFiltersRef.current = hydrateUITableQuickFilters(
defaultQuickFilters,
model,
columns,
utils
);
}
const initialHydratedState = lockedInitialHydratedStateRef.current;

const hydratedQuickFilters = useMemo(() => {
const hydratedDefaultQuickFilters = hydratedDefaultQuickFiltersRef.current;

const initialHydratedState = useMemo(() => {
if (model && utils && initialState.current != null) {
return {
...utils.hydrateIrisGridState(model, initialState.current),
...IrisGridUtils.hydrateGridState(model, initialState.current),
};
}
// No persisted client state: use the user-owned defaults as the initial
// grid state so they apply once on load. Changes the user makes afterwards
// are kept in IrisGrid's own state and persisted via onStateChange.
if (
quickFilters !== undefined &&
model &&
utils &&
model !== undefined &&
columns !== undefined
(hydratedDefaultSorts !== undefined ||
hydratedDefaultQuickFilters !== 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);
return {
...(hydratedDefaultSorts !== undefined
? { sorts: hydratedDefaultSorts }
: {}),
...(hydratedDefaultQuickFilters !== undefined
? { quickFilters: hydratedDefaultQuickFilters }
: {}),
};
}
return undefined;
}, [quickFilters, model, columns, utils]);
}, [model, utils, hydratedDefaultSorts, hydratedDefaultQuickFilters]);

// Get any format values that match column names
// Assume the format value is derived from the column
Expand Down Expand Up @@ -561,6 +609,7 @@ export function UITable({
mouseHandlers,
alwaysFetchColumns,
showSearchBar,
sorts: hydratedSorts,
quickFilters: hydratedQuickFilters,
isFilterBarShown: showQuickFilters,
reverse,
Expand Down Expand Up @@ -610,6 +659,7 @@ export function UITable({
alwaysFetchColumns,
showSearchBar,
showQuickFilters,
hydratedSorts,
hydratedQuickFilters,
reverse,
density,
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>;
defaultQuickFilters?: Record<string, string>;
sorts?: DehydratedSort[];
defaultSorts?: DehydratedSort[];
aggregations?: UIAggregation | UIAggregation[];
aggregationsPosition?: 'top' | 'bottom';
showSearch: boolean;
Expand Down
65 changes: 65 additions & 0 deletions plugins/ui/test/deephaven/ui/test_ui_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ def test_quick_filters(self):
},
)

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

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

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

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

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

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

Expand Down Expand Up @@ -253,6 +274,50 @@ def test_sorts_list(self):
},
)

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

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

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

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

self.expect_render(
t,
{
"defaultSorts": [
{
"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