Skip to content

Commit 688ec48

Browse files
committed
add js unit test framework
1 parent 0680139 commit 688ec48

File tree

11 files changed

+6909
-73
lines changed

11 files changed

+6909
-73
lines changed

bigframes/display/anywidget.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
"""A scrollable, paginated table widget for BigQuery DataFrames."""
16+
1517
from __future__ import annotations
1618

1719
import dataclasses
@@ -107,7 +109,7 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
107109
# TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns.
108110
self.page_size = initial_page_size
109111
self.orderable_columns = [
110-
col_name
112+
str(col_name)
111113
for col_name, dtype in dataframe.dtypes.items()
112114
if dtypes.is_orderable(dtype)
113115
]

bigframes/display/html.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,15 @@ def render_html(
5050
if col in orderable_columns:
5151
th_classes.append("sortable")
5252
class_str = f'class="{" ".join(th_classes)}"' if th_classes else ""
53+
header_div = (
54+
'<div style="resize: horizontal; overflow: auto; '
55+
"box-sizing: border-box; width: 100%; height: 100%; "
56+
'padding: 0.5em;">'
57+
f"{html.escape(str(col))}"
58+
"</div>"
59+
)
5360
table_html.append(
54-
f' <th style="text-align: left;" {class_str}><div style="resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%; height: 100%; padding: 0.5em;">{html.escape(str(col))}</div></th>'
61+
f' <th style="text-align: left;" {class_str}>{header_div}</th>'
5562
)
5663
table_html.append(" </tr>")
5764
table_html.append(" </thead>")

bigframes/display/table_widget.js

Lines changed: 66 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
/**
2-
* Copyright 2025 Google LLC
1+
/*
2+
* Copyright 2024 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,24 +26,22 @@ const ModelProperty = {
2626
};
2727

2828
const Event = {
29-
CHANGE: "change",
30-
CHANGE_TABLE_HTML: `change:${ModelProperty.TABLE_HTML}`,
3129
CLICK: "click",
30+
CHANGE: "change",
31+
CHANGE_TABLE_HTML: "change:table_html",
3232
};
3333

3434
/**
3535
* Renders the interactive table widget.
36-
* @param {{
37-
* model: any,
38-
* el: HTMLElement
39-
* }} options
36+
* @param {{ model: any, el: HTMLElement }} props - The widget properties.
37+
* @param {Document} doc - The document object to use for creating elements.
4038
*/
41-
function render({ model, el }) {
39+
function render({ model, el }, doc) {
4240
// Main container with a unique class for CSS scoping
4341
el.classList.add("bigframes-widget");
4442

4543
// Add error message container at the top
46-
const errorContainer = document.createElement("div");
44+
const errorContainer = doc.createElement("div");
4745
errorContainer.classList.add("error-message");
4846
errorContainer.style.display = "none";
4947
errorContainer.style.color = "red";
@@ -53,94 +51,95 @@ function render({ model, el }) {
5351
errorContainer.style.borderRadius = "4px";
5452
errorContainer.style.backgroundColor = "#ffebee";
5553

56-
const tableContainer = document.createElement("div");
57-
const footer = document.createElement("div");
58-
59-
// Footer: Total rows label
60-
const rowCountLabel = document.createElement("div");
61-
62-
// Footer: Pagination controls
63-
const paginationContainer = document.createElement("div");
64-
const prevPage = document.createElement("button");
65-
const paginationLabel = document.createElement("span");
66-
const nextPage = document.createElement("button");
67-
68-
// Footer: Page size controls
69-
const pageSizeContainer = document.createElement("div");
70-
const pageSizeLabel = document.createElement("label");
71-
const pageSizeSelect = document.createElement("select");
72-
73-
// Add CSS classes
74-
tableContainer.classList.add("table-container");
75-
footer.classList.add("footer");
76-
paginationContainer.classList.add("pagination");
77-
pageSizeContainer.classList.add("page-size");
78-
79-
// Configure pagination buttons
80-
prevPage.type = "button";
81-
nextPage.type = "button";
82-
prevPage.textContent = "Prev";
83-
nextPage.textContent = "Next";
84-
85-
// Configure page size selector
86-
pageSizeLabel.textContent = "Page Size";
87-
for (const size of [10, 25, 50, 100]) {
88-
const option = document.createElement("option");
54+
const tableContainer = doc.createElement("div");
55+
const footer = doc.createElement("div");
56+
57+
// Footer styles
58+
footer.style.display = "flex";
59+
footer.style.justifyContent = "space-between";
60+
footer.style.alignItems = "center";
61+
footer.style.padding = "8px";
62+
footer.style.fontFamily =
63+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
64+
65+
// Pagination controls
66+
const paginationContainer = doc.createElement("div");
67+
const prevPage = doc.createElement("button");
68+
const pageIndicator = doc.createElement("span");
69+
const nextPage = doc.createElement("button");
70+
const rowCountLabel = doc.createElement("span");
71+
72+
// Page size controls
73+
const pageSizeContainer = doc.createElement("div");
74+
const pageSizeLabel = doc.createElement("label");
75+
const pageSizeInput = doc.createElement("select");
76+
77+
prevPage.textContent = "<";
78+
nextPage.textContent = ">";
79+
pageSizeLabel.textContent = "Page size:";
80+
pageSizeLabel.style.marginRight = "8px";
81+
pageIndicator.style.margin = "0 8px";
82+
rowCountLabel.style.margin = "0 8px";
83+
84+
// Page size options
85+
const pageSizes = [10, 20, 50, 100, 200, 500, 1000];
86+
for (const size of pageSizes) {
87+
const option = doc.createElement("option");
8988
option.value = size;
9089
option.textContent = size;
9190
if (size === model.get(ModelProperty.PAGE_SIZE)) {
9291
option.selected = true;
9392
}
94-
pageSizeSelect.appendChild(option);
93+
pageSizeInput.appendChild(option);
9594
}
9695

9796
/** Updates the footer states and page label based on the model. */
9897
function updateButtonStates() {
99-
const rowCount = model.get(ModelProperty.ROW_COUNT);
100-
const pageSize = model.get(ModelProperty.PAGE_SIZE);
10198
const currentPage = model.get(ModelProperty.PAGE);
99+
const pageSize = model.get(ModelProperty.PAGE_SIZE);
100+
const rowCount = model.get(ModelProperty.ROW_COUNT);
102101

103102
if (rowCount === null) {
104103
// Unknown total rows
105104
rowCountLabel.textContent = "Total rows unknown";
106-
paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`;
105+
pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`;
107106
prevPage.disabled = currentPage === 0;
108107
nextPage.disabled = false; // Allow navigation until we hit the end
109108
} else {
110109
// Known total rows
111110
const totalPages = Math.ceil(rowCount / pageSize);
112111
rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`;
113-
paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${rowCount.toLocaleString()}`;
112+
pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`;
114113
prevPage.disabled = currentPage === 0;
115114
nextPage.disabled = currentPage >= totalPages - 1;
116115
}
117-
pageSizeSelect.value = pageSize;
116+
pageSizeInput.value = pageSize;
118117
}
119118

120119
/**
121-
* Increments or decrements the page in the model.
122-
* @param {number} direction - `1` for next, `-1` for previous.
120+
* Handles page navigation.
121+
* @param {number} direction - The direction to navigate (-1 for previous, 1 for next).
123122
*/
124123
function handlePageChange(direction) {
125-
const current = model.get(ModelProperty.PAGE);
126-
const next = current + direction;
127-
model.set(ModelProperty.PAGE, next);
124+
const currentPage = model.get(ModelProperty.PAGE);
125+
model.set(ModelProperty.PAGE, currentPage + direction);
128126
model.save_changes();
129127
}
130128

131129
/**
132-
* Handles changes to the page size from the dropdown.
133-
* @param {number} size - The new page size.
130+
* Handles page size changes.
131+
* @param {number} newSize - The new page size.
134132
*/
135-
function handlePageSizeChange(size) {
136-
const currentSize = model.get(ModelProperty.PAGE_SIZE);
137-
if (size !== currentSize) {
138-
model.set(ModelProperty.PAGE_SIZE, size);
139-
model.save_changes();
140-
}
133+
function handlePageSizeChange(newSize) {
134+
model.set(ModelProperty.PAGE_SIZE, newSize);
135+
model.set(ModelProperty.PAGE, 0); // Reset to first page
136+
model.save_changes();
141137
}
142138

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

146145
// Get sortable columns from backend
@@ -159,7 +158,7 @@ function render({ model, el }) {
159158
header.style.cursor = "pointer";
160159

161160
// Create a span for the indicator
162-
const indicatorSpan = document.createElement("span");
161+
const indicatorSpan = doc.createElement("span");
163162
indicatorSpan.classList.add("sort-indicator");
164163
indicatorSpan.style.paddingLeft = "5px";
165164

@@ -230,7 +229,7 @@ function render({ model, el }) {
230229
// Add event listeners
231230
prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1));
232231
nextPage.addEventListener(Event.CLICK, () => handlePageChange(1));
233-
pageSizeSelect.addEventListener(Event.CHANGE, (e) => {
232+
pageSizeInput.addEventListener(Event.CHANGE, (e) => {
234233
const newSize = Number(e.target.value);
235234
if (newSize) {
236235
handlePageSizeChange(newSize);
@@ -244,14 +243,15 @@ function render({ model, el }) {
244243
updateButtonStates();
245244
}
246245
});
246+
model.on(`change:${ModelProperty.PAGE}`, updateButtonStates);
247247

248248
// Assemble the DOM
249249
paginationContainer.appendChild(prevPage);
250-
paginationContainer.appendChild(paginationLabel);
250+
paginationContainer.appendChild(pageIndicator);
251251
paginationContainer.appendChild(nextPage);
252252

253253
pageSizeContainer.appendChild(pageSizeLabel);
254-
pageSizeContainer.appendChild(pageSizeSelect);
254+
pageSizeContainer.appendChild(pageSizeInput);
255255

256256
footer.appendChild(rowCountLabel);
257257
footer.appendChild(paginationContainer);

notebooks/dataframes/anywidget_mode.ipynb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@
157157
{
158158
"data": {
159159
"application/vnd.jupyter.widget-view+json": {
160-
"model_id": "690ebe0bf3c34f83919efff6e6c17857",
160+
"model_id": "e77180eeab3044efb14c01a1705e4bf9",
161161
"version_major": 2,
162162
"version_minor": 1
163163
},
@@ -255,7 +255,7 @@
255255
{
256256
"data": {
257257
"application/vnd.jupyter.widget-view+json": {
258-
"model_id": "18820d9a6273417583384784e33c4bf0",
258+
"model_id": "69a579846a4f4107956ac6047fef2d15",
259259
"version_major": 2,
260260
"version_minor": 1
261261
},
@@ -369,7 +369,7 @@
369369
{
370370
"data": {
371371
"application/vnd.jupyter.widget-view+json": {
372-
"model_id": "eecb39677d214b1e9a06b7045be41765",
372+
"model_id": "9223dcd7727d49d7b2e633cc268399f5",
373373
"version_major": 2,
374374
"version_minor": 1
375375
},
@@ -409,7 +409,7 @@
409409
"data": {
410410
"text/html": [
411411
"✅ Completed. \n",
412-
" Query processed 85.9 kB in 12 seconds of slot time.\n",
412+
" Query processed 85.9 kB in 14 seconds of slot time.\n",
413413
" "
414414
],
415415
"text/plain": [
@@ -456,7 +456,7 @@
456456
{
457457
"data": {
458458
"application/vnd.jupyter.widget-view+json": {
459-
"model_id": "54337083f3814865b928d066e35289c4",
459+
"model_id": "efc4892c045742dbbf96fb90eff2bb22",
460460
"version_major": 2,
461461
"version_minor": 1
462462
},

tests/js/babel.config.cjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
module.exports = {
18+
presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
19+
};

tests/js/jest.config.cjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/** @type {import('jest').Config} */
18+
const config = {
19+
testEnvironment: 'jsdom',
20+
transform: {
21+
'^.+\.js$': 'babel-jest',
22+
},
23+
setupFilesAfterEnv: ['./jest.setup.js'],
24+
transformIgnorePatterns: [],
25+
};
26+
27+
module.exports = config;

tests/js/jest.setup.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { TextDecoder, TextEncoder } from "node:util";
18+
19+
global.TextEncoder = TextEncoder;
20+
global.TextDecoder = TextDecoder;

0 commit comments

Comments
 (0)