Skip to content

Commit d1ecc61

Browse files
shuoweiltswast
andauthored
feat: Implement single-column sorting for interactive table widget (#2255)
This PR introduces single-column sorting functionality to the interactive table widget. 1) **Three-State Sorting UI** 1.1) The sort indicator dot (●) is now hidden by default and only appears when the user hovers the mouse over a column header 1.2) Implemented a sorting cycle: unsorted (●) → ascending (▲) → descending (▼) → unsorted (●). 1.3) Visual indicators (●, ▲, ▼) are displayed in column headers to reflect the current sort state. 1.4) Sorting controls are now only enabled for columns with orderable data types. 2) **Tests** 2.1) Updated `paginated_pandas_df` fixture for better sorting test coverage 2.2) Added new system tests to verify ascending, descending, and multi-column sorting. **3. Frontend Unit Tests** JavaScript-level unit tests have been added to validate the widget's frontend logic, specifically the new sorting functionality and UI interactions. **How to Run Frontend Unit Tests**: To execute these tests from the project root directory: ```bash cd tests/js npm install # Only needed if dependencies haven't been installed or have changed npm test ``` Docs has been updated to document the new features. The main description now mentions column sorting and adjustable widths, and a new section has been added to explain how to use the column resizing feature. The sorting section was also updated to mention that the indicators are only visible on hover. Fixes #<459835971> 🦕 --------- Co-authored-by: Tim Sweña (Swast) <[email protected]>
1 parent 32e5313 commit d1ecc61

File tree

14 files changed

+7329
-84
lines changed

14 files changed

+7329
-84
lines changed

.github/workflows/js-tests.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: js-tests
2+
on:
3+
pull_request:
4+
branches:
5+
- main
6+
push:
7+
branches:
8+
- main
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
- name: Install modules
16+
working-directory: ./tests/js
17+
run: npm install
18+
- name: Run tests
19+
working-directory: ./tests/js
20+
run: npm test

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ coverage.xml
5858

5959
# System test environment variables.
6060
system_tests/local_test_setup
61+
tests/js/node_modules/
6162

6263
# Make sure a generated file isn't accidentally committed.
6364
pylintrc

bigframes/display/anywidget.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import dataclasses
1920
from importlib import resources
2021
import functools
2122
import math
@@ -28,6 +29,7 @@
2829
from bigframes.core import blocks
2930
import bigframes.dataframe
3031
import bigframes.display.html
32+
import bigframes.dtypes as dtypes
3133

3234
# anywidget and traitlets are optional dependencies. We don't want the import of
3335
# this module to fail if they aren't installed, though. Instead, we try to
@@ -48,6 +50,12 @@
4850
WIDGET_BASE = object
4951

5052

53+
@dataclasses.dataclass(frozen=True)
54+
class _SortState:
55+
column: str
56+
ascending: bool
57+
58+
5159
class TableWidget(WIDGET_BASE):
5260
"""An interactive, paginated table widget for BigFrames DataFrames.
5361
@@ -63,6 +71,9 @@ class TableWidget(WIDGET_BASE):
6371
allow_none=True,
6472
).tag(sync=True)
6573
table_html = traitlets.Unicode().tag(sync=True)
74+
sort_column = traitlets.Unicode("").tag(sync=True)
75+
sort_ascending = traitlets.Bool(True).tag(sync=True)
76+
orderable_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True)
6677
_initial_load_complete = traitlets.Bool(False).tag(sync=True)
6778
_batches: Optional[blocks.PandasBatches] = None
6879
_error_message = traitlets.Unicode(allow_none=True, default_value=None).tag(
@@ -89,15 +100,25 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
89100
self._all_data_loaded = False
90101
self._batch_iter: Optional[Iterator[pd.DataFrame]] = None
91102
self._cached_batches: List[pd.DataFrame] = []
103+
self._last_sort_state: Optional[_SortState] = None
92104

93105
# respect display options for initial page size
94106
initial_page_size = bigframes.options.display.max_rows
95107

96108
# set traitlets properties that trigger observers
109+
# TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns.
97110
self.page_size = initial_page_size
111+
# TODO(b/463754889): Support non-string column labels for sorting.
112+
if all(isinstance(col, str) for col in dataframe.columns):
113+
self.orderable_columns = [
114+
str(col_name)
115+
for col_name, dtype in dataframe.dtypes.items()
116+
if dtypes.is_orderable(dtype)
117+
]
118+
else:
119+
self.orderable_columns = []
98120

99-
# len(dataframe) is expensive, since it will trigger a
100-
# SELECT COUNT(*) query. It is a must have however.
121+
# obtain the row counts
101122
# TODO(b/428238610): Start iterating over the result of `to_pandas_batches()`
102123
# before we get here so that the count might already be cached.
103124
self._reset_batches_for_new_page_size()
@@ -121,6 +142,11 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
121142
# Also used as a guard to prevent observers from firing during initialization.
122143
self._initial_load_complete = True
123144

145+
@traitlets.observe("_initial_load_complete")
146+
def _on_initial_load_complete(self, change: Dict[str, Any]):
147+
if change["new"]:
148+
self._set_table_html()
149+
124150
@functools.cached_property
125151
def _esm(self):
126152
"""Load JavaScript code from external file."""
@@ -221,13 +247,17 @@ def _cached_data(self) -> pd.DataFrame:
221247
return pd.DataFrame(columns=self._dataframe.columns)
222248
return pd.concat(self._cached_batches, ignore_index=True)
223249

250+
def _reset_batch_cache(self) -> None:
251+
"""Resets batch caching attributes."""
252+
self._cached_batches = []
253+
self._batch_iter = None
254+
self._all_data_loaded = False
255+
224256
def _reset_batches_for_new_page_size(self) -> None:
225257
"""Reset the batch iterator when page size changes."""
226258
self._batches = self._dataframe._to_pandas_batches(page_size=self.page_size)
227259

228-
self._cached_batches = []
229-
self._batch_iter = None
230-
self._all_data_loaded = False
260+
self._reset_batch_cache()
231261

232262
def _set_table_html(self) -> None:
233263
"""Sets the current html data based on the current page and page size."""
@@ -237,6 +267,21 @@ def _set_table_html(self) -> None:
237267
)
238268
return
239269

270+
# Apply sorting if a column is selected
271+
df_to_display = self._dataframe
272+
if self.sort_column:
273+
# TODO(b/463715504): Support sorting by index columns.
274+
df_to_display = df_to_display.sort_values(
275+
by=self.sort_column, ascending=self.sort_ascending
276+
)
277+
278+
# Reset batches when sorting changes
279+
if self._last_sort_state != _SortState(self.sort_column, self.sort_ascending):
280+
self._batches = df_to_display._to_pandas_batches(page_size=self.page_size)
281+
self._reset_batch_cache()
282+
self._last_sort_state = _SortState(self.sort_column, self.sort_ascending)
283+
self.page = 0 # Reset to first page
284+
240285
start = self.page * self.page_size
241286
end = start + self.page_size
242287

@@ -272,8 +317,14 @@ def _set_table_html(self) -> None:
272317
self.table_html = bigframes.display.html.render_html(
273318
dataframe=page_data,
274319
table_id=f"table-{self._table_id}",
320+
orderable_columns=self.orderable_columns,
275321
)
276322

323+
@traitlets.observe("sort_column", "sort_ascending")
324+
def _sort_changed(self, _change: Dict[str, Any]):
325+
"""Handler for when sorting parameters change from the frontend."""
326+
self._set_table_html()
327+
277328
@traitlets.observe("page")
278329
def _page_changed(self, _change: Dict[str, Any]) -> None:
279330
"""Handler for when the page number is changed from the frontend."""
@@ -288,6 +339,9 @@ def _page_size_changed(self, _change: Dict[str, Any]) -> None:
288339
return
289340
# Reset the page to 0 when page size changes to avoid invalid page states
290341
self.page = 0
342+
# Reset the sort state to default (no sort)
343+
self.sort_column = ""
344+
self.sort_ascending = True
291345

292346
# Reset batches to use new page size for future data fetching
293347
self._reset_batches_for_new_page_size()

bigframes/display/html.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@
1717
from __future__ import annotations
1818

1919
import html
20+
from typing import Any
2021

2122
import pandas as pd
2223
import pandas.api.types
2324

2425
from bigframes._config import options
2526

2627

27-
def _is_dtype_numeric(dtype) -> bool:
28+
def _is_dtype_numeric(dtype: Any) -> bool:
2829
"""Check if a dtype is numeric for alignment purposes."""
2930
return pandas.api.types.is_numeric_dtype(dtype)
3031

@@ -33,18 +34,31 @@ def render_html(
3334
*,
3435
dataframe: pd.DataFrame,
3536
table_id: str,
37+
orderable_columns: list[str] | None = None,
3638
) -> str:
3739
"""Render a pandas DataFrame to HTML with specific styling."""
3840
classes = "dataframe table table-striped table-hover"
3941
table_html = [f'<table border="1" class="{classes}" id="{table_id}">']
4042
precision = options.display.precision
43+
orderable_columns = orderable_columns or []
4144

4245
# Render table head
4346
table_html.append(" <thead>")
4447
table_html.append(' <tr style="text-align: left;">')
4548
for col in dataframe.columns:
49+
th_classes = []
50+
if col in orderable_columns:
51+
th_classes.append("sortable")
52+
class_str = f'class="{" ".join(th_classes)}"' if th_classes else ""
53+
header_div = (
54+
'<div style="resize: horizontal; overflow: auto; '
55+
"box-sizing: border-box; width: 100%; height: 100%; "
56+
'padding: 0.5em;">'
57+
f"{html.escape(str(col))}"
58+
"</div>"
59+
)
4660
table_html.append(
47-
f' <th style="text-align: left;"><div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">{html.escape(str(col))}</div></th>'
61+
f' <th style="text-align: left;" {class_str}>{header_div}</th>'
4862
)
4963
table_html.append(" </tr>")
5064
table_html.append(" </thead>")

bigframes/display/table_widget.css

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
align-items: center;
2929
display: flex;
3030
font-size: 0.8rem;
31-
padding-top: 8px;
31+
justify-content: space-between;
32+
padding: 8px;
33+
font-family:
34+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
3235
}
3336

3437
.bigframes-widget .footer > * {
@@ -44,6 +47,14 @@
4447
padding: 4px;
4548
}
4649

50+
.bigframes-widget .page-indicator {
51+
margin: 0 8px;
52+
}
53+
54+
.bigframes-widget .row-count {
55+
margin: 0 8px;
56+
}
57+
4758
.bigframes-widget .page-size {
4859
align-items: center;
4960
display: flex;
@@ -52,19 +63,31 @@
5263
justify-content: end;
5364
}
5465

66+
.bigframes-widget .page-size label {
67+
margin-right: 8px;
68+
}
69+
5570
.bigframes-widget table {
5671
border-collapse: collapse;
5772
text-align: left;
5873
}
5974

6075
.bigframes-widget th {
6176
background-color: var(--colab-primary-surface-color, var(--jp-layout-color0));
62-
/* Uncomment once we support sorting: cursor: pointer; */
6377
position: sticky;
6478
top: 0;
6579
z-index: 1;
6680
}
6781

82+
.bigframes-widget th .sort-indicator {
83+
padding-left: 4px;
84+
visibility: hidden;
85+
}
86+
87+
.bigframes-widget th:hover .sort-indicator {
88+
visibility: visible;
89+
}
90+
6891
.bigframes-widget button {
6992
cursor: pointer;
7093
display: inline-block;
@@ -78,3 +101,14 @@
78101
opacity: 0.65;
79102
pointer-events: none;
80103
}
104+
105+
.bigframes-widget .error-message {
106+
font-family:
107+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
108+
font-size: 14px;
109+
padding: 8px;
110+
margin-bottom: 8px;
111+
border: 1px solid red;
112+
border-radius: 4px;
113+
background-color: #ffebee;
114+
}

0 commit comments

Comments
 (0)