From aec3929d0d9bc9a9c8dc399f2e7cf706e67ca4c7 Mon Sep 17 00:00:00 2001 From: Jason Prodonovich Date: Wed, 16 Jul 2025 16:53:57 -0400 Subject: [PATCH 1/2] Add CSS styles and page size handling to TableWidget --- bigframes/display/anywidget.py | 10 ++ bigframes/display/table_widget.css | 75 ++++++++++++++ bigframes/display/table_widget.js | 156 +++++++++++++++++++---------- 3 files changed, 188 insertions(+), 53 deletions(-) create mode 100644 bigframes/display/table_widget.css diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 04d82c97fe..292d1e1999 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -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.""" @@ -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() diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css new file mode 100644 index 0000000000..f1ecb0c5c6 --- /dev/null +++ b/bigframes/display/table_widget.css @@ -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; +} diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 71484af4d5..4effb1befa 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -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", }; /** @@ -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 }; From 034ae8f0be69e307c908ef361dad7080b44b7674 Mon Sep 17 00:00:00 2001 From: Jason Prodonovich Date: Thu, 17 Jul 2025 07:59:19 -0400 Subject: [PATCH 2/2] Update test --- tests/system/small/test_anywidget.py | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index b6dfb22934..9922e00793 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -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):