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 2 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
10 changes: 10 additions & 0 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 @@ -86,6 +86,11 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
# 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):
"""Load JavaScript code from external file."""
Expand Down Expand Up @@ -177,3 +182,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 };
34 changes: 17 additions & 17 deletions tests/system/small/test_anywidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,28 +291,28 @@ def test_widget_with_few_rows_should_have_only_one_page(small_widget):
assert small_widget.page == 0


def test_widget_page_size_should_be_immutable_after_creation(
paginated_bf_df: bf.dataframe.DataFrame,
def test_widget_page_size_should_be_mutable_after_creation(
table_widget, paginated_pandas_df: pd.DataFrame
):
"""
A widget's page size should be fixed on creation and not be affected
by subsequent changes to global options.
A widget's page size can be changed after creation.
"""
with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2):
from bigframes.display import TableWidget

widget = TableWidget(paginated_bf_df)
assert widget.page_size == 2

# Navigate to second page to ensure widget is in a non-default state
widget.page = 1
assert widget.page == 1
# Check initial state
assert table_widget.page_size == 2
expected_slice = paginated_pandas_df.iloc[0:2]
_assert_html_matches_pandas_slice(
table_widget.table_html, expected_slice, paginated_pandas_df
)

# Change global max_rows - widget should not be affected
bf.options.display.max_rows = 10
# Change page size
table_widget.page_size = 3
assert table_widget.page_size == 3

assert widget.page_size == 2 # Should remain unchanged
assert widget.page == 1 # Should remain on same page
# Check that the table content updates
expected_slice = paginated_pandas_df.iloc[0:3]
_assert_html_matches_pandas_slice(
table_widget.table_html, expected_slice, paginated_pandas_df
)


def test_empty_widget_should_have_zero_row_count(empty_bf_df: bf.dataframe.DataFrame):
Expand Down
Loading