Skip to content

feat: Add CSS styles and page selector to TableWidget #1913

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .github/header-checker-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
"allowedLicenses": ["Apache-2.0", "MIT", "BSD-3"],
"ignoreFiles": ["**/requirements.txt", "**/requirements-test.txt", "**/__init__.py", "samples/**/constraints.txt", "samples/**/constraints-test.txt"],
"sourceFileExtensions": [
"ts",
"js",
"java",
"sh",
"Dockerfile",
"yaml",
"css",
"ts",
"js",
"java",
"sh",
"Dockerfile",
"yaml",
"py",
"html",
"txt"
]
}
}
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: '\.(css|js)$'
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 *.css *.json *.proto *.js py.typed
recursive-include tests *
global-exclude *.py[co]
global-exclude __pycache__
Expand Down
22 changes: 18 additions & 4 deletions bigframes/display/anywidget.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need comprehensive page size validation and batch management

Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
else:
WIDGET_BASE = object

_DEFAULT_PAGE_SIZE = 25

class TableWidget(WIDGET_BASE):
"""
Expand All @@ -63,10 +64,10 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
self._dataframe = dataframe

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

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

# Use list of DataFrames to avoid memory copies from concatenation
self._cached_batches: List[pd.DataFrame] = []
Expand All @@ -82,9 +83,17 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
# TODO(b/428238610): Start iterating over the result of `to_pandas_batches()`
# before we get here so that the count might already be cached.
self.row_count = len(dataframe)
# get the initial page, either through resetting the page_size or
# explicitly.
if page_size != self.page_size:
self.page_size = page_size
else:
self._set_table_html()

# get the initial page
self._set_table_html()
@functools.cached_property
def _css(self):
"""Load JavaScript code from external file."""
return resources.read_text(bigframes.display, "table_widget.css")

@functools.cached_property
def _esm(self):
Expand Down Expand Up @@ -177,3 +186,8 @@ def _set_table_html(self):
def _page_changed(self, change):
"""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):
"""Handler for when the page size is changed from the frontend."""
self._set_table_html()
75 changes: 75 additions & 0 deletions bigframes/display/table_widget.css
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need additional build configuration:
(1) Updates .pre-commit-config.yaml to include CSS files in biome-check
(2) Modifies owlbot.py to include CSS files in the package manifest

Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 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.
*/

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

.footer {
align-items: center;
display: flex;
font-size: 0.8rem;
}

.footer > * {
flex: 1;
}

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

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

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

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;
}

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

button:disabled {
opacity: 0.65;
pointer-events: none;
}
156 changes: 103 additions & 53 deletions bigframes/display/table_widget.js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would need a more robust UI structure:
(1) Creates separate containers for table content and footer with proper CSS classes
(2) Adds row count display showing total rows
(3) Implements page size selector with predefined options [10, 25, 50, 100] and proper selection state
(4) Better organized DOM structure with dedicated containers for pagination and page size controls

Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@
*/

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

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

/**
Expand All @@ -34,62 +35,111 @@ const Event = {
* }} options
*/
function render({ model, el }) {
const container = document.createElement("div");
container.innerHTML = model.get(ModelProperty.TABLE_HTML);
// Structure
const container = document.createElement("div");
const tableContainer = document.createElement("div");
const footer = document.createElement("div");
// Total rows label
const rowCountLabel = document.createElement("div");
// Pagination controls
const paginationContainer = document.createElement("div");
const prevPage = document.createElement("button");
const paginationLabel = document.createElement("span");
const nextPage = document.createElement("button");
// Page size controls
const pageSizeContainer = document.createElement("div");
const pageSizeLabel = document.createElement("label");
const pageSizeSelect = document.createElement("select");

const buttonContainer = document.createElement("div");
const prevPage = document.createElement("button");
const label = document.createElement("span");
const nextPage = document.createElement("button");
tableContainer.classList.add("table-container");
footer.classList.add("footer");
paginationContainer.classList.add("pagination");
pageSizeContainer.classList.add("page-size");

prevPage.type = "button";
nextPage.type = "button";
prevPage.textContent = "Prev";
nextPage.textContent = "Next";
prevPage.type = "button";
nextPage.type = "button";
prevPage.textContent = "Prev";
nextPage.textContent = "Next";

/** Updates the button 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 currentPage = model.get(ModelProperty.PAGE);
pageSizeLabel.textContent = "Page Size";
for (const size of [10, 25, 50, 100]) {
const option = document.createElement('option');
option.value = size;
option.textContent = size;
pageSizeSelect.appendChild(option);
}
pageSizeSelect.value = Number(model.get(ModelProperty.PAGE_SIZE));

label.textContent = `Page ${currentPage + 1} of ${totalPages}`;
prevPage.disabled = currentPage === 0;
nextPage.disabled = currentPage >= totalPages - 1;
}
/** Updates the button states and page label based on the model. */
function updateButtonStates() {
const rowCount = model.get(ModelProperty.ROW_COUNT);
rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`;

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

prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1));
nextPage.addEventListener(Event.CLICK, () => handlePageChange(1));
paginationLabel.textContent = `Page ${currentPage + 1} of ${totalPages}`;
prevPage.disabled = currentPage === 0;
nextPage.disabled = currentPage >= totalPages - 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();
});
/**
* Updates the page in the model.
* @param {number} direction -1 for previous, 1 for next.
*/
function handlePageChange(direction) {
const currentPage = model.get(ModelProperty.PAGE);
const newPage = Math.max(0, currentPage + direction);
if (newPage !== currentPage) {
model.set(ModelProperty.PAGE, newPage);
model.save_changes();
}
}

// Initial setup
updateButtonStates();
/** Handles the page_size in the model.
* @param {number} size - new size to set
*/
function handlePageSizeChange(size) {
const currentSize = model.get(ModelProperty.PAGE_SIZE);
if (size !== currentSize) {
model.set(ModelProperty.PAGE_SIZE, size);
model.save_changes();
}
}

buttonContainer.appendChild(prevPage);
buttonContainer.appendChild(label);
buttonContainer.appendChild(nextPage);
el.appendChild(container);
el.appendChild(buttonContainer);
/** Updates the HTML in the table container **/
function handleTableHTMLChange() {
// Note: Using innerHTML can be a security risk if the content is
// user-generated. Ensure 'table_html' is properly sanitized.
tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML);
updateButtonStates();
}

prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1));
nextPage.addEventListener(Event.CLICK, () => handlePageChange(1));
pageSizeSelect.addEventListener(Event.CHANGE, (e) => {
const newSize = Number(e.target.value);
if (newSize) {
handlePageSizeChange(newSize);
}
});
model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange);

// Initial setup
paginationContainer.appendChild(prevPage);
paginationContainer.appendChild(paginationLabel);
paginationContainer.appendChild(nextPage);
pageSizeContainer.appendChild(pageSizeLabel);
pageSizeContainer.appendChild(pageSizeSelect);
footer.appendChild(rowCountLabel);
footer.appendChild(paginationContainer);
footer.appendChild(pageSizeContainer);
container.appendChild(tableContainer);
container.appendChild(footer);
el.appendChild(container);
handleTableHTMLChange();
}

export default { render };
6 changes: 3 additions & 3 deletions owlbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@
"recursive-include third_party/bigframes_vendored *\nrecursive-include bigframes",
)

# Include JavaScript files for display widgets
# Include CSS and JS files for display widgets
assert 1 == s.replace( # MANIFEST.in
["MANIFEST.in"],
re.escape("recursive-include bigframes *.json *.proto py.typed"),
"recursive-include bigframes *.json *.proto *.js py.typed",
re.escape("recursive-include bigframes *.css *.json *.proto py.typed"),
"recursive-include bigframes *.css *.json *.proto *.js py.typed",
)

# Fixup the documentation.
Expand Down
Loading
Loading