From 4f0f7c830bb8a4e1a831d194f001d0d1d9ecb778 Mon Sep 17 00:00:00 2001 From: Marc Wouts Date: Mon, 16 Sep 2024 22:30:02 +0100 Subject: [PATCH] Selected rows conversion in JS --- docs/changelog.md | 3 +- docs/ipywidgets.md | 98 ++++++++++++++++++-- packages/itables_anywidget/js/widget.ts | 27 +++++- packages/itables_for_streamlit/src/index.tsx | 16 +++- src/itables/javascript.py | 33 +++---- src/itables/widget/__init__.py | 22 +---- tests/test_extension_arguments.py | 1 + 7 files changed, 152 insertions(+), 48 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1103b291..5c73786a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,7 +5,8 @@ ITables ChangeLog ------------------ **Added** -- ITables now has a Jupyter Widget ([#267](https://github.com/mwouts/itables/issues/267)) - this would have taken months without AnyWidget! +- 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! +- 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)) 2.1.5 (2024-09-08) diff --git a/docs/ipywidgets.md b/docs/ipywidgets.md index d1cae631..bc7463a4 100644 --- a/docs/ipywidgets.md +++ b/docs/ipywidgets.md @@ -14,25 +14,105 @@ kernelspec: # Jupyter Widget -ITables is also available as a [Jupyter Widget](https://ipywidgets.readthedocs.io). +ITables is also available as a [Jupyter Widget](https://ipywidgets.readthedocs.io), since v2.2. -Make sure you install [AnyWidget](https://github.com/manzt/anywidget), the framework that we use to provide our widget: +## Using `show` + +If you only want to _display_ the table, you **do not need** +our Jupyter widget. The `show` function is enough! + +```{code-cell} +import ipywidgets as widgets + +from itables import show +from itables.sample_dfs import get_dict_of_test_dfs + +sample_dfs = get_dict_of_test_dfs() + + +def use_show_in_interactive_output(table_name: str): + show( + sample_dfs[table_name], + caption=table_name, + style="table-layout:auto;width:auto;float:left;caption-side:bottom", + ) + + +table_selector = widgets.Dropdown(options=sample_dfs.keys(), value="int_float_str") +out = widgets.interactive_output( + use_show_in_interactive_output, {"table_name": table_selector} +) + +widgets.VBox([table_selector, out]) +``` + +```{tip} +Jupyter widgets only work in a live notebook. +Click on the rocket icon at the top of the page to run this demo in Binder. +``` + +## Using the ITable widget + +The `ITable` widget has a few dependencies that you can install with ```bash -pip install anywidget +pip install itables[widget] ``` -Then, create a table widget with `ITable` from `itables.widget`: +The `ITable` class accepts the same arguments as the `show` method, but +the `df` argument is optional. ```{code-cell} -from itables.sample_dfs import get_countries from itables.widget import ITable -df = get_countries(html=False) -dt = ITable(df) +table = ITable(selected_rows=[0, 2, 5, 99]) + + +def update_selected_table(change): + table_name = table_selector.value + table.update( + sample_dfs[table_name], + caption=table_name, + select=True, + style="table-layout:auto;width:auto;float:left", + ) + + +# Update the table when the selector changes +table_selector.observe(update_selected_table, "value") + +# Set the table to the initial table selected +update_selected_table(None) + +widgets.VBox([table_selector, table]) ``` +## Get the selected rows + +The `ITable` widget let you access the state of the table +and in particular, it has an `.selected_rows` attribute +that you can use to determine the rows that have been +selected by the user (allow selection by passing `select=True` +to the `ITable` widget). + ```{code-cell} -dt +out = widgets.Output() + + +def show_selected_rows(change): + with out: + out.clear_output() + print("selected_rows: ", table.selected_rows) + + +table.observe(show_selected_rows, "selected_rows") + +# Display the initial selection +show_selected_rows(None) + +out ``` -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. +## Limitations + +Compared to `show`, the `ITable` widget has the same limitations as the [streamlit component](streamlit.md#limitations), +e.g. structured headers are not available, you can't pass JavaScript callback, etc. diff --git a/packages/itables_anywidget/js/widget.ts b/packages/itables_anywidget/js/widget.ts index 2c36268c..a4f5d8b0 100644 --- a/packages/itables_anywidget/js/widget.ts +++ b/packages/itables_anywidget/js/widget.ts @@ -52,8 +52,22 @@ function render({ model, el }: RenderContext) { function set_selected_rows_from_model() { // We use this variable to avoid triggering model updates! setting_selected_rows_from_model = true; + + // The model selected rows are for the full table, so + // we map them to the actual data + let selected_rows = model.get('selected_rows'); + let full_row_count = model.get('full_row_count'); + let data_row_count = model.get('data').length; + if (data_row_count < full_row_count) { + let bottom_half = data_row_count / 2; + let top_half = full_row_count - bottom_half; + selected_rows = selected_rows.filter(i => i >= 0 && i < full_row_count && (i < bottom_half || i >= top_half)).map( + i => (i < bottom_half) ? i : i - full_row_count + data_row_count); + } + dt.rows().deselect(); - dt.rows(model.get('selected_rows')).select(); + dt.rows(selected_rows).select(); + setting_selected_rows_from_model = false; }; @@ -88,6 +102,17 @@ function render({ model, el }: RenderContext) { return; let selected_rows = Array.from(dt.rows({ selected: true }).indexes()); + + // Here the selected rows are for the datatable. + // We convert them back to the full table + let full_row_count = model.get('full_row_count'); + let data_row_count = model.get('data').length; + if (data_row_count < full_row_count) { + let bottom_half = data_row_count / 2; + selected_rows = selected_rows.map( + i => (i < bottom_half ? i : i + full_row_count - data_row_count)); + } + model.set('selected_rows', selected_rows); model.save_changes(); }; diff --git a/packages/itables_for_streamlit/src/index.tsx b/packages/itables_for_streamlit/src/index.tsx index 5990ebb8..87db8764 100644 --- a/packages/itables_for_streamlit/src/index.tsx +++ b/packages/itables_for_streamlit/src/index.tsx @@ -34,8 +34,20 @@ function onRender(event: Event): void { } function export_selected_rows() { - let selected_rows = Array.from(dt.rows({ selected: true }).indexes()); - Streamlit.setComponentValue(selected_rows); + let selected_rows:Array = Array.from(dt.rows({ selected: true }).indexes()); + + let full_row_count:number = other_args.full_row_count; + let data_row_count:number = dt_args.data.length; + + // Here the selected rows are for the datatable. + // We convert them back to the full table + if (data_row_count < full_row_count) { + let bottom_half = data_row_count / 2; + selected_rows = selected_rows.map( + (x:number, i:number) => (x < bottom_half ? x : x + full_row_count - data_row_count)); + } + + Streamlit.setComponentValue({selected_rows}); }; dt.on('select', function (e: any, dt: any, type: any, indexes: any) { diff --git a/src/itables/javascript.py b/src/itables/javascript.py index 7521ecc1..5c156f7a 100644 --- a/src/itables/javascript.py +++ b/src/itables/javascript.py @@ -518,6 +518,7 @@ def get_itables_extension_arguments(df, caption=None, selected_rows=None, **kwar maxColumns = kwargs.pop("maxColumns", pd.get_option("display.max_columns") or 0) warn_on_unexpected_types = kwargs.pop("warn_on_unexpected_types", False) + full_row_count = len(df) df, downsampling_warning = downsample( df, max_rows=maxRows, max_columns=maxColumns, max_bytes=maxBytes ) @@ -559,18 +560,21 @@ def get_itables_extension_arguments(df, caption=None, selected_rows=None, **kwar f"This dataframe can't be serialized to JSON:\n{e}\n{data_json}" ) + assert len(data) <= full_row_count + return {"columns": columns, "data": data, **kwargs}, { "classes": classes, "style": style, "caption": caption, "downsampling_warning": downsampling_warning, - "selected_rows": get_selected_rows_after_downsampling( - selected_rows, len(df), len(data) + "full_row_count": full_row_count, + "selected_rows": warn_if_selected_rows_are_not_visible( + selected_rows, full_row_count, len(data) ), } -def get_selected_rows_after_downsampling( +def warn_if_selected_rows_are_not_visible( selected_rows, full_row_count, downsampled_row_count ): if selected_rows is None: @@ -579,21 +583,18 @@ def get_selected_rows_after_downsampling( return selected_rows half = downsampled_row_count // 2 assert downsampled_row_count == 2 * half, downsampled_row_count + bottom_limit = half + top_limit = full_row_count - half - filtered_rows = full_row_count - downsampled_row_count - return [i if i < half else i - filtered_rows for i in selected_rows] - - -def get_selected_rows_before_downsampling( - selected_rows, full_row_count, downsampled_row_count -): - if full_row_count == downsampled_row_count: - return selected_rows - half = downsampled_row_count // 2 - assert downsampled_row_count == 2 * half, downsampled_row_count + if any(bottom_limit <= i < top_limit for i in selected_rows): + warnings.warn( + f"This table has been downsampled. " + f"Only {downsampled_row_count} of the original {full_row_count} rows " + "are rendered, see https://mwouts.github.io/itables/downsampling.html. " + f"In particular the rows [{bottom_limit}:{top_limit}] cannot be selected." + ) - filtered_rows = full_row_count - downsampled_row_count - return [i if i < half else i + filtered_rows for i in selected_rows] + return [i for i in selected_rows if i < bottom_limit or i >= top_limit] def check_table_id(table_id): diff --git a/src/itables/widget/__init__.py b/src/itables/widget/__init__.py index 6c68029e..e5e3b722 100644 --- a/src/itables/widget/__init__.py +++ b/src/itables/widget/__init__.py @@ -1,16 +1,11 @@ import importlib.metadata import pathlib -from typing import Sequence import anywidget import pandas as pd import traitlets -from itables.javascript import ( - get_itables_extension_arguments, - get_selected_rows_after_downsampling, - get_selected_rows_before_downsampling, -) +from itables.javascript import get_itables_extension_arguments try: __version__ = importlib.metadata.version("itables_anywidget") @@ -22,6 +17,7 @@ class ITable(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "widget.js" _css = pathlib.Path(__file__).parent / "static" / "widget.css" + full_row_count = traitlets.Int().tag(sync=True) data = traitlets.List(traitlets.List()).tag(sync=True) selected_rows = traitlets.List(traitlets.Int).tag(sync=True) 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: if df is None: df = pd.DataFrame() - self.df = df dt_args, other_args = get_itables_extension_arguments( df, caption, selected_rows, **kwargs ) + self.full_row_count = other_args.pop("full_row_count") self.data = dt_args.pop("data") self.dt_args = dt_args self.classes = other_args.pop("classes") @@ -79,15 +75,3 @@ def update(self, df=None, caption=None, selected_rows=None, **kwargs): self.selected_rows = selected_rows self.destroy_and_recreate += 1 - - def get_selected_rows(self) -> list[int]: - return get_selected_rows_before_downsampling( - self.selected_rows, len(self.df), len(self.data) - ) - - def set_selected_rows(self, selected_rows: Sequence[int]): - selected_rows = get_selected_rows_after_downsampling( - selected_rows, len(self.df), len(self.data) - ) - if self.selected_rows != selected_rows: - self.selected_rows = selected_rows diff --git a/tests/test_extension_arguments.py b/tests/test_extension_arguments.py index 17c9e676..ff0e31df 100644 --- a/tests/test_extension_arguments.py +++ b/tests/test_extension_arguments.py @@ -26,6 +26,7 @@ def test_get_itables_extension_arguments(df): "caption", "downsampling_warning", "selected_rows", + "full_row_count", }, set(dt_args) assert isinstance(other_args["classes"], str) assert isinstance(other_args["style"], str)