Skip to content

Commit 4f0f7c8

Browse files
committed
Selected rows conversion in JS
1 parent c6fe9bd commit 4f0f7c8

File tree

7 files changed

+152
-48
lines changed

7 files changed

+152
-48
lines changed

docs/changelog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ ITables ChangeLog
55
------------------
66

77
**Added**
8-
- ITables now has a Jupyter Widget ([#267](https://github.com/mwouts/itables/issues/267)) - this would have taken months without AnyWidget!
8+
- ITables has a Jupyter Widget ([#267](https://github.com/mwouts/itables/issues/267)). Our widget was developed and packaged using [AnyWidget](https://anywidget.dev/) which I highly recommend!
9+
- The selected rows are now available! Use either the `selected_rows` attribute of the `ITable` widget, or the returned value of the Streamlit `interactive_table` component ([#250](https://github.com/mwouts/itables/issues/250))
910

1011

1112
2.1.5 (2024-09-08)

docs/ipywidgets.md

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,105 @@ kernelspec:
1414

1515
# Jupyter Widget
1616

17-
ITables is also available as a [Jupyter Widget](https://ipywidgets.readthedocs.io).
17+
ITables is also available as a [Jupyter Widget](https://ipywidgets.readthedocs.io), since v2.2.
1818

19-
Make sure you install [AnyWidget](https://github.com/manzt/anywidget), the framework that we use to provide our widget:
19+
## Using `show`
20+
21+
If you only want to _display_ the table, you **do not need**
22+
our Jupyter widget. The `show` function is enough!
23+
24+
```{code-cell}
25+
import ipywidgets as widgets
26+
27+
from itables import show
28+
from itables.sample_dfs import get_dict_of_test_dfs
29+
30+
sample_dfs = get_dict_of_test_dfs()
31+
32+
33+
def use_show_in_interactive_output(table_name: str):
34+
show(
35+
sample_dfs[table_name],
36+
caption=table_name,
37+
style="table-layout:auto;width:auto;float:left;caption-side:bottom",
38+
)
39+
40+
41+
table_selector = widgets.Dropdown(options=sample_dfs.keys(), value="int_float_str")
42+
out = widgets.interactive_output(
43+
use_show_in_interactive_output, {"table_name": table_selector}
44+
)
45+
46+
widgets.VBox([table_selector, out])
47+
```
48+
49+
```{tip}
50+
Jupyter widgets only work in a live notebook.
51+
Click on the rocket icon at the top of the page to run this demo in Binder.
52+
```
53+
54+
## Using the ITable widget
55+
56+
The `ITable` widget has a few dependencies that you can install with
2057
```bash
21-
pip install anywidget
58+
pip install itables[widget]
2259
```
2360

24-
Then, create a table widget with `ITable` from `itables.widget`:
61+
The `ITable` class accepts the same arguments as the `show` method, but
62+
the `df` argument is optional.
2563

2664
```{code-cell}
27-
from itables.sample_dfs import get_countries
2865
from itables.widget import ITable
2966
30-
df = get_countries(html=False)
31-
dt = ITable(df)
67+
table = ITable(selected_rows=[0, 2, 5, 99])
68+
69+
70+
def update_selected_table(change):
71+
table_name = table_selector.value
72+
table.update(
73+
sample_dfs[table_name],
74+
caption=table_name,
75+
select=True,
76+
style="table-layout:auto;width:auto;float:left",
77+
)
78+
79+
80+
# Update the table when the selector changes
81+
table_selector.observe(update_selected_table, "value")
82+
83+
# Set the table to the initial table selected
84+
update_selected_table(None)
85+
86+
widgets.VBox([table_selector, table])
3287
```
3388

89+
## Get the selected rows
90+
91+
The `ITable` widget let you access the state of the table
92+
and in particular, it has an `.selected_rows` attribute
93+
that you can use to determine the rows that have been
94+
selected by the user (allow selection by passing `select=True`
95+
to the `ITable` widget).
96+
3497
```{code-cell}
35-
dt
98+
out = widgets.Output()
99+
100+
101+
def show_selected_rows(change):
102+
with out:
103+
out.clear_output()
104+
print("selected_rows: ", table.selected_rows)
105+
106+
107+
table.observe(show_selected_rows, "selected_rows")
108+
109+
# Display the initial selection
110+
show_selected_rows(None)
111+
112+
out
36113
```
37114

38-
The `ITable` class accepts the same arguments as the `show` method. It comes with a few limitations - the same as for the [streamlit component](streamlit.md#limitations), e.g. you can't pass JavaScript callback.
115+
## Limitations
116+
117+
Compared to `show`, the `ITable` widget has the same limitations as the [streamlit component](streamlit.md#limitations),
118+
e.g. structured headers are not available, you can't pass JavaScript callback, etc.

packages/itables_anywidget/js/widget.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,22 @@ function render({ model, el }: RenderContext<WidgetModel>) {
5252
function set_selected_rows_from_model() {
5353
// We use this variable to avoid triggering model updates!
5454
setting_selected_rows_from_model = true;
55+
56+
// The model selected rows are for the full table, so
57+
// we map them to the actual data
58+
let selected_rows = model.get('selected_rows');
59+
let full_row_count = model.get('full_row_count');
60+
let data_row_count = model.get('data').length;
61+
if (data_row_count < full_row_count) {
62+
let bottom_half = data_row_count / 2;
63+
let top_half = full_row_count - bottom_half;
64+
selected_rows = selected_rows.filter(i => i >= 0 && i < full_row_count && (i < bottom_half || i >= top_half)).map(
65+
i => (i < bottom_half) ? i : i - full_row_count + data_row_count);
66+
}
67+
5568
dt.rows().deselect();
56-
dt.rows(model.get('selected_rows')).select();
69+
dt.rows(selected_rows).select();
70+
5771
setting_selected_rows_from_model = false;
5872
};
5973

@@ -88,6 +102,17 @@ function render({ model, el }: RenderContext<WidgetModel>) {
88102
return;
89103

90104
let selected_rows = Array.from(dt.rows({ selected: true }).indexes());
105+
106+
// Here the selected rows are for the datatable.
107+
// We convert them back to the full table
108+
let full_row_count = model.get('full_row_count');
109+
let data_row_count = model.get('data').length;
110+
if (data_row_count < full_row_count) {
111+
let bottom_half = data_row_count / 2;
112+
selected_rows = selected_rows.map(
113+
i => (i < bottom_half ? i : i + full_row_count - data_row_count));
114+
}
115+
91116
model.set('selected_rows', selected_rows);
92117
model.save_changes();
93118
};

packages/itables_for_streamlit/src/index.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,20 @@ function onRender(event: Event): void {
3434
}
3535

3636
function export_selected_rows() {
37-
let selected_rows = Array.from(dt.rows({ selected: true }).indexes());
38-
Streamlit.setComponentValue(selected_rows);
37+
let selected_rows:Array<number> = Array.from(dt.rows({ selected: true }).indexes());
38+
39+
let full_row_count:number = other_args.full_row_count;
40+
let data_row_count:number = dt_args.data.length;
41+
42+
// Here the selected rows are for the datatable.
43+
// We convert them back to the full table
44+
if (data_row_count < full_row_count) {
45+
let bottom_half = data_row_count / 2;
46+
selected_rows = selected_rows.map(
47+
(x:number, i:number) => (x < bottom_half ? x : x + full_row_count - data_row_count));
48+
}
49+
50+
Streamlit.setComponentValue({selected_rows});
3951
};
4052

4153
dt.on('select', function (e: any, dt: any, type: any, indexes: any) {

src/itables/javascript.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ def get_itables_extension_arguments(df, caption=None, selected_rows=None, **kwar
518518
maxColumns = kwargs.pop("maxColumns", pd.get_option("display.max_columns") or 0)
519519
warn_on_unexpected_types = kwargs.pop("warn_on_unexpected_types", False)
520520

521+
full_row_count = len(df)
521522
df, downsampling_warning = downsample(
522523
df, max_rows=maxRows, max_columns=maxColumns, max_bytes=maxBytes
523524
)
@@ -559,18 +560,21 @@ def get_itables_extension_arguments(df, caption=None, selected_rows=None, **kwar
559560
f"This dataframe can't be serialized to JSON:\n{e}\n{data_json}"
560561
)
561562

563+
assert len(data) <= full_row_count
564+
562565
return {"columns": columns, "data": data, **kwargs}, {
563566
"classes": classes,
564567
"style": style,
565568
"caption": caption,
566569
"downsampling_warning": downsampling_warning,
567-
"selected_rows": get_selected_rows_after_downsampling(
568-
selected_rows, len(df), len(data)
570+
"full_row_count": full_row_count,
571+
"selected_rows": warn_if_selected_rows_are_not_visible(
572+
selected_rows, full_row_count, len(data)
569573
),
570574
}
571575

572576

573-
def get_selected_rows_after_downsampling(
577+
def warn_if_selected_rows_are_not_visible(
574578
selected_rows, full_row_count, downsampled_row_count
575579
):
576580
if selected_rows is None:
@@ -579,21 +583,18 @@ def get_selected_rows_after_downsampling(
579583
return selected_rows
580584
half = downsampled_row_count // 2
581585
assert downsampled_row_count == 2 * half, downsampled_row_count
586+
bottom_limit = half
587+
top_limit = full_row_count - half
582588

583-
filtered_rows = full_row_count - downsampled_row_count
584-
return [i if i < half else i - filtered_rows for i in selected_rows]
585-
586-
587-
def get_selected_rows_before_downsampling(
588-
selected_rows, full_row_count, downsampled_row_count
589-
):
590-
if full_row_count == downsampled_row_count:
591-
return selected_rows
592-
half = downsampled_row_count // 2
593-
assert downsampled_row_count == 2 * half, downsampled_row_count
589+
if any(bottom_limit <= i < top_limit for i in selected_rows):
590+
warnings.warn(
591+
f"This table has been downsampled. "
592+
f"Only {downsampled_row_count} of the original {full_row_count} rows "
593+
"are rendered, see https://mwouts.github.io/itables/downsampling.html. "
594+
f"In particular the rows [{bottom_limit}:{top_limit}] cannot be selected."
595+
)
594596

595-
filtered_rows = full_row_count - downsampled_row_count
596-
return [i if i < half else i + filtered_rows for i in selected_rows]
597+
return [i for i in selected_rows if i < bottom_limit or i >= top_limit]
597598

598599

599600
def check_table_id(table_id):

src/itables/widget/__init__.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import importlib.metadata
22
import pathlib
3-
from typing import Sequence
43

54
import anywidget
65
import pandas as pd
76
import traitlets
87

9-
from itables.javascript import (
10-
get_itables_extension_arguments,
11-
get_selected_rows_after_downsampling,
12-
get_selected_rows_before_downsampling,
13-
)
8+
from itables.javascript import get_itables_extension_arguments
149

1510
try:
1611
__version__ = importlib.metadata.version("itables_anywidget")
@@ -22,6 +17,7 @@ class ITable(anywidget.AnyWidget):
2217
_esm = pathlib.Path(__file__).parent / "static" / "widget.js"
2318
_css = pathlib.Path(__file__).parent / "static" / "widget.css"
2419

20+
full_row_count = traitlets.Int().tag(sync=True)
2521
data = traitlets.List(traitlets.List()).tag(sync=True)
2622
selected_rows = traitlets.List(traitlets.Int).tag(sync=True)
2723
destroy_and_recreate = traitlets.Int(0).tag(sync=True)
@@ -37,11 +33,11 @@ def __init__(self, df=None, caption=None, selected_rows=None, **kwargs) -> None:
3733

3834
if df is None:
3935
df = pd.DataFrame()
40-
self.df = df
4136

4237
dt_args, other_args = get_itables_extension_arguments(
4338
df, caption, selected_rows, **kwargs
4439
)
40+
self.full_row_count = other_args.pop("full_row_count")
4541
self.data = dt_args.pop("data")
4642
self.dt_args = dt_args
4743
self.classes = other_args.pop("classes")
@@ -79,15 +75,3 @@ def update(self, df=None, caption=None, selected_rows=None, **kwargs):
7975
self.selected_rows = selected_rows
8076

8177
self.destroy_and_recreate += 1
82-
83-
def get_selected_rows(self) -> list[int]:
84-
return get_selected_rows_before_downsampling(
85-
self.selected_rows, len(self.df), len(self.data)
86-
)
87-
88-
def set_selected_rows(self, selected_rows: Sequence[int]):
89-
selected_rows = get_selected_rows_after_downsampling(
90-
selected_rows, len(self.df), len(self.data)
91-
)
92-
if self.selected_rows != selected_rows:
93-
self.selected_rows = selected_rows

tests/test_extension_arguments.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def test_get_itables_extension_arguments(df):
2626
"caption",
2727
"downsampling_warning",
2828
"selected_rows",
29+
"full_row_count",
2930
}, set(dt_args)
3031
assert isinstance(other_args["classes"], str)
3132
assert isinstance(other_args["style"], str)

0 commit comments

Comments
 (0)