diff --git a/plugins/ui/docs/components/table.md b/plugins/ui/docs/components/table.md index 8e591a3c8..f509b495a 100644 --- a/plugins/ui/docs/components/table.md +++ b/plugins/ui/docs/components/table.md @@ -800,9 +800,54 @@ t2 = ui.table( # Filters applied when table is opened on the client ![Example of quick filters](../_assets/table_quick_filter.png) +## Sort + +You can set the initial sort state of a `ui.table` with the `sorts` prop. This controls table UI state (similar to `quick_filters` and `reverse`) without changing the underlying table definition. + +The `sorts` prop accepts: + +- A single column name string (ascending sort) +- A `ui.TableSort` object +- A list mixing column names and `ui.TableSort` objects + +When you pass `sorts`, those values seed the initial client sort state. If the user changes the sort, that client state is persisted and restored on reload. + +```python order=data,t_mixed_sort_list,t_string_sort +from deephaven import ui, new_table +from deephaven.column import int_col, string_col + +data = new_table( + [ + string_col("Name", ["R01", "R02", "R03", "R04", "R05", "R06"]), + string_col("Category", ["B", "A", "B", "A", "C", "C"]), + int_col("SepalLength", [51, -149, 64, 58, -32, 58]), + ] +) + +t_mixed_sort_list = ui.table( + data, + sorts=[ + "Category", + ui.TableSort(column="Name", direction="DESC"), + ui.TableSort(column="SepalLength", direction="ASC", is_abs=True), + ], +) + +t_string_sort = ui.table( + data, + sorts="Name", +) +``` + +`ui.TableSort` supports: + +- `column`: The column name to sort by. +- `direction`: `"ASC"` or `"DESC"`. +- `is_abs`: If `True`, sort by absolute value. + ## Reverse -The table can be displayed in reverse order using the `reverse` prop. Using the reverse prop visually indicates to the user that the table is reversed via a colored bar under the column headers. Users can disable the reverse with the column header context menu or via a shortcut. The reverse is applied on the server via request from the client. +The table can be displayed in reverse order using the `reverse` prop. Using the reverse prop visually indicates to the user that the table is reversed via a colored bar under the column headers. Users can disable the reverse with the column header context menu or via a shortcut. The reverse is applied on the server via request from the client. If both `sorts` and `reverse` are set, reverse is applied after sorting. ```python from deephaven import ui @@ -825,6 +870,12 @@ t = ui.table(dx.data.stocks(), reverse=True) .. dhautofunction:: deephaven.ui.TableAgg ``` +### TableSort + +```{eval-rst} +.. dhautofunction:: deephaven.ui.TableSort +``` + ### TableFormat ```{eval-rst} diff --git a/plugins/ui/docs/snapshots/43147ace5cee510f96e24cc6a3275935.json b/plugins/ui/docs/snapshots/43147ace5cee510f96e24cc6a3275935.json new file mode 100644 index 000000000..cadf53745 --- /dev/null +++ b/plugins/ui/docs/snapshots/43147ace5cee510f96e24cc6a3275935.json @@ -0,0 +1 @@ +{"file":"components/table.md","objects":{"data":{"type":"Table","data":{"columns":[{"name":"Name","type":"java.lang.String"},{"name":"Category","type":"java.lang.String"},{"name":"SepalLength","type":"int"}],"rows":[[{"value":"R01"},{"value":"B"},{"value":"51"}],[{"value":"R02"},{"value":"A"},{"value":"-149"}],[{"value":"R03"},{"value":"B"},{"value":"64"}],[{"value":"R04"},{"value":"A"},{"value":"58"}],[{"value":"R05"},{"value":"C"},{"value":"-32"}],[{"value":"R06"},{"value":"C"},{"value":"58"}]]}},"t_mixed_sort_list":{"type":"deephaven.ui.Element","data":{"document":{"__dhElemName":"deephaven.ui.elements.UITable","props":{"table":{"__dhObid":0},"sorts":[{"column":"Category","direction":"ASC","isAbs":false},{"column":"Name","direction":"DESC","isAbs":false},{"column":"SepalLength","direction":"ASC","isAbs":true}],"showQuickFilters":false,"showGroupingColumn":true,"showSearch":false,"reverse":false}},"state":"{}"}},"t_string_sort":{"type":"deephaven.ui.Element","data":{"document":{"__dhElemName":"deephaven.ui.elements.UITable","props":{"table":{"__dhObid":0},"sorts":[{"column":"Name","direction":"ASC","isAbs":false}],"showQuickFilters":false,"showGroupingColumn":true,"showSearch":false,"reverse":false}},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 26f9eae1b..05ea2b44d 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -72,7 +72,14 @@ from .tab_list import tab_list from .tab_panels import tab_panels from .tab import tab -from .table import table, TableAgg, TableDatabar, TableFormat, TableHeatmap +from .table import ( + table, + TableAgg, + TableDatabar, + TableFormat, + TableHeatmap, + TableSort, +) from .tabs import tabs from .tag_group import tag_group from .text import text @@ -166,6 +173,7 @@ "TableDatabar", "TableFormat", "TableHeatmap", + "TableSort", "tab_list", "tab_panels", "tabs", diff --git a/plugins/ui/src/deephaven/ui/components/table.py b/plugins/ui/src/deephaven/ui/components/table.py index b24812bd7..cabcc2769 100644 --- a/plugins/ui/src/deephaven/ui/components/table.py +++ b/plugins/ui/src/deephaven/ui/components/table.py @@ -1,6 +1,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal, Any, Optional +from typing import Literal, Any, Union import logging from deephaven.table import RollupTable, TreeTable from ..elements import Element, resolve @@ -39,6 +39,8 @@ "Var", ] +SortDirection = Literal["ASC", "DESC"] + @dataclass class TableAgg: @@ -58,6 +60,27 @@ class TableAgg: ignore_cols: ColumnName | list[ColumnName] | None = None +@dataclass +class TableSort: + """ + A sort configuration for a table. + + Args: + column: The column to sort. + direction: The sort direction. One of "ASC" or "DESC". + is_abs: Whether to sort by absolute value. + Returns: + The TableSort configuration. + """ + + column: ColumnName + direction: SortDirection = "ASC" + is_abs: bool = False + + +TableSortLike = Union[ColumnName, TableSort] + + @dataclass class TableFormat: """ @@ -206,6 +229,38 @@ def _validate_table_format( raise ValueError("TableHeatmap gradient must have at least 2 colors.") +def _normalize_table_sorts( + sorts: TableSortLike | list[TableSortLike], +) -> list[dict[str, Any]]: + """Normalize table sorts into the dehydrated sort shape used by iris-grid.""" + + sort_list = sorts if isinstance(sorts, list) else [sorts] + normalized: list[dict[str, Any]] = [] + for sort in sort_list: + if isinstance(sort, str): + sort = TableSort(column=sort) + elif not isinstance(sort, TableSort): + raise ValueError( + "Table sorts must be a column name, TableSort, or list of column " + f"names and TableSort instances. Received {type(sort).__name__}." + ) + + direction = sort.direction.upper() + if direction not in ("ASC", "DESC"): + raise ValueError( + f"Invalid sort direction: {sort.direction}. Expected 'ASC' or 'DESC'." + ) + normalized.append( + { + "column": sort.column, + "direction": direction, + "isAbs": sort.is_abs, + } + ) + + return normalized + + class table(Element): """ Customization to how a table is displayed, how it behaves, and listen to UI events. @@ -232,6 +287,10 @@ class table(Element): 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. + 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. aggregations_position: The position to show the aggregations. One of "top" or "bottom". "bottom" by default. @@ -317,6 +376,7 @@ def __init__( on_selection_change: SelectionChangeCallback | None = None, always_fetch_columns: ColumnName | list[ColumnName] | bool | None = None, quick_filters: dict[ColumnName, QuickFilterExpression] | None = None, + sorts: TableSortLike | list[TableSortLike] | None = None, show_quick_filters: bool = False, aggregations: TableAgg | list[TableAgg] | None = None, aggregations_position: Literal["top", "bottom"] | None = None, @@ -384,6 +444,9 @@ def __init__( if format_ is not None: _validate_table_format(format_, table) + if sorts is not None: + props["sorts"] = _normalize_table_sorts(sorts) + props["table"] = resolve(table) if isinstance(table, str) else table del props["self"] self._props = props diff --git a/plugins/ui/src/js/src/elements/UITable/UITable.tsx b/plugins/ui/src/js/src/elements/UITable/UITable.tsx index 98622d186..35c81820f 100644 --- a/plugins/ui/src/js/src/elements/UITable/UITable.tsx +++ b/plugins/ui/src/js/src/elements/UITable/UITable.tsx @@ -374,23 +374,43 @@ export function UITable({ [memoizedStateFn, model, setDehydratedState] ); - const initialHydratedState = useMemo(() => { - if (model && utils && initialState.current != null) { - return { - ...utils.hydrateIrisGridState(model, initialState.current), - ...IrisGridUtils.hydrateGridState(model, initialState.current), - }; - } - }, [model, utils]); - - const hydratedSorts = useMemo(() => { - if (utils && sorts !== undefined && columns !== undefined) { - log.debug('Hydrating sorts', sorts); - - return utils.hydrateSort(columns, sorts); + // 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); + + // 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 | undefined + >(undefined); + const initialHydratedStateComputedRef = useRef(false); + if ( + !initialHydratedStateComputedRef.current && + model != null && + utils != null + ) { + 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 }; } - return undefined; - }, [columns, utils, sorts]); + } + const initialHydratedState = lockedInitialHydratedStateRef.current; const hydratedQuickFilters = useMemo(() => { if ( @@ -536,7 +556,6 @@ export function UITable({ mouseHandlers, alwaysFetchColumns, showSearchBar, - sorts: hydratedSorts, quickFilters: hydratedQuickFilters, isFilterBarShown: showQuickFilters, reverse, @@ -586,7 +605,6 @@ export function UITable({ alwaysFetchColumns, showSearchBar, showQuickFilters, - hydratedSorts, hydratedQuickFilters, reverse, density, @@ -629,19 +647,19 @@ export function UITable({ * Otherwise, we have received changes from the server and we should use those over client state. * In the future we may want to do a smarter merge of these. */ - const mergedIrisGridProps = useMemo(() => { - if (initialIrisGridServerProps.current === irisGridServerProps) { - return { - ...irisGridServerProps, - ...initialHydratedState, - }; - } - - return { - ...initialHydratedState, - ...irisGridServerProps, - }; - }, [irisGridServerProps, initialHydratedState]); + const mergedIrisGridProps = useMemo( + () => + initialIrisGridServerProps.current === irisGridServerProps + ? { + ...irisGridServerProps, + ...(initialHydratedState ?? {}), + } + : { + ...(initialHydratedState ?? {}), + ...irisGridServerProps, + }, + [irisGridServerProps, initialHydratedState] + ); const inputFilters = useDashboardColumnFilters( model?.columns ?? null, diff --git a/plugins/ui/test/deephaven/ui/test_ui_table.py b/plugins/ui/test/deephaven/ui/test_ui_table.py index 37f17dc2a..f23ee0f0d 100644 --- a/plugins/ui/test/deephaven/ui/test_ui_table.py +++ b/plugins/ui/test/deephaven/ui/test_ui_table.py @@ -147,6 +147,136 @@ def test_show_quick_filters(self): }, ) + def test_sorts(self): + import deephaven.ui as ui + + t = ui.table(self.source, sorts="X") + + self.expect_render( + t, + { + "sorts": [ + { + "column": "X", + "direction": "ASC", + "isAbs": False, + } + ] + }, + ) + + t = ui.table(self.source, sorts=ui.TableSort(column="X")) + + self.expect_render( + t, + { + "sorts": [ + { + "column": "X", + "direction": "ASC", + "isAbs": False, + } + ] + }, + ) + + t = ui.table( + self.source, + sorts=ui.TableSort(column="X", direction="DESC", is_abs=True), + ) + + self.expect_render( + t, + { + "sorts": [ + { + "column": "X", + "direction": "DESC", + "isAbs": True, + } + ] + }, + ) + + def test_sorts_list(self): + import deephaven.ui as ui + + t = ui.table(self.source, sorts=["X", "Y"]) + + self.expect_render( + t, + { + "sorts": [ + { + "column": "X", + "direction": "ASC", + "isAbs": False, + }, + { + "column": "Y", + "direction": "ASC", + "isAbs": False, + }, + ] + }, + ) + + t = ui.table( + self.source, + sorts=[ + "X", + ui.TableSort(column="X", direction="DESC", is_abs=True), + ui.TableSort(column="Y", direction="ASC", is_abs=False), + ], + ) + + self.expect_render( + t, + { + "sorts": [ + { + "column": "X", + "direction": "ASC", + "isAbs": False, + }, + { + "column": "X", + "direction": "DESC", + "isAbs": True, + }, + { + "column": "Y", + "direction": "ASC", + "isAbs": False, + }, + ] + }, + ) + + def test_sorts_invalid_direction(self): + import deephaven.ui as ui + + self.assertRaises( + ValueError, + lambda: ui.table( + self.source, + sorts=ui.TableSort(column="X", direction="UP"), + ), + ) + + def test_sorts_invalid_type(self): + import deephaven.ui as ui + + self.assertRaises( + ValueError, + lambda: ui.table(self.source, sorts=1), + ) + + self.assertRaises( + ValueError, + lambda: ui.table(self.source, sorts=["X", 1]), + ) + def test_show_search(self): import deephaven.ui as ui diff --git a/tests/app.d/ui_table.py b/tests/app.d/ui_table.py index 6ad9a188f..a931e352b 100644 --- a/tests/app.d/ui_table.py +++ b/tests/app.d/ui_table.py @@ -1,5 +1,6 @@ from deephaven import ui -from deephaven import empty_table +from deephaven import empty_table, new_table +from deephaven.column import int_col, string_col import deephaven.plot.express as dx _t = empty_table(100).update(["x = i", "y = sin(i)"]) @@ -408,6 +409,47 @@ def t_selection_component(): ], ) +_programmatic_sort_data = new_table( + [ + string_col("Name", [f"R{i:02d}" for i in range(1, 21)]), + int_col( + "SepalLength", + [ + 51, + -49, + 64, + 58, + -32, + 47, + 53, + -41, + 60, + 55, + -28, + 62, + 44, + -36, + 57, + 50, + -22, + 59, + 46, + -30, + ], + ), + ] +) + +t_programmatic_sort_asc = ui.table( + _programmatic_sort_data, + sorts=ui.TableSort(column="SepalLength", direction="ASC"), +) + +t_programmatic_sort_abs_desc = ui.table( + _programmatic_sort_data, + sorts=ui.TableSort(column="Name", direction="DESC", is_abs=True), +) + from deephaven import agg _rollup_source = empty_table(100).update( diff --git a/tests/ui_table.spec.ts b/tests/ui_table.spec.ts index 6bc956bbc..71febbabb 100644 --- a/tests/ui_table.spec.ts +++ b/tests/ui_table.spec.ts @@ -31,6 +31,8 @@ test.describe('UI table', () => { 't_heatmap_both', 't_heatmap_databar_overlay', 't_heatmap_databar_mixed', + 't_programmatic_sort_asc', + 't_programmatic_sort_abs_desc', 't_rollup_format', ].forEach(name => { test(name, async ({ page }) => { diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-chromium-linux.png new file mode 100644 index 000000000..180500a1d Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-firefox-linux.png new file mode 100644 index 000000000..50fd2d343 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-webkit-linux.png new file mode 100644 index 000000000..e90f30212 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-abs-desc-1-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-chromium-linux.png new file mode 100644 index 000000000..02ee9d8ba Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-firefox-linux.png new file mode 100644 index 000000000..f694d28ae Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-webkit-linux.png new file mode 100644 index 000000000..e51819065 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-t-programmatic-sort-asc-1-webkit-linux.png differ diff --git a/tests/utils.ts b/tests/utils.ts index 402ce12e9..05f4e1535 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -86,7 +86,9 @@ export async function openPanel( await page.mouse.move(0, 0); // check for panel to be loaded - await expect(page.locator(panelLocator)).toHaveCount(panelCount + 1); + await expect(page.locator(panelLocator)).toHaveCount(panelCount + 1, { + timeout: 30000, + }); if (awaitLoad) { await waitForLoad(page); }