Skip to content

Commit 50bee9c

Browse files
committed
feat(display): support multi-column sorting in anywidget
Updates the TableWidget to support sorting by multiple columns. - Python: Changes sort_column/sort_ascending traits to lists. - JS: Adds Shift+Click support for multi-column sorting. - JS: Displays sort indicators for all sorted columns. - Tests: Adds unit tests for multi-column sorting logic.
1 parent e016751 commit 50bee9c

File tree

3 files changed

+136
-32
lines changed

3 files changed

+136
-32
lines changed

bigframes/display/anywidget.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@
5353

5454
@dataclasses.dataclass(frozen=True)
5555
class _SortState:
56-
column: str
57-
ascending: bool
56+
columns: tuple[str, ...]
57+
ascending: tuple[bool, ...]
5858

5959

6060
class TableWidget(_WIDGET_BASE):
@@ -68,8 +68,8 @@ class TableWidget(_WIDGET_BASE):
6868
page_size = traitlets.Int(0).tag(sync=True)
6969
row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True)
7070
table_html = traitlets.Unicode("").tag(sync=True)
71-
sort_column = traitlets.Unicode("").tag(sync=True)
72-
sort_ascending = traitlets.Bool(True).tag(sync=True)
71+
sort_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True)
72+
sort_ascending = traitlets.List(traitlets.Bool(), []).tag(sync=True)
7373
orderable_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True)
7474
_initial_load_complete = traitlets.Bool(False).tag(sync=True)
7575
_batches: Optional[blocks.PandasBatches] = None
@@ -280,23 +280,22 @@ def _set_table_html(self) -> None:
280280

281281
# Apply sorting if a column is selected
282282
df_to_display = self._dataframe
283-
if self.sort_column:
283+
if self.sort_columns:
284284
# TODO(b/463715504): Support sorting by index columns.
285285
df_to_display = df_to_display.sort_values(
286-
by=self.sort_column, ascending=self.sort_ascending
286+
by=list(self.sort_columns), ascending=list(self.sort_ascending)
287287
)
288288

289289
# Reset batches when sorting changes
290-
if self._last_sort_state != _SortState(
291-
self.sort_column, self.sort_ascending
292-
):
290+
current_sort_state = _SortState(
291+
tuple(self.sort_columns), tuple(self.sort_ascending)
292+
)
293+
if self._last_sort_state != current_sort_state:
293294
self._batches = df_to_display.to_pandas_batches(
294295
page_size=self.page_size
295296
)
296297
self._reset_batch_cache()
297-
self._last_sort_state = _SortState(
298-
self.sort_column, self.sort_ascending
299-
)
298+
self._last_sort_state = current_sort_state
300299
if self.page != 0:
301300
new_page = 0 # Reset to first page
302301

@@ -348,14 +347,15 @@ def _set_table_html(self) -> None:
348347
self.table_html = bigframes.display.html.render_html(
349348
dataframe=page_data,
350349
table_id=f"table-{self._table_id}",
350+
orderable_columns=self.orderable_columns,
351351
)
352352

353353
if new_page is not None:
354354
# Navigate to the new page. This triggers the observer, which will
355355
# re-enter _set_table_html. Since we've released the lock, this is safe.
356356
self.page = new_page
357357

358-
@traitlets.observe("sort_column", "sort_ascending")
358+
@traitlets.observe("sort_columns", "sort_ascending")
359359
def _sort_changed(self, _change: dict[str, Any]):
360360
"""Handler for when sorting parameters change from the frontend."""
361361
self._set_table_html()
@@ -375,8 +375,8 @@ def _page_size_changed(self, _change: dict[str, Any]) -> None:
375375
# Reset the page to 0 when page size changes to avoid invalid page states
376376
self.page = 0
377377
# Reset the sort state to default (no sort)
378-
self.sort_column = ""
379-
self.sort_ascending = True
378+
self.sort_columns = []
379+
self.sort_ascending = []
380380

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

bigframes/display/table_widget.js

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const ModelProperty = {
2121
PAGE_SIZE: "page_size",
2222
ROW_COUNT: "row_count",
2323
SORT_ASCENDING: "sort_ascending",
24-
SORT_COLUMN: "sort_column",
24+
SORT_COLUMNS: "sort_columns",
2525
TABLE_HTML: "table_html",
2626
};
2727

@@ -141,7 +141,7 @@ function render({ model, el }) {
141141

142142
// Get sortable columns from backend
143143
const sortableColumns = model.get(ModelProperty.ORDERABLE_COLUMNS);
144-
const currentSortColumn = model.get(ModelProperty.SORT_COLUMN);
144+
const currentSortColumns = model.get(ModelProperty.SORT_COLUMNS);
145145
const currentSortAscending = model.get(ModelProperty.SORT_ASCENDING);
146146

147147
// Add click handlers to column headers for sorting
@@ -161,8 +161,10 @@ function render({ model, el }) {
161161

162162
// Determine sort indicator and initial visibility
163163
let indicator = "●"; // Default: unsorted (dot)
164-
if (currentSortColumn === columnName) {
165-
indicator = currentSortAscending ? "▲" : "▼";
164+
const sortIndex = currentSortColumns.indexOf(columnName);
165+
if (sortIndex !== -1) {
166+
const isAscending = currentSortAscending[sortIndex];
167+
indicator = isAscending ? "▲" : "▼";
166168
indicatorSpan.style.visibility = "visible"; // Sorted arrows always visible
167169
} else {
168170
indicatorSpan.style.visibility = "hidden"; // Unsorted dot hidden by default
@@ -178,32 +180,57 @@ function render({ model, el }) {
178180

179181
// Add hover effects for unsorted columns only
180182
header.addEventListener("mouseover", () => {
181-
if (currentSortColumn !== columnName) {
183+
if (currentSortColumns.indexOf(columnName) === -1) {
182184
indicatorSpan.style.visibility = "visible";
183185
}
184186
});
185187
header.addEventListener("mouseout", () => {
186-
if (currentSortColumn !== columnName) {
188+
if (currentSortColumns.indexOf(columnName) === -1) {
187189
indicatorSpan.style.visibility = "hidden";
188190
}
189191
});
190192

191193
// Add click handler for three-state toggle
192-
header.addEventListener(Event.CLICK, () => {
193-
if (currentSortColumn === columnName) {
194-
if (currentSortAscending) {
195-
// Currently ascending → switch to descending
196-
model.set(ModelProperty.SORT_ASCENDING, false);
194+
header.addEventListener(Event.CLICK, (event) => {
195+
const sortIndex = currentSortColumns.indexOf(columnName);
196+
let newColumns = [...currentSortColumns];
197+
let newAscending = [...currentSortAscending];
198+
199+
if (event.shiftKey) {
200+
if (sortIndex !== -1) {
201+
// Already sorted. Toggle or Remove.
202+
if (newAscending[sortIndex]) {
203+
// Asc -> Desc
204+
newAscending[sortIndex] = false;
205+
} else {
206+
// Desc -> Remove
207+
newColumns.splice(sortIndex, 1);
208+
newAscending.splice(sortIndex, 1);
209+
}
197210
} else {
198-
// Currently descending → clear sort (back to unsorted)
199-
model.set(ModelProperty.SORT_COLUMN, "");
200-
model.set(ModelProperty.SORT_ASCENDING, true);
211+
// Not sorted -> Append Asc
212+
newColumns.push(columnName);
213+
newAscending.push(true);
201214
}
202215
} else {
203-
// Not currently sorted → sort ascending
204-
model.set(ModelProperty.SORT_COLUMN, columnName);
205-
model.set(ModelProperty.SORT_ASCENDING, true);
216+
// No shift key. Single column mode.
217+
if (sortIndex !== -1 && newColumns.length === 1) {
218+
// Already only this column. Toggle or Remove.
219+
if (newAscending[sortIndex]) {
220+
newAscending[sortIndex] = false;
221+
} else {
222+
newColumns = [];
223+
newAscending = [];
224+
}
225+
} else {
226+
// Start fresh with this column
227+
newColumns = [columnName];
228+
newAscending = [true];
229+
}
206230
}
231+
232+
model.set(ModelProperty.SORT_COLUMNS, newColumns);
233+
model.set(ModelProperty.SORT_ASCENDING, newAscending);
207234
model.save_changes();
208235
});
209236
}

tests/unit/display/test_anywidget.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,80 @@ def handler(signum, frame):
7878
finally:
7979
if has_sigalrm:
8080
signal.alarm(0)
81+
82+
83+
@pytest.fixture
84+
def mock_df():
85+
df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True)
86+
df.columns = ["col1", "col2"]
87+
df.dtypes = {"col1": "int64", "col2": "int64"}
88+
89+
mock_block = mock.Mock()
90+
mock_block.has_index = False
91+
df._block = mock_block
92+
93+
# Mock to_pandas_batches to return empty iterator or simple data
94+
batch_df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]})
95+
batches = mock.MagicMock()
96+
batches.__iter__.return_value = iter([batch_df])
97+
batches.total_rows = 2
98+
df.to_pandas_batches.return_value = batches
99+
100+
# Mock sort_values to return self (for chaining)
101+
df.sort_values.return_value = df
102+
103+
return df
104+
105+
106+
def test_sorting_single_column(mock_df):
107+
from bigframes.display.anywidget import TableWidget
108+
109+
with bigframes.option_context("display.repr_mode", "anywidget"):
110+
widget = TableWidget(mock_df)
111+
112+
# Verify initial state
113+
assert widget.sort_columns == []
114+
assert widget.sort_ascending == []
115+
116+
# Apply sort
117+
widget.sort_columns = ["col1"]
118+
widget.sort_ascending = [True]
119+
120+
# This should trigger _sort_changed -> _set_table_html
121+
# which calls df.sort_values
122+
123+
mock_df.sort_values.assert_called_with(by=["col1"], ascending=[True])
124+
125+
126+
def test_sorting_multi_column(mock_df):
127+
from bigframes.display.anywidget import TableWidget
128+
129+
with bigframes.option_context("display.repr_mode", "anywidget"):
130+
widget = TableWidget(mock_df)
131+
132+
# Apply multi-column sort
133+
widget.sort_columns = ["col1", "col2"]
134+
widget.sort_ascending = [True, False]
135+
136+
mock_df.sort_values.assert_called_with(by=["col1", "col2"], ascending=[True, False])
137+
138+
139+
def test_page_size_change_resets_sort(mock_df):
140+
from bigframes.display.anywidget import TableWidget
141+
142+
with bigframes.option_context("display.repr_mode", "anywidget"):
143+
widget = TableWidget(mock_df)
144+
145+
# Set sort state
146+
widget.sort_columns = ["col1"]
147+
widget.sort_ascending = [True]
148+
149+
# Change page size
150+
widget.page_size = 50
151+
152+
# Sort should be reset
153+
assert widget.sort_columns == []
154+
assert widget.sort_ascending == []
155+
156+
# to_pandas_batches called again (reset)
157+
assert mock_df.to_pandas_batches.call_count >= 2

0 commit comments

Comments
 (0)