Skip to content

Commit 5b232d7

Browse files
feat: Add CSS styling for TableWidget pagination interface (#1934)
* Beautify buttons and tables * introduce css file * change the size * change the order of initialization sequence * final touch up * add CSS scoping * Add css related testcase * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
1 parent bbbcaf3 commit 5b232d7

File tree

8 files changed

+371
-76
lines changed

8 files changed

+371
-76
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,4 @@ repos:
4646
rev: v2.0.2
4747
hooks:
4848
- id: biome-check
49-
files: '\.js$'
49+
files: '\.(js|css)$'

MANIFEST.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
# Generated by synthtool. DO NOT EDIT!
1818
include README.rst LICENSE
1919
recursive-include third_party/bigframes_vendored *
20-
recursive-include bigframes *.json *.proto *.js py.typed
20+
recursive-include bigframes *.json *.proto *.js *.css py.typed
2121
recursive-include tests *
2222
global-exclude *.py[co]
2323
global-exclude __pycache__

bigframes/display/anywidget.py

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,20 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
6262
super().__init__()
6363
self._dataframe = dataframe
6464

65-
# respect display options
66-
self.page_size = bigframes.options.display.max_rows
67-
68-
# Initialize data fetching attributes.
69-
self._batches = dataframe.to_pandas_batches(page_size=self.page_size)
70-
71-
# Use list of DataFrames to avoid memory copies from concatenation
72-
self._cached_batches: List[pd.DataFrame] = []
73-
74-
# Unique identifier for HTML table element
65+
# Initialize attributes that might be needed by observers FIRST
7566
self._table_id = str(uuid.uuid4())
7667
self._all_data_loaded = False
77-
# Renamed from _batch_iterator to _batch_iter to avoid naming conflict
7868
self._batch_iter: Optional[Iterator[pd.DataFrame]] = None
69+
self._cached_batches: List[pd.DataFrame] = []
70+
71+
# respect display options for initial page size
72+
initial_page_size = bigframes.options.display.max_rows
73+
74+
# Initialize data fetching attributes.
75+
self._batches = dataframe.to_pandas_batches(page_size=initial_page_size)
76+
77+
# set traitlets properties that trigger observers
78+
self.page_size = initial_page_size
7979

8080
# len(dataframe) is expensive, since it will trigger a
8181
# SELECT COUNT(*) query. It is a must have however.
@@ -91,18 +91,26 @@ def _esm(self):
9191
"""Load JavaScript code from external file."""
9292
return resources.read_text(bigframes.display, "table_widget.js")
9393

94+
@functools.cached_property
95+
def _css(self):
96+
"""Load CSS code from external file."""
97+
return resources.read_text(bigframes.display, "table_widget.css")
98+
9499
page = traitlets.Int(0).tag(sync=True)
95100
page_size = traitlets.Int(25).tag(sync=True)
96101
row_count = traitlets.Int(0).tag(sync=True)
97102
table_html = traitlets.Unicode().tag(sync=True)
98103

99104
@traitlets.validate("page")
100-
def _validate_page(self, proposal: Dict[str, Any]):
105+
def _validate_page(self, proposal: Dict[str, Any]) -> int:
101106
"""Validate and clamp the page number to a valid range.
102107
103108
Args:
104109
proposal: A dictionary from the traitlets library containing the
105110
proposed change. The new value is in proposal["value"].
111+
112+
Returns:
113+
The validated and clamped page number as an integer.
106114
"""
107115

108116
value = proposal["value"]
@@ -115,11 +123,32 @@ def _validate_page(self, proposal: Dict[str, Any]):
115123
# Clamp the proposed value to the valid range [0, max_page].
116124
return max(0, min(value, max_page))
117125

126+
@traitlets.validate("page_size")
127+
def _validate_page_size(self, proposal: Dict[str, Any]) -> int:
128+
"""Validate page size to ensure it's positive and reasonable.
129+
130+
Args:
131+
proposal: A dictionary from the traitlets library containing the
132+
proposed change. The new value is in proposal["value"].
133+
134+
Returns:
135+
The validated page size as an integer.
136+
"""
137+
value = proposal["value"]
138+
139+
# Ensure page size is positive and within reasonable bounds
140+
if value <= 0:
141+
return self.page_size # Keep current value
142+
143+
# Cap at reasonable maximum to prevent performance issues
144+
max_page_size = 1000
145+
return min(value, max_page_size)
146+
118147
def _get_next_batch(self) -> bool:
119148
"""
120149
Gets the next batch of data from the generator and appends to cache.
121150
122-
Return:
151+
Returns:
123152
True if a batch was successfully loaded, False otherwise.
124153
"""
125154
if self._all_data_loaded:
@@ -148,6 +177,13 @@ def _cached_data(self) -> pd.DataFrame:
148177
return pd.DataFrame(columns=self._dataframe.columns)
149178
return pd.concat(self._cached_batches, ignore_index=True)
150179

180+
def _reset_batches_for_new_page_size(self):
181+
"""Reset the batch iterator when page size changes."""
182+
self._batches = self._dataframe.to_pandas_batches(page_size=self.page_size)
183+
self._cached_batches = []
184+
self._batch_iter = None
185+
self._all_data_loaded = False
186+
151187
def _set_table_html(self):
152188
"""Sets the current html data based on the current page and page size."""
153189
start = self.page * self.page_size
@@ -174,6 +210,18 @@ def _set_table_html(self):
174210
)
175211

176212
@traitlets.observe("page")
177-
def _page_changed(self, change):
213+
def _page_changed(self, _change: Dict[str, Any]):
178214
"""Handler for when the page number is changed from the frontend."""
179215
self._set_table_html()
216+
217+
@traitlets.observe("page_size")
218+
def _page_size_changed(self, _change: Dict[str, Any]):
219+
"""Handler for when the page size is changed from the frontend."""
220+
# Reset the page to 0 when page size changes to avoid invalid page states
221+
self.page = 0
222+
223+
# Reset batches to use new page size for future data fetching
224+
self._reset_batches_for_new_page_size()
225+
226+
# Update the table display
227+
self._set_table_html()

bigframes/display/table_widget.css

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
.bigframes-widget .table-container {
18+
max-height: 620px;
19+
overflow: auto;
20+
}
21+
22+
.bigframes-widget .footer {
23+
align-items: center;
24+
display: flex;
25+
font-size: 0.8rem;
26+
padding-top: 8px;
27+
}
28+
29+
.bigframes-widget .footer > * {
30+
flex: 1;
31+
}
32+
33+
.bigframes-widget .pagination {
34+
align-items: center;
35+
display: flex;
36+
flex-direction: row;
37+
gap: 4px;
38+
justify-content: center;
39+
padding: 4px;
40+
}
41+
42+
.bigframes-widget .page-size {
43+
align-items: center;
44+
display: flex;
45+
flex-direction: row;
46+
gap: 4px;
47+
justify-content: end;
48+
}
49+
50+
.bigframes-widget table {
51+
border-collapse: collapse;
52+
text-align: left;
53+
width: 100%;
54+
}
55+
56+
.bigframes-widget th {
57+
background-color: var(--colab-primary-surface-color, var(--jp-layout-color0));
58+
/* Uncomment once we support sorting: cursor: pointer; */
59+
position: sticky;
60+
top: 0;
61+
z-index: 1;
62+
}
63+
64+
.bigframes-widget button {
65+
cursor: pointer;
66+
display: inline-block;
67+
text-align: center;
68+
text-decoration: none;
69+
user-select: none;
70+
vertical-align: middle;
71+
}
72+
73+
.bigframes-widget button:disabled {
74+
opacity: 0.65;
75+
pointer-events: none;
76+
}

bigframes/display/table_widget.js

Lines changed: 98 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,81 +15,147 @@
1515
*/
1616

1717
const ModelProperty = {
18-
TABLE_HTML: "table_html",
19-
ROW_COUNT: "row_count",
20-
PAGE_SIZE: "page_size",
2118
PAGE: "page",
19+
PAGE_SIZE: "page_size",
20+
ROW_COUNT: "row_count",
21+
TABLE_HTML: "table_html",
2222
};
2323

2424
const Event = {
25+
CHANGE: "change",
2526
CHANGE_TABLE_HTML: `change:${ModelProperty.TABLE_HTML}`,
2627
CLICK: "click",
2728
};
2829

2930
/**
30-
* Renders a paginated table and its controls into a given element.
31+
* Renders the interactive table widget.
3132
* @param {{
32-
* model: !Backbone.Model,
33-
* el: !HTMLElement
33+
* model: any,
34+
* el: HTMLElement
3435
* }} options
3536
*/
3637
function render({ model, el }) {
38+
// Main container with a unique class for CSS scoping
3739
const container = document.createElement("div");
38-
container.innerHTML = model.get(ModelProperty.TABLE_HTML);
40+
container.classList.add("bigframes-widget");
41+
42+
// Structure
43+
const tableContainer = document.createElement("div");
44+
const footer = document.createElement("div");
3945

40-
const buttonContainer = document.createElement("div");
46+
// Footer: Total rows label
47+
const rowCountLabel = document.createElement("div");
48+
49+
// Footer: Pagination controls
50+
const paginationContainer = document.createElement("div");
4151
const prevPage = document.createElement("button");
42-
const label = document.createElement("span");
52+
const paginationLabel = document.createElement("span");
4353
const nextPage = document.createElement("button");
4454

55+
// Footer: Page size controls
56+
const pageSizeContainer = document.createElement("div");
57+
const pageSizeLabel = document.createElement("label");
58+
const pageSizeSelect = document.createElement("select");
59+
60+
// Add CSS classes
61+
tableContainer.classList.add("table-container");
62+
footer.classList.add("footer");
63+
paginationContainer.classList.add("pagination");
64+
pageSizeContainer.classList.add("page-size");
65+
66+
// Configure pagination buttons
4567
prevPage.type = "button";
4668
nextPage.type = "button";
4769
prevPage.textContent = "Prev";
4870
nextPage.textContent = "Next";
4971

50-
/** Updates the button states and page label based on the model. */
72+
// Configure page size selector
73+
pageSizeLabel.textContent = "Page Size";
74+
for (const size of [10, 25, 50, 100]) {
75+
const option = document.createElement("option");
76+
option.value = size;
77+
option.textContent = size;
78+
if (size === model.get(ModelProperty.PAGE_SIZE)) {
79+
option.selected = true;
80+
}
81+
pageSizeSelect.appendChild(option);
82+
}
83+
84+
/** Updates the footer states and page label based on the model. */
5185
function updateButtonStates() {
52-
const totalPages = Math.ceil(
53-
model.get(ModelProperty.ROW_COUNT) / model.get(ModelProperty.PAGE_SIZE),
54-
);
86+
const rowCount = model.get(ModelProperty.ROW_COUNT);
87+
const pageSize = model.get(ModelProperty.PAGE_SIZE);
5588
const currentPage = model.get(ModelProperty.PAGE);
89+
const totalPages = Math.ceil(rowCount / pageSize);
5690

57-
label.textContent = `Page ${currentPage + 1} of ${totalPages}`;
91+
rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`;
92+
paginationLabel.textContent = `Page ${currentPage + 1} of ${totalPages || 1}`;
5893
prevPage.disabled = currentPage === 0;
5994
nextPage.disabled = currentPage >= totalPages - 1;
95+
pageSizeSelect.value = pageSize;
6096
}
6197

6298
/**
63-
* Updates the page in the model.
64-
* @param {number} direction -1 for previous, 1 for next.
99+
* Increments or decrements the page in the model.
100+
* @param {number} direction - `1` for next, `-1` for previous.
65101
*/
66102
function handlePageChange(direction) {
67-
const currentPage = model.get(ModelProperty.PAGE);
68-
const newPage = Math.max(0, currentPage + direction);
69-
if (newPage !== currentPage) {
70-
model.set(ModelProperty.PAGE, newPage);
103+
const current = model.get(ModelProperty.PAGE);
104+
const next = current + direction;
105+
model.set(ModelProperty.PAGE, next);
106+
model.save_changes();
107+
}
108+
109+
/**
110+
* Handles changes to the page size from the dropdown.
111+
* @param {number} size - The new page size.
112+
*/
113+
function handlePageSizeChange(size) {
114+
const currentSize = model.get(ModelProperty.PAGE_SIZE);
115+
if (size !== currentSize) {
116+
model.set(ModelProperty.PAGE_SIZE, size);
71117
model.save_changes();
72118
}
73119
}
74120

121+
/** Updates the HTML in the table container and refreshes button states. */
122+
function handleTableHTMLChange() {
123+
// Note: Using innerHTML is safe here because the content is generated
124+
// by a trusted backend (DataFrame.to_html).
125+
tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML);
126+
updateButtonStates();
127+
}
128+
129+
// Add event listeners
75130
prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1));
76131
nextPage.addEventListener(Event.CLICK, () => handlePageChange(1));
77-
78-
model.on(Event.CHANGE_TABLE_HTML, () => {
79-
// Note: Using innerHTML can be a security risk if the content is
80-
// user-generated. Ensure 'table_html' is properly sanitized.
81-
container.innerHTML = model.get(ModelProperty.TABLE_HTML);
82-
updateButtonStates();
132+
pageSizeSelect.addEventListener(Event.CHANGE, (e) => {
133+
const newSize = Number(e.target.value);
134+
if (newSize) {
135+
handlePageSizeChange(newSize);
136+
}
83137
});
138+
model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange);
139+
140+
// Assemble the DOM
141+
paginationContainer.appendChild(prevPage);
142+
paginationContainer.appendChild(paginationLabel);
143+
paginationContainer.appendChild(nextPage);
84144

85-
// Initial setup
86-
updateButtonStates();
145+
pageSizeContainer.appendChild(pageSizeLabel);
146+
pageSizeContainer.appendChild(pageSizeSelect);
147+
148+
footer.appendChild(rowCountLabel);
149+
footer.appendChild(paginationContainer);
150+
footer.appendChild(pageSizeContainer);
151+
152+
container.appendChild(tableContainer);
153+
container.appendChild(footer);
87154

88-
buttonContainer.appendChild(prevPage);
89-
buttonContainer.appendChild(label);
90-
buttonContainer.appendChild(nextPage);
91155
el.appendChild(container);
92-
el.appendChild(buttonContainer);
156+
157+
// Initial render
158+
handleTableHTMLChange();
93159
}
94160

95161
export default { render };

0 commit comments

Comments
 (0)