Skip to content

feat: Add CSS styling for TableWidget pagination interface #1934

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 24, 2025
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ repos:
rev: v2.0.2
hooks:
- id: biome-check
files: '\.js$'
files: '\.(js|css)$'
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# Generated by synthtool. DO NOT EDIT!
include README.rst LICENSE
recursive-include third_party/bigframes_vendored *
recursive-include bigframes *.json *.proto *.js py.typed
recursive-include bigframes *.json *.proto *.js *.css py.typed
recursive-include tests *
global-exclude *.py[co]
global-exclude __pycache__
Expand Down
76 changes: 62 additions & 14 deletions bigframes/display/anywidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,20 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
super().__init__()
self._dataframe = dataframe

# respect display options
self.page_size = bigframes.options.display.max_rows

# Initialize data fetching attributes.
self._batches = dataframe.to_pandas_batches(page_size=self.page_size)

# Use list of DataFrames to avoid memory copies from concatenation
self._cached_batches: List[pd.DataFrame] = []

# Unique identifier for HTML table element
# Initialize attributes that might be needed by observers FIRST
self._table_id = str(uuid.uuid4())
self._all_data_loaded = False
# Renamed from _batch_iterator to _batch_iter to avoid naming conflict
self._batch_iter: Optional[Iterator[pd.DataFrame]] = None
self._cached_batches: List[pd.DataFrame] = []

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

# Initialize data fetching attributes.
self._batches = dataframe.to_pandas_batches(page_size=initial_page_size)

# set traitlets properties that trigger observers
self.page_size = initial_page_size

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

@functools.cached_property
def _css(self):
"""Load CSS code from external file."""
return resources.read_text(bigframes.display, "table_widget.css")

page = traitlets.Int(0).tag(sync=True)
page_size = traitlets.Int(25).tag(sync=True)
row_count = traitlets.Int(0).tag(sync=True)
table_html = traitlets.Unicode().tag(sync=True)

@traitlets.validate("page")
def _validate_page(self, proposal: Dict[str, Any]):
def _validate_page(self, proposal: Dict[str, Any]) -> int:
"""Validate and clamp the page number to a valid range.

Args:
proposal: A dictionary from the traitlets library containing the
proposed change. The new value is in proposal["value"].

Returns:
The validated and clamped page number as an integer.
"""

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

@traitlets.validate("page_size")
def _validate_page_size(self, proposal: Dict[str, Any]) -> int:
"""Validate page size to ensure it's positive and reasonable.

Args:
proposal: A dictionary from the traitlets library containing the
proposed change. The new value is in proposal["value"].

Returns:
The validated page size as an integer.
"""
value = proposal["value"]

# Ensure page size is positive and within reasonable bounds
if value <= 0:
return self.page_size # Keep current value

# Cap at reasonable maximum to prevent performance issues
max_page_size = 1000
return min(value, max_page_size)

def _get_next_batch(self) -> bool:
"""
Gets the next batch of data from the generator and appends to cache.

Return:
Returns:
True if a batch was successfully loaded, False otherwise.
"""
if self._all_data_loaded:
Expand Down Expand Up @@ -148,6 +177,13 @@ def _cached_data(self) -> pd.DataFrame:
return pd.DataFrame(columns=self._dataframe.columns)
return pd.concat(self._cached_batches, ignore_index=True)

def _reset_batches_for_new_page_size(self):
"""Reset the batch iterator when page size changes."""
self._batches = self._dataframe.to_pandas_batches(page_size=self.page_size)
self._cached_batches = []
self._batch_iter = None
self._all_data_loaded = False

def _set_table_html(self):
"""Sets the current html data based on the current page and page size."""
start = self.page * self.page_size
Expand All @@ -174,6 +210,18 @@ def _set_table_html(self):
)

@traitlets.observe("page")
def _page_changed(self, change):
def _page_changed(self, _change: Dict[str, Any]):
"""Handler for when the page number is changed from the frontend."""
self._set_table_html()

@traitlets.observe("page_size")
def _page_size_changed(self, _change: Dict[str, Any]):
"""Handler for when the page size is changed from the frontend."""
# Reset the page to 0 when page size changes to avoid invalid page states
self.page = 0

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

# Update the table display
self._set_table_html()
76 changes: 76 additions & 0 deletions bigframes/display/table_widget.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

.bigframes-widget .table-container {
max-height: 620px;
overflow: auto;
}

.bigframes-widget .footer {
align-items: center;
display: flex;
font-size: 0.8rem;
padding-top: 8px;
}

.bigframes-widget .footer > * {
flex: 1;
}

.bigframes-widget .pagination {
align-items: center;
display: flex;
flex-direction: row;
gap: 4px;
justify-content: center;
padding: 4px;
}

.bigframes-widget .page-size {
align-items: center;
display: flex;
flex-direction: row;
gap: 4px;
justify-content: end;
}

.bigframes-widget table {
border-collapse: collapse;
text-align: left;
width: 100%;
}

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

.bigframes-widget button {
cursor: pointer;
display: inline-block;
text-align: center;
text-decoration: none;
user-select: none;
vertical-align: middle;
}

.bigframes-widget button:disabled {
opacity: 0.65;
pointer-events: none;
}
130 changes: 98 additions & 32 deletions bigframes/display/table_widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,81 +15,147 @@
*/

const ModelProperty = {
TABLE_HTML: "table_html",
ROW_COUNT: "row_count",
PAGE_SIZE: "page_size",
PAGE: "page",
PAGE_SIZE: "page_size",
ROW_COUNT: "row_count",
TABLE_HTML: "table_html",
};

const Event = {
CHANGE: "change",
CHANGE_TABLE_HTML: `change:${ModelProperty.TABLE_HTML}`,
CLICK: "click",
};

/**
* Renders a paginated table and its controls into a given element.
* Renders the interactive table widget.
* @param {{
* model: !Backbone.Model,
* el: !HTMLElement
* model: any,
* el: HTMLElement
* }} options
*/
function render({ model, el }) {
// Main container with a unique class for CSS scoping
const container = document.createElement("div");
container.innerHTML = model.get(ModelProperty.TABLE_HTML);
container.classList.add("bigframes-widget");

// Structure
const tableContainer = document.createElement("div");
const footer = document.createElement("div");

const buttonContainer = document.createElement("div");
// Footer: Total rows label
const rowCountLabel = document.createElement("div");

// Footer: Pagination controls
const paginationContainer = document.createElement("div");
const prevPage = document.createElement("button");
const label = document.createElement("span");
const paginationLabel = document.createElement("span");
const nextPage = document.createElement("button");

// Footer: Page size controls
const pageSizeContainer = document.createElement("div");
const pageSizeLabel = document.createElement("label");
const pageSizeSelect = document.createElement("select");

// Add CSS classes
tableContainer.classList.add("table-container");
footer.classList.add("footer");
paginationContainer.classList.add("pagination");
pageSizeContainer.classList.add("page-size");

// Configure pagination buttons
prevPage.type = "button";
nextPage.type = "button";
prevPage.textContent = "Prev";
nextPage.textContent = "Next";

/** Updates the button states and page label based on the model. */
// Configure page size selector
pageSizeLabel.textContent = "Page Size";
for (const size of [10, 25, 50, 100]) {
const option = document.createElement("option");
option.value = size;
option.textContent = size;
if (size === model.get(ModelProperty.PAGE_SIZE)) {
option.selected = true;
}
pageSizeSelect.appendChild(option);
}

/** Updates the footer states and page label based on the model. */
function updateButtonStates() {
const totalPages = Math.ceil(
model.get(ModelProperty.ROW_COUNT) / model.get(ModelProperty.PAGE_SIZE),
);
const rowCount = model.get(ModelProperty.ROW_COUNT);
const pageSize = model.get(ModelProperty.PAGE_SIZE);
const currentPage = model.get(ModelProperty.PAGE);
const totalPages = Math.ceil(rowCount / pageSize);

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

/**
* Updates the page in the model.
* @param {number} direction -1 for previous, 1 for next.
* Increments or decrements the page in the model.
* @param {number} direction - `1` for next, `-1` for previous.
*/
function handlePageChange(direction) {
const currentPage = model.get(ModelProperty.PAGE);
const newPage = Math.max(0, currentPage + direction);
if (newPage !== currentPage) {
model.set(ModelProperty.PAGE, newPage);
const current = model.get(ModelProperty.PAGE);
const next = current + direction;
model.set(ModelProperty.PAGE, next);
model.save_changes();
}

/**
* Handles changes to the page size from the dropdown.
* @param {number} size - The new page size.
*/
function handlePageSizeChange(size) {
const currentSize = model.get(ModelProperty.PAGE_SIZE);
if (size !== currentSize) {
model.set(ModelProperty.PAGE_SIZE, size);
model.save_changes();
}
}

/** Updates the HTML in the table container and refreshes button states. */
function handleTableHTMLChange() {
// Note: Using innerHTML is safe here because the content is generated
// by a trusted backend (DataFrame.to_html).
tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML);
updateButtonStates();
}

// Add event listeners
prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1));
nextPage.addEventListener(Event.CLICK, () => handlePageChange(1));

model.on(Event.CHANGE_TABLE_HTML, () => {
// Note: Using innerHTML can be a security risk if the content is
// user-generated. Ensure 'table_html' is properly sanitized.
container.innerHTML = model.get(ModelProperty.TABLE_HTML);
updateButtonStates();
pageSizeSelect.addEventListener(Event.CHANGE, (e) => {
const newSize = Number(e.target.value);
if (newSize) {
handlePageSizeChange(newSize);
}
});
model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange);

// Assemble the DOM
paginationContainer.appendChild(prevPage);
paginationContainer.appendChild(paginationLabel);
paginationContainer.appendChild(nextPage);

// Initial setup
updateButtonStates();
pageSizeContainer.appendChild(pageSizeLabel);
pageSizeContainer.appendChild(pageSizeSelect);

footer.appendChild(rowCountLabel);
footer.appendChild(paginationContainer);
footer.appendChild(pageSizeContainer);

container.appendChild(tableContainer);
container.appendChild(footer);

buttonContainer.appendChild(prevPage);
buttonContainer.appendChild(label);
buttonContainer.appendChild(nextPage);
el.appendChild(container);
el.appendChild(buttonContainer);

// Initial render
handleTableHTMLChange();
}

export default { render };
Loading