diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 6a16a9f762..be0d2b45d0 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -66,6 +66,7 @@ class TableWidget(_WIDGET_BASE): page = traitlets.Int(0).tag(sync=True) page_size = traitlets.Int(0).tag(sync=True) + max_columns = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) table_html = traitlets.Unicode("").tag(sync=True) sort_context = traitlets.List(traitlets.Dict(), default_value=[]).tag(sync=True) @@ -103,10 +104,13 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # respect display options for initial page size initial_page_size = bigframes.options.display.max_rows + initial_max_columns = bigframes.options.display.max_columns # set traitlets properties that trigger observers # TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns. self.page_size = initial_page_size + self.max_columns = initial_max_columns + # TODO(b/469861913): Nested columns from structs (e.g., 'struct_col.name') are not currently sortable. # TODO(b/463754889): Support non-string column labels for sorting. if all(isinstance(col, str) for col in dataframe.columns): @@ -218,6 +222,14 @@ def _validate_page_size(self, proposal: dict[str, Any]) -> int: max_page_size = 1000 return min(value, max_page_size) + @traitlets.validate("max_columns") + def _validate_max_columns(self, proposal: dict[str, Any]) -> int: + """Validate max columns to ensure it's positive or 0 (for all).""" + value = proposal["value"] + if value is None: + return 0 # Normalize None to 0 for traitlet + return max(0, value) + def _get_next_batch(self) -> bool: """ Gets the next batch of data from the generator and appends to cache. @@ -348,6 +360,7 @@ def _set_table_html(self) -> None: dataframe=page_data, table_id=f"table-{self._table_id}", orderable_columns=self.orderable_columns, + max_columns=self.max_columns, ) if new_page is not None: @@ -382,3 +395,10 @@ def _page_size_changed(self, _change: dict[str, Any]) -> None: # Update the table display self._set_table_html() + + @traitlets.observe("max_columns") + def _max_columns_changed(self, _change: dict[str, Any]) -> None: + """Handler for when max columns is changed from the frontend.""" + if not self._initial_load_complete: + return + self._set_table_html() diff --git a/bigframes/display/html.py b/bigframes/display/html.py index 912f1d7e3a..6102d1512c 100644 --- a/bigframes/display/html.py +++ b/bigframes/display/html.py @@ -46,21 +46,51 @@ def render_html( dataframe: pd.DataFrame, table_id: str, orderable_columns: list[str] | None = None, + max_columns: int | None = None, ) -> str: """Render a pandas DataFrame to HTML with specific styling.""" orderable_columns = orderable_columns or [] classes = "dataframe table table-striped table-hover" table_html_parts = [f''] - table_html_parts.append(_render_table_header(dataframe, orderable_columns)) - table_html_parts.append(_render_table_body(dataframe)) + + # Handle column truncation + columns = list(dataframe.columns) + if max_columns is not None and max_columns > 0 and len(columns) > max_columns: + half = max_columns // 2 + left_columns = columns[:half] + # Ensure we don't take more than available if half is 0 or calculation is weird, + # but typical case is safe. + right_count = max_columns - half + right_columns = columns[-right_count:] if right_count > 0 else [] + show_ellipsis = True + else: + left_columns = columns + right_columns = [] + show_ellipsis = False + + table_html_parts.append( + _render_table_header( + dataframe, orderable_columns, left_columns, right_columns, show_ellipsis + ) + ) + table_html_parts.append( + _render_table_body(dataframe, left_columns, right_columns, show_ellipsis) + ) table_html_parts.append("
") return "".join(table_html_parts) -def _render_table_header(dataframe: pd.DataFrame, orderable_columns: list[str]) -> str: +def _render_table_header( + dataframe: pd.DataFrame, + orderable_columns: list[str], + left_columns: list[Any], + right_columns: list[Any], + show_ellipsis: bool, +) -> str: """Render the header of the HTML table.""" header_parts = [" ", " "] - for col in dataframe.columns: + + def render_col_header(col): th_classes = [] if col in orderable_columns: th_classes.append("sortable") @@ -69,11 +99,28 @@ def _render_table_header(dataframe: pd.DataFrame, orderable_columns: list[str]) f'
' f"{html.escape(str(col))}
" ) + + for col in left_columns: + render_col_header(col) + + if show_ellipsis: + header_parts.append( + '
...
' + ) + + for col in right_columns: + render_col_header(col) + header_parts.extend([" ", " "]) return "\n".join(header_parts) -def _render_table_body(dataframe: pd.DataFrame) -> str: +def _render_table_body( + dataframe: pd.DataFrame, + left_columns: list[Any], + right_columns: list[Any], + show_ellipsis: bool, +) -> str: """Render the body of the HTML table.""" body_parts = [" "] precision = options.display.precision @@ -81,7 +128,9 @@ def _render_table_body(dataframe: pd.DataFrame) -> str: for i in range(len(dataframe)): body_parts.append(" ") row = dataframe.iloc[i] - for col_name, value in row.items(): + + def render_col_cell(col_name): + value = row[col_name] dtype = dataframe.dtypes.loc[col_name] # type: ignore align = "right" if _is_dtype_numeric(dtype) else "left" @@ -101,6 +150,17 @@ def _render_table_body(dataframe: pd.DataFrame) -> str: f' ' f"{html.escape(cell_content)}" ) + + for col in left_columns: + render_col_cell(col) + + if show_ellipsis: + # Ellipsis cell + body_parts.append(' ...') + + for col in right_columns: + render_col_cell(col) + body_parts.append(" ") body_parts.append(" ") return "\n".join(body_parts) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index b02caa004e..da0a701d69 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -117,15 +117,24 @@ body[data-theme='dark'] .bigframes-widget.bigframes-widget { margin: 0 8px; } -.bigframes-widget .page-size { +.bigframes-widget .settings { align-items: center; display: flex; flex-direction: row; - gap: 4px; + gap: 16px; justify-content: end; } -.bigframes-widget .page-size label { +.bigframes-widget .page-size, +.bigframes-widget .max-columns { + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; +} + +.bigframes-widget .page-size label, +.bigframes-widget .max-columns label { margin-right: 8px; } diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 40a027a8bc..6beaf47c21 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -22,6 +22,7 @@ const ModelProperty = { ROW_COUNT: 'row_count', SORT_CONTEXT: 'sort_context', TABLE_HTML: 'table_html', + MAX_COLUMNS: 'max_columns', }; const Event = { @@ -71,6 +72,10 @@ function render({ model, el }) { attributeFilter: ['class', 'data-theme', 'data-vscode-theme-kind'], }); + // Settings controls container + const settingsContainer = document.createElement('div'); + settingsContainer.classList.add('settings'); + // Pagination controls const paginationContainer = document.createElement('div'); paginationContainer.classList.add('pagination'); @@ -102,6 +107,32 @@ function render({ model, el }) { pageSizeInput.appendChild(option); } + // Max columns controls + const maxColumnsContainer = document.createElement('div'); + maxColumnsContainer.classList.add('max-columns'); + const maxColumnsLabel = document.createElement('label'); + const maxColumnsInput = document.createElement('select'); + + maxColumnsLabel.textContent = 'Max columns:'; + + // 0 represents "All" (all columns) + const maxColumnOptions = [5, 10, 15, 20, 0]; + for (const cols of maxColumnOptions) { + const option = document.createElement('option'); + option.value = cols; + option.textContent = cols === 0 ? 'All' : cols; + + const currentMax = model.get(ModelProperty.MAX_COLUMNS); + // Handle None/null from python as 0/All + const currentMaxVal = + currentMax === null || currentMax === undefined ? 0 : currentMax; + + if (cols === currentMaxVal) { + option.selected = true; + } + maxColumnsInput.appendChild(option); + } + function updateButtonStates() { const currentPage = model.get(ModelProperty.PAGE); const pageSize = model.get(ModelProperty.PAGE_SIZE); @@ -259,6 +290,12 @@ function render({ model, el }) { } }); + maxColumnsInput.addEventListener(Event.CHANGE, (e) => { + const newVal = Number(e.target.value); + model.set(ModelProperty.MAX_COLUMNS, newVal); + model.save_changes(); + }); + model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates); model.on(`change:${ModelProperty.ERROR_MESSAGE}`, handleErrorMessageChange); @@ -270,11 +307,19 @@ function render({ model, el }) { paginationContainer.appendChild(prevPage); paginationContainer.appendChild(pageIndicator); paginationContainer.appendChild(nextPage); + pageSizeContainer.appendChild(pageSizeLabel); pageSizeContainer.appendChild(pageSizeInput); + + maxColumnsContainer.appendChild(maxColumnsLabel); + maxColumnsContainer.appendChild(maxColumnsInput); + + settingsContainer.appendChild(maxColumnsContainer); + settingsContainer.appendChild(pageSizeContainer); + footer.appendChild(rowCountLabel); footer.appendChild(paginationContainer); - footer.appendChild(pageSizeContainer); + footer.appendChild(settingsContainer); el.appendChild(errorContainer); el.appendChild(tableContainer); diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index a25acd5d28..bf40dd77c5 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -51,7 +51,8 @@ "- **Rich DataFrames & Series:** Both DataFrames and Series are displayed as interactive widgets.\n", "- **Pagination:** Navigate through large datasets page by page without overwhelming the output.\n", "- **Column Sorting:** Click column headers to toggle between ascending, descending, and unsorted views. Use **Shift + Click** to sort by multiple columns.\n", - "- **Column Resizing:** Drag the dividers between column headers to adjust their width." + "- **Column Resizing:** Drag the dividers between column headers to adjust their width.\n", + "- **Max Columns Control:** Limit the number of displayed columns to improve performance and readability for wide datasets." ] }, { @@ -511,7 +512,10 @@ "metadata": {}, "source": [ "### Adjustable Column Widths\n", - "You can easily adjust the width of any column in the table. Simply hover your mouse over the vertical dividers between column headers. When the cursor changes to a resize icon, click and drag to expand or shrink the column to your desired width. This allows for better readability and customization of your table view." + "You can easily adjust the width of any column in the table. Simply hover your mouse over the vertical dividers between column headers. When the cursor changes to a resize icon, click and drag to expand or shrink the column to your desired width. This allows for better readability and customization of your table view.\n", + "\n", + "### Control Maximum Columns\n", + "You can control the number of columns displayed in the widget using the **Max columns** dropdown in the footer. This is useful for wide DataFrames where you want to focus on a subset of columns or improve rendering performance. Options include 3, 5, 7, 10, 20, or All." ] }, { diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js index 5843694617..e392b38270 100644 --- a/tests/js/table_widget.test.js +++ b/tests/js/table_widget.test.js @@ -335,4 +335,149 @@ describe('TableWidget', () => { expect(headers[0].textContent).toBe(''); expect(headers[1].textContent).toBe('value'); }); + + /* + * Tests that the widget correctly renders HTML with truncated columns (ellipsis) + * and ensures that the ellipsis column is not treated as a sortable column. + */ + it('should render truncated columns with ellipsis and not make ellipsis sortable', () => { + // Mock HTML with truncated columns + // Use the structure produced by the python backend + const mockHtml = ` + + + + + + + + + + + + + + + +
col1
...
col10
1...10
+ `; + + model.get.mockImplementation((property) => { + if (property === 'table_html') { + return mockHtml; + } + if (property === 'orderable_columns') { + // Only actual columns are orderable + return ['col1', 'col10']; + } + if (property === 'sort_context') { + return []; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === 'change:table_html', + )[1]; + tableHtmlChangeHandler(); + + const headers = el.querySelectorAll('th'); + expect(headers).toHaveLength(3); + + // Check col1 (sortable) + const col1Header = headers[0]; + const col1Indicator = col1Header.querySelector('.sort-indicator'); + expect(col1Indicator).not.toBeNull(); // Should exist (hidden by default) + + // Check ellipsis (not sortable) + const ellipsisHeader = headers[1]; + const ellipsisIndicator = ellipsisHeader.querySelector('.sort-indicator'); + // The render function adds sort indicators only if the column name matches an entry in orderable_columns. + // The ellipsis header content is "..." which is not in ['col1', 'col10']. + expect(ellipsisIndicator).toBeNull(); + + // Check col10 (sortable) + const col10Header = headers[2]; + const col10Indicator = col10Header.querySelector('.sort-indicator'); + expect(col10Indicator).not.toBeNull(); + }); + + describe('Max columns', () => { + /* + * Tests for the max columns dropdown functionality. + */ + + it('should render the max columns dropdown', () => { + // Mock basic state + model.get.mockImplementation((property) => { + if (property === 'max_columns') { + return 20; + } + return null; + }); + + render({ model, el }); + + const maxColumnsContainer = el.querySelector('.max-columns'); + expect(maxColumnsContainer).not.toBeNull(); + const label = maxColumnsContainer.querySelector('label'); + expect(label.textContent).toBe('Max columns:'); + const select = maxColumnsContainer.querySelector('select'); + expect(select).not.toBeNull(); + }); + + it('should select the correct initial value', () => { + const initialMaxColumns = 20; + model.get.mockImplementation((property) => { + if (property === 'max_columns') { + return initialMaxColumns; + } + return null; + }); + + render({ model, el }); + + const select = el.querySelector('.max-columns select'); + expect(Number(select.value)).toBe(initialMaxColumns); + }); + + it('should handle None/null initial value as 0 (All)', () => { + model.get.mockImplementation((property) => { + if (property === 'max_columns') { + return null; // Python None is null in JS + } + return null; + }); + + render({ model, el }); + + const select = el.querySelector('.max-columns select'); + expect(Number(select.value)).toBe(0); + expect(select.options[select.selectedIndex].textContent).toBe('All'); + }); + + it('should update model when value changes', () => { + model.get.mockImplementation((property) => { + if (property === 'max_columns') { + return 20; + } + return null; + }); + + render({ model, el }); + + const select = el.querySelector('.max-columns select'); + + // Change to 10 + select.value = '10'; + const event = new Event('change'); + select.dispatchEvent(event); + + expect(model.set).toHaveBeenCalledWith('max_columns', 10); + expect(model.save_changes).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/display/test_html.py b/tests/unit/display/test_html.py index 0762a2fd8d..35a74d098a 100644 --- a/tests/unit/display/test_html.py +++ b/tests/unit/display/test_html.py @@ -148,3 +148,40 @@ def test_render_html_precision(): # Make sure we reset to default html = bf_html.render_html(dataframe=df, table_id="test-table") assert "3.141593" in html + + +def test_render_html_max_columns_truncation(): + # Create a DataFrame with 10 columns + data = {f"col_{i}": [i] for i in range(10)} + df = pd.DataFrame(data) + + # Test max_columns=4 + # max_columns=4 -> 2 left, 2 right. col_0, col_1 ... col_8, col_9 + html = bf_html.render_html(dataframe=df, table_id="test", max_columns=4) + + assert "col_0" in html + assert "col_1" in html + assert "col_2" not in html + assert "col_7" not in html + assert "col_8" in html + assert "col_9" in html + assert "..." in html + + # Test max_columns=3 + # 3 // 2 = 1. Left: col_0. Right: 3 - 1 = 2. col_8, col_9. + # Total displayed: col_0, ..., col_8, col_9. (3 data cols + 1 ellipsis) + html = bf_html.render_html(dataframe=df, table_id="test", max_columns=3) + assert "col_0" in html + assert "col_1" not in html + assert "col_7" not in html + assert "col_8" in html + assert "col_9" in html + + # Test max_columns=1 + # 1 // 2 = 0. Left: []. Right: 1. col_9. + # Total: ..., col_9. + html = bf_html.render_html(dataframe=df, table_id="test", max_columns=1) + assert "col_0" not in html + assert "col_8" not in html + assert "col_9" in html + assert "..." in html