Skip to content

Commit c23d9ce

Browse files
committed
fix: Improve Anywidget pagination and display for unknown row counts
1 parent 64995d6 commit c23d9ce

File tree

4 files changed

+195
-11
lines changed

4 files changed

+195
-11
lines changed

bigframes/display/anywidget.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ class TableWidget(WIDGET_BASE):
5555

5656
page = traitlets.Int(0).tag(sync=True)
5757
page_size = traitlets.Int(0).tag(sync=True)
58-
row_count = traitlets.Int(0).tag(sync=True)
58+
row_count = traitlets.Union(
59+
[traitlets.Int(), traitlets.Instance(type(None))],
60+
default_value=None,
61+
allow_none=True,
62+
).tag(sync=True)
5963
table_html = traitlets.Unicode().tag(sync=True)
6064
_initial_load_complete = traitlets.Bool(False).tag(sync=True)
6165
_batches: Optional[blocks.PandasBatches] = None
@@ -98,8 +102,10 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
98102
# there are multiple pages and show "page 1 of many" in this case
99103
self._reset_batches_for_new_page_size()
100104
if self._batches is None or self._batches.total_rows is None:
105+
# TODO(b/428238610): We could still end up with a None here if the
106+
# underlying execution doesn't produce a total row count.
101107
self._error_message = "Could not determine total row count. Data might be unavailable or an error occurred."
102-
self.row_count = 0
108+
self.row_count = None
103109
else:
104110
self.row_count = self._batches.total_rows
105111

@@ -131,8 +137,13 @@ def _validate_page(self, proposal: Dict[str, Any]) -> int:
131137
Returns:
132138
The validated and clamped page number as an integer.
133139
"""
134-
135140
value = proposal["value"]
141+
142+
# If row count is unknown, allow any non-negative page
143+
if self.row_count is None:
144+
return max(0, value)
145+
146+
# If truly empty or invalid page size, stay on page 0
136147
if self.row_count == 0 or self.page_size == 0:
137148
return 0
138149

@@ -229,6 +240,23 @@ def _set_table_html(self) -> None:
229240
# Get the data for the current page
230241
page_data = cached_data.iloc[start:end]
231242

243+
# Handle case where user navigated beyond available data with unknown row count
244+
is_unknown_count = self.row_count is None
245+
is_beyond_data = self._all_data_loaded and len(page_data) == 0 and self.page > 0
246+
if is_unknown_count and is_beyond_data:
247+
# Calculate the last valid page (zero-indexed)
248+
total_rows = len(cached_data)
249+
if total_rows > 0:
250+
last_valid_page = max(0, math.ceil(total_rows / self.page_size) - 1)
251+
# Navigate back to the last valid page
252+
self.page = last_valid_page
253+
# Recursively call to display the correct page
254+
return self._set_table_html()
255+
else:
256+
# If no data at all, stay on page 0 with empty display
257+
self.page = 0
258+
return self._set_table_html()
259+
232260
# Generate HTML table
233261
self.table_html = bigframes.display.html.render_html(
234262
dataframe=page_data,

bigframes/display/table_widget.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,23 @@ function render({ model, el }) {
8585
const rowCount = model.get(ModelProperty.ROW_COUNT);
8686
const pageSize = model.get(ModelProperty.PAGE_SIZE);
8787
const currentPage = model.get(ModelProperty.PAGE);
88-
const totalPages = Math.ceil(rowCount / pageSize);
89-
90-
rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`;
91-
paginationLabel.textContent = `Page ${(
92-
currentPage + 1
93-
).toLocaleString()} of ${(totalPages || 1).toLocaleString()}`;
94-
prevPage.disabled = currentPage === 0;
95-
nextPage.disabled = currentPage >= totalPages - 1;
88+
89+
if (rowCount === null) {
90+
// Unknown total rows
91+
rowCountLabel.textContent = "Total rows unknown";
92+
paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()}`;
93+
prevPage.disabled = currentPage === 0;
94+
nextPage.disabled = false; // Allow navigation until we hit the end
95+
} else {
96+
// Known total rows
97+
const totalPages = Math.ceil(rowCount / pageSize);
98+
rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`;
99+
paginationLabel.textContent = `Page ${(
100+
currentPage + 1
101+
).toLocaleString()} of ${(totalPages || 1).toLocaleString()}`;
102+
prevPage.disabled = currentPage === 0;
103+
nextPage.disabled = currentPage >= totalPages - 1;
104+
}
96105
pageSizeSelect.value = pageSize;
97106
}
98107

notebooks/dataframes/anywidget_mode.ipynb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,15 @@
354354
"small_widget"
355355
]
356356
},
357+
{
358+
"cell_type": "markdown",
359+
"id": "a9d5d13b",
360+
"metadata": {},
361+
"source": [
362+
"### Handling of Invalid Row Count\n",
363+
"In cases where the total number of rows cannot be determined, `row_count` will be `None`. The widget will display 'Total rows unknown', and you can navigate forward until you reach the end of the available data. If you navigate beyond the last page, the widget will automatically return to the last valid page."
364+
]
365+
},
357366
{
358367
"cell_type": "markdown",
359368
"id": "added-cell-2",

tests/system/small/test_anywidget.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,144 @@ def test_widget_row_count_reflects_actual_data_available(
549549
assert widget.page_size == 2 # Respects the display option
550550

551551

552+
def test_widget_with_unknown_row_count_should_auto_navigate_to_last_page(
553+
session: bf.Session,
554+
):
555+
"""
556+
Given a widget with unknown row count (row_count=None), when a user
557+
navigates beyond the available data and all data is loaded, then the
558+
widget should automatically navigate back to the last valid page.
559+
"""
560+
from bigframes.display import TableWidget
561+
562+
# Create a small DataFrame with known content
563+
test_data = pd.DataFrame(
564+
{
565+
"id": [0, 1, 2, 3, 4],
566+
"value": ["row_0", "row_1", "row_2", "row_3", "row_4"],
567+
}
568+
)
569+
bf_df = session.read_pandas(test_data)
570+
571+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
572+
widget = TableWidget(bf_df)
573+
574+
# Manually set row_count to None to simulate unknown total
575+
widget.row_count = None
576+
577+
# Navigate to a page beyond available data (page 10)
578+
# With page_size=2 and 5 rows, valid pages are 0, 1, 2
579+
widget.page = 10
580+
581+
# Force data loading by accessing table_html
582+
_ = widget.table_html
583+
584+
# After all data is loaded, widget should auto-navigate to last valid page
585+
# Last valid page = ceil(5 / 2) - 1 = 2
586+
assert widget.page == 2
587+
588+
# Verify the displayed content is the last page
589+
html = widget.table_html
590+
assert "row_4" in html # Last row should be visible
591+
assert "row_0" not in html # First row should not be visible
592+
593+
594+
def test_widget_with_unknown_row_count_should_display_correct_ui_text(
595+
session: bf.Session,
596+
):
597+
"""
598+
Given a widget with unknown row count, the JavaScript frontend should
599+
display appropriate text indicating the total is unknown.
600+
"""
601+
from bigframes.display import TableWidget
602+
603+
test_data = pd.DataFrame(
604+
{
605+
"id": [0, 1, 2],
606+
"value": ["a", "b", "c"],
607+
}
608+
)
609+
bf_df = session.read_pandas(test_data)
610+
611+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
612+
widget = TableWidget(bf_df)
613+
614+
# Set row_count to None
615+
widget.row_count = None
616+
617+
# Verify row_count is None (not 0)
618+
assert widget.row_count is None
619+
620+
# The widget should still function normally
621+
assert widget.page == 0
622+
assert widget.page_size == 2
623+
624+
625+
def test_widget_with_unknown_row_count_should_allow_forward_navigation(
626+
session: bf.Session,
627+
):
628+
"""
629+
Given a widget with unknown row count, users should be able to navigate
630+
forward until they reach the end of available data.
631+
"""
632+
from bigframes.display import TableWidget
633+
634+
test_data = pd.DataFrame(
635+
{
636+
"id": [0, 1, 2, 3, 4, 5],
637+
"value": ["p0_r0", "p0_r1", "p1_r0", "p1_r1", "p2_r0", "p2_r1"],
638+
}
639+
)
640+
bf_df = session.read_pandas(test_data)
641+
642+
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
643+
widget = TableWidget(bf_df)
644+
widget.row_count = None
645+
646+
# Navigate to page 1
647+
widget.page = 1
648+
html = widget.table_html
649+
assert "p1_r0" in html
650+
assert "p1_r1" in html
651+
652+
# Navigate to page 2
653+
widget.page = 2
654+
html = widget.table_html
655+
assert "p2_r0" in html
656+
assert "p2_r1" in html
657+
658+
# Navigate beyond available data (page 5)
659+
widget.page = 5
660+
_ = widget.table_html
661+
662+
# Should auto-navigate back to last valid page (page 2)
663+
assert widget.page == 2
664+
665+
666+
def test_widget_with_unknown_row_count_empty_dataframe(
667+
session: bf.Session,
668+
):
669+
"""
670+
Given an empty DataFrame with unknown row count, the widget should
671+
stay on page 0 and display empty content.
672+
"""
673+
from bigframes.display import TableWidget
674+
675+
empty_data = pd.DataFrame(columns=["id", "value"])
676+
bf_df = session.read_pandas(empty_data)
677+
678+
with bf.option_context("display.repr_mode", "anywidget"):
679+
widget = TableWidget(bf_df)
680+
widget.row_count = None
681+
682+
# Attempt to navigate to page 5
683+
widget.page = 5
684+
_ = widget.table_html
685+
686+
# Should stay on page 0 for empty DataFrame
687+
assert widget.page == 0
688+
689+
552690
# TODO(shuowei): Add tests for custom index and multiindex
553691
# This may not be necessary for the SQL Cell use case but should be
554692
# considered for completeness.

0 commit comments

Comments
 (0)