diff --git a/packages/solara-meta/pyproject.toml b/packages/solara-meta/pyproject.toml index a0978ed15..5d326c8ca 100644 --- a/packages/solara-meta/pyproject.toml +++ b/packages/solara-meta/pyproject.toml @@ -39,7 +39,9 @@ documentation = [ "bqplot", "altair", "folium", + "ipyaggrid", "ipycanvas", + "ipydatagrid", "ipyleaflet", "matplotlib", "mkdocs-pycafe", diff --git a/solara/website/pages/documentation/advanced/content/10-howto/50-ipywidget_libraries.md b/solara/website/pages/documentation/advanced/content/10-howto/50-ipywidget_libraries.md index d9b6e37f5..8be05443a 100644 --- a/solara/website/pages/documentation/advanced/content/10-howto/50-ipywidget_libraries.md +++ b/solara/website/pages/documentation/advanced/content/10-howto/50-ipywidget_libraries.md @@ -26,13 +26,13 @@ marker = Marker(location=(52.1, 10.1), draggable=True) m.add_layer(marker) ``` -In Solara, we should not create widgets, but elements instead. We can create elements using the `.element(...)` method. This method takes the same arguments as the widget constructor, but returns an element instead of a widget. The element can be used in the same way as a widget, but it is not a widget. It is a special object that can be used in Solara. +In Solara, ideally, we should not create widgets, but elements instead. We can create elements using the `.element(...)` method. This method takes the same arguments as the widget constructor, but returns an element instead of a widget. The element can be used in the same way as a widget, but it is not a widget. It is a special object that can be used in Solara. However, how do we add the marker to the map? The map element object does not have an `add_layer` method. That is the downside of using the React-like API of Solara. We cannot call methods on the widget anymore. Instead, we need to pass the marker to the layers argument. That, however, introduces a new problem. Ipyleaflet by default adds a layer to the map when it is created, and the `add_layer` adds the second layer. We now need to manually add the map layer ourselves. Putting this together. -```solara +```solara {pycafe-link extra-requirements="ipyleaflet"} import ipyleaflet import solara @@ -71,7 +71,7 @@ makes this easier. The `Video` widget does not have a corresponding component in solara (yet), but we can manually fill in the `value`. For example: -```solara +```solara {pycafe-link} import solara import ipywidgets @@ -87,6 +87,227 @@ def Page(): ``` +## Escape hatch + +Some libraries do not give access to the widget classes, or wrap the creation of widgets into a function making it impossible to create an element. + +### Quick and bad way + +If you quickly want to show a widget in your prototype, and want to avoid all the boilerplate at the +cost of a bit of a memory leak, use the following technique. + +Here we directly create a widget in the render function instead of indirectly via an element. +Since only elements get automatically added to its parent component, so we need to manually +call [display](/api/display). + +```solara {pycafe-link} +import solara +import ipywidgets as widgets + + +@solara.component +def Page(): + button_widget = widgets.Button(description="Classic Widget") + solara.display(button_widget) + + def change_description(btn): + button_widget.description = "Great escape hatch" + button_widget.on_click(change_description) +``` + +With this approach, there are two issues. First, we do not clean up the widget we created by calling `.close()` on it. Although +we can do that in the cleanup function of a [use_effect](/api/use_effect), in some situations the render function can be called, +without calling the use_effect. + +The second issue is that every time to component gets rerendered (argument change, or state changes +like a reactive variable it depends on) it will re-create the widget. + +This last issue is demonstrated in this example. We modify the above example by adding an extra state change (modifying the +`if_i_change_we_recreate_the_widget` reactive variable) that causes the button to be completely re-created, resetting the description. + +```solara {pycafe-link} +import solara +import ipywidgets as widgets + + +@solara.component +def Page(): + if_i_change_we_recreate_the_widget = solara.use_reactive(0) + print("if_i_change_we_recreate_the_widget", if_i_change_we_recreate_the_widget.value) + + button_widget = widgets.Button(description="I do not change") + solara.display(button_widget) + + + def change_description(btn): + # this 'works' + button_widget.description = "Can I change you?" + # but because this will trigger a re-render, it will + # re-create the widget + if_i_change_we_recreate_the_widget.value += 1 + # Now we can call normal functions on it + button_widget.on_click(change_description) +``` + +### Proper way + +If you want to have more control on when your widgets gets created (only once for instance), and how to clean it up, uou can use the the following general pattern, +here demonstrated using an ipywidget Button: + +```solara {pycafe-link} +import solara +import ipywidgets as widgets + + +@solara.component +def Page(): + if_i_change_we_rerender = solara.use_reactive(0) + # Important to use a widget component, not a function component, + # otherwise the children will be reset after we change it in the + # use_effect function. + container = solara.v.Html(tag="div") + # Because of this, this container will not work: + # container = solara.Column() + + def add_classic_widget(): + # generate your normal widget + button_widget = widgets.Button(description="Classic Widget") + + def change_description(btn): + button_widget.description = "Proper escape hatch" + # This will trigger a rerender, but not re-execute the use_effect + if_i_change_we_rerender.value += 1 + # Now we can call normal functions on it + button_widget.on_click(change_description) + + # add it to the generated widget by solara/reacton + container_widget = solara.get_widget(container) + container_widget.children = (button_widget,) + + def optional_cleanup(): + # ideally, we cleanup the widgets we created. + # If you skip this step, the widgets will be garbage collected + # when the solara virtual kernel gets closed. + # In the Notebook or Voila skipping this step can cause a (small) + # memory leak. + container_widget.children = () + button_widget.on_click(change_description, remove=True) + button_widget.layout.close() + button_widget.style.close() + button_widget.close() + return optional_cleanup + + solara.use_effect(add_classic_widget, dependencies=[]) + + # We could potentially update the button based on if_i_change_we_rerender using: + # solara.use_effect(update_button, dependencies[if_i_change_we_rerender.value]) + # However, getting a reference to the widget is a bit trickier, use solara.use_ref + # https://reacton.solara.dev/en/latest/api/#use_ref would come handy, e.g. + # button = solara.use_ref(None) + # And assign button.current in the add_classic_widget function and access + # if in other use_effects + return container +``` + + + +## ipyaggrid + +[IPyAgGrid](https://github.com/widgetti/ipyaggrid) has the disadvantage that the constructor arguments +are not the same as the traits or property names on the object. For instance, when calling the +Grid constructor, `grid_data` is used, while updating the dataframe goes via [`.update_grid_data(...)`](https://widgetti.github.io/ipyaggrid/guide/create.html#update-data) + + +```python +import ipyaggrid +grid = ipyaggrid.Grid(grid_data=df) +... +grid.update_grid_data(df_other) +``` + +When using solara/reacton components, we do not create widgets directly, but prefer to use elements (descriptions of component instances) +to get lifetime management, and automatic updates of traits. This automatic updating of traits however, does not work in this case, +since it should call `.update_grid_data(...)` instead. + +To get around this, we can again use `use_effect` whenever the dataframe (or other state that signals a change in the dataframe) changes. + +```solara {pycafe-link extra-requirements="plotly, ipyaggrid"} +from typing import cast +import ipyaggrid +import plotly.express as px +import solara + + +df = px.data.iris() +species = solara.reactive("setosa") + +grid_options = { + "columnDefs": [ + {"headerName": "Sepal Length", "field": "sepal_length"}, + {"headerName": "Species", "field": "species"}, + ] +} + + +@solara.component +def Page(): + df_filtered = df.query(f"species == {species.value!r}") + solara.Select("Filter species", value=species, values=["setosa", "versicolor", "virginica"]) + + # does NOT update aggrid when grid_data argument changes + # since grid_data is not a trait, so letting reacton/solara update this property has no effect + grid = ipyaggrid.Grid.element(grid_data=df_filtered, grid_options=grid_options) + + # Instead, we need to get a reference to the widget and call .update_grid_data in a use_effect + def update_df(): + # NOTE: the cast is optional, and only needed if you like type hinting + grid_widget = cast(ipyaggrid.Grid, solara.get_widget(grid)) + grid_widget.update_grid_data(df_filtered) + + # Note, instead of having df_filtered as a dependency, we use species which is easier/cheaper + # to compare. + solara.use_effect(update_df, [species.value]) +``` + +[Or check out our more worked out example](/documentation/examples/libraries/ipyaggrid). + + +## ipydatagrid + +The problem with ipydatagrid is similar to aggrid, except here we need to use the `dataframe` argument for the +constructor, and the `.data` property for updating the dataframe. + +```solara {pycafe-link extra-requirements="plotly, ipydatagrid"} +from typing import Dict, List, cast +import ipydatagrid +import plotly.express as px +import solara + + +df = px.data.iris() +species = solara.reactive("setosa") + + +@solara.component +def Page(): + df_filtered = df.query(f"species == {species.value!r}") + solara.Select("Filter species", value=species, values=["setosa", "versicolor", "virginica"]) + datagrid = ipydatagrid.DataGrid.element(dataframe=df_filtered, selection_mode="row") + + # we need to use .data instead (on the widget) to update the dataframe + # similar to aggrid + def update_df(): + # NOTE: the cast is optional, and only needed if you like type hinting + datagrid_widget = cast(ipydatagrid.DataGrid, solara.get_widget(datagrid)) + # Updating the dataframe goes via the .data property + datagrid_widget.data = df_filtered + + solara.use_effect(update_df, [species.value]) +``` + +[Or check out our more worked out example](/documentation/examples/libraries/ipydatagrid). + + ## Wrapper libraries However, because we care about type safety, we generate wrapper components for some libraries. This enables type completion in VSCode, type checks with VSCode, and mypy. diff --git a/solara/website/pages/documentation/examples/libraries/ipyaggrid.py b/solara/website/pages/documentation/examples/libraries/ipyaggrid.py new file mode 100644 index 000000000..cea24091c --- /dev/null +++ b/solara/website/pages/documentation/examples/libraries/ipyaggrid.py @@ -0,0 +1,57 @@ +""" +# ipyaggrid + +[IPyAgGrid](https://github.com/widgetti/ipyaggrid) is a Jupyter widget for the [AG-Grid](https://www.ag-grid.com/) JavaScript library. + +It is a feature-rich datagrid designed for enterprise applications. + +To use it in a Solara component, requires a bit of manual wiring up of the dataframe and grid_options, as the widget does not have traits for these. + +For more details, see [the IPywidget libraries Howto](https://solara.dev/docs/howto/ipywidget-libraries). + + +""" + +from typing import cast + +import ipyaggrid +import plotly.express as px + +import solara + +df = px.data.iris() +species = solara.reactive("setosa") +filter_species = solara.reactive(True) + + +@solara.component +def AgGrid(df, grid_options): + """Convenient component wrapper around ipyaggrid.Grid""" + + def update_df(): + widget = cast(ipyaggrid.Grid, solara.get_widget(el)) + widget.grid_options = grid_options + widget.update_grid_data(df) # this also updates the grid_options + + # when df changes, grid_data will be update, however, ... + el = ipyaggrid.Grid.element(grid_data=df, grid_options=grid_options) + # grid_data and grid_options are not traits, so letting them update by reacton/solara has no effect + # instead, we need to get a reference to the widget and call .update_grid_data in a use_effect + solara.use_effect(update_df, [df, grid_options]) + return el + + +@solara.component +def Page(): + grid_options = { + "columnDefs": [ + {"headerName": "Sepal Length", "field": "sepal_length"}, + {"headerName": "Species", "field": "species"}, + ] + } + + df_filtered = df.query(f"species == {species.value!r}") if filter_species.value else df + + solara.Select("Filter species", value=species, values=["setosa", "versicolor", "virginica"]) + solara.Checkbox(label="Filter species", value=filter_species) + AgGrid(df=df_filtered, grid_options=grid_options) diff --git a/solara/website/pages/documentation/examples/libraries/ipydatagrid.py b/solara/website/pages/documentation/examples/libraries/ipydatagrid.py new file mode 100644 index 000000000..933b6c689 --- /dev/null +++ b/solara/website/pages/documentation/examples/libraries/ipydatagrid.py @@ -0,0 +1,55 @@ +""" +# ipydatagrid + +[ipydatagrid](https://github.com/bloomberg/ipydatagrid) is a Jupyter widget developed by Bloomberg which describes itself as a +"Fast Datagrid widget for the Jupyter Notebook and JupyterLab". + +To use it in a Solara component requires a bit of manual wiring up of the dataframe, as this widget does not use a trait for this +(and the property name does not match the constructor argument). + +For more details, see [the IPywidget libraries Howto](https://solara.dev/docs/howto/ipywidget-libraries). + + +""" + +from typing import Dict, List, cast + +import ipydatagrid +import plotly.express as px + +import solara + +df = px.data.iris() +species = solara.reactive("setosa") +filter_species = solara.reactive(True) + + +@solara.component +def DataGrid(df, **kwargs): + """Convenient component wrapper around ipydatagrid.DataGrid""" + + def update_df(): + widget = cast(ipydatagrid.DataGrid, solara.get_widget(el)) + # This is needed to update the dataframe, see + # https://solara.dev/docs/howto/ipywidget-libraries for details + widget.data = df + + el = ipydatagrid.DataGrid.element(dataframe=df, **kwargs) # does NOT change when df changes + # we need to use .data instead (on the widget) to update the dataframe + solara.use_effect(update_df, [df]) + return el + + +@solara.component +def Page(): + selections: solara.Reactive[List[Dict]] = solara.use_reactive([]) + df_filtered = df.query(f"species == {species.value!r}") if filter_species.value else df + + with solara.Card("ipydatagrid demo", style={"width": "700px"}): + solara.Select("Filter species", value=species, values=["setosa", "versicolor", "virginica"]) + solara.Checkbox(label="Filter species", value=filter_species) + DataGrid(df=df_filtered, selection_mode="row", selections=selections.value, on_selections=selections.set) + if selections.value: + with solara.Column(): + solara.Text(f"Selected rows: {selections.value!r}") + solara.Button("Clear selections", on_click=lambda: selections.set([]))