Repository: randyzwitch/streamlit-folium (python)

Issue:
Feature group not removed from the map
from #153 
if you remove a dynamic feature group (setting feature_group_to_add to None), it is not removed from the map.
I think it comes from [here](https://github.com/randyzwitch/streamlit-folium/blob/884746d241dbc3e2767790898ff3a8d24644b647/streamlit_folium/frontend/src/index.tsx#L246C1-L248C6), where the removeLayer mewthod is called only if the feature group is not None.

Sample code:

```python
import folium
import geopandas as gpd
import shapely
import streamlit as st

from streamlit_folium import st_folium

st.set_page_config(layout='wide', initial_sidebar_state='collapsed')
st.write('<style>div.block-container{padding-top:2rem;}</style>', unsafe_allow_html=True) # remove padding top

START_LOCATION = [37.7944347109497, -122.398077892527]
START_ZOOM = 17

if not 'feature_group' in st.session_state:
    st.session_state['feature_group'] = None

wkt1 = "POLYGON ((-122.399077892527 37.7934347109497, -122.398922660838 37.7934544916178, -122.398980265018 37.7937266504805, -122.399133972495 37.7937070646238, -122.399077892527 37.7934347109497))"

polygon_1 = shapely.wkt.loads(wkt1)

gdf1 = gpd.GeoDataFrame(geometry=[polygon_1]).set_crs(epsg=4326)

style_parcels1 = {'fillColor': '#1100f8', 'color': '#1100f8', 'fillOpacity': 0.13, 'weight': 2 }

polygon_folium1 = folium.GeoJson(data=gdf1, style_function=lambda x: style_parcels1)

map = folium.Map(
    location=START_LOCATION, zoom_start=START_ZOOM, tiles="OpenStreetMap", max_zoom=21
)



fg1 = folium.FeatureGroup(name="Parcels")
fg1.add_child(polygon_folium1)

def add_fg_to_ss(fg):
    st.session_state["feature_group"] = fg

def remove_fg_from_ss():
    st.session_state["feature_group"] = None

b1 = st.button("Add Feature Group", on_click=add_fg_to_ss, args=(fg1,))
b2 = st.button("Remove Feature Group", on_click=remove_fg_from_ss)

st_folium(
    map,
    width=800,
    height=450,
    returned_objects=[],
    feature_group_to_add=st.session_state['feature_group'],
    debug=False,
)
```

Pull request description:
Ability to provide feature group as list
It gives the ability to provide feature groups as list
It fixes #163 and (includes #164)

I added tests to make sure it works. 
@blackary I am not an expert with this testing library, so I used the styling to make sure the layer is shown or not. There is probably a better way of doing it.

It partially addresses #153 (possibility to pass a list of feature groups). 
However, the layerControl is still not available.


Patch:
diff --git a/examples/pages/dynamic_updates.py b/examples/pages/dynamic_updates.py
index 8ed84e2..5b78b07 100644
--- a/examples/pages/dynamic_updates.py
+++ b/examples/pages/dynamic_updates.py
@@ -4,8 +4,10 @@ from pathlib import Path
 
 import folium
 import folium.features
+import geopandas as gpd
 import pandas as pd
 import requests
+import shapely
 import streamlit as st
 
 from streamlit_folium import st_folium
@@ -19,8 +21,8 @@ st.set_page_config(layout="wide")
 "# Dynamic Updates -- Click on a marker"
 
 st.subheader(
-    """Use new arguments `center`, `zoom`, and `feature_group_to_add` to update the map
-    without re-rendering it."""
+    "Use new arguments `center`, `zoom`, and `feature_group_to_add` to update the map "
+    "without re-rendering it."
 )
 
 
@@ -105,6 +107,76 @@ def main():
         st.session_state["selected_state"] = state
         st.experimental_rerun()
 
+    st.write("## Dynamic feature group updates")
+
+    START_LOCATION = [37.7944347109497, -122.398077892527]
+    START_ZOOM = 17
+
+    if "feature_group" not in st.session_state:
+        st.session_state["feature_group"] = None
+
+    wkt1 = (
+        "POLYGON ((-122.399077892527 37.7934347109497, -122.398922660838 "
+        "37.7934544916178, -122.398980265018 37.7937266504805, -122.399133972495 "
+        "37.7937070646238, -122.399077892527 37.7934347109497))"
+    )
+    wkt2 = (
+        "POLYGON ((-122.397416 37.795017, -122.397137 37.794712, -122.396332 37.794983,"
+        " -122.396171 37.795483, -122.396858 37.795695, -122.397652 37.795466, "
+        "-122.397759 37.79511, -122.397416 37.795017))"
+    )
+
+    polygon_1 = shapely.wkt.loads(wkt1)
+    polygon_2 = shapely.wkt.loads(wkt2)
+
+    gdf1 = gpd.GeoDataFrame(geometry=[polygon_1]).set_crs(epsg=4326)
+    gdf2 = gpd.GeoDataFrame(geometry=[polygon_2]).set_crs(epsg=4326)
+
+    style_parcels = {
+        "fillColor": "#1100f8",
+        "color": "#1100f8",
+        "fillOpacity": 0.13,
+        "weight": 2,
+    }
+    style_buildings = {
+        "color": "#ff3939",
+        "fillOpacity": 0,
+        "weight": 3,
+        "opacity": 1,
+        "dashArray": "5, 5",
+    }
+
+    polygon_folium1 = folium.GeoJson(data=gdf1, style_function=lambda x: style_parcels)
+    polygon_folium2 = folium.GeoJson(
+        data=gdf2, style_function=lambda x: style_buildings
+    )
+
+    map = folium.Map(
+        location=START_LOCATION,
+        zoom_start=START_ZOOM,
+        tiles="OpenStreetMap",
+        max_zoom=21,
+    )
+
+    fg1 = folium.FeatureGroup(name="Parcels")
+    fg1.add_child(polygon_folium1)
+
+    fg2 = folium.FeatureGroup(name="Buildings")
+    fg2.add_child(polygon_folium2)
+
+    fg_dict = {"Parcels": fg1, "Buildings": fg2, "None": None, "Both": [fg1, fg2]}
+
+    fg = st.radio("Feature Group", ["Parcels", "Buildings", "None", "Both"])
+
+    st_folium(
+        map,
+        width=800,
+        height=450,
+        returned_objects=[],
+        feature_group_to_add=fg_dict[fg],
+        debug=True,
+    )
+
 
 if __name__ == "__main__":
     main()
diff --git a/streamlit_folium/__init__.py b/streamlit_folium/__init__.py
index 5e49763..d500c6a 100644
--- a/streamlit_folium/__init__.py
+++ b/streamlit_folium/__init__.py
@@ -5,7 +5,7 @@ import os
 import re
 import warnings
 from textwrap import dedent
-from typing import Dict, Iterable, List
+from typing import Iterable
 
 import branca
 import folium
@@ -154,23 +154,26 @@ def _get_map_string(fig: folium.Map) -> str:
 def _get_feature_group_string(
     feature_group_to_add: folium.FeatureGroup,
     map: folium.Map,
+    idx: int = 0,
 ) -> str:
-    feature_group_to_add._id = "feature_group"
+    feature_group_to_add._id = f"feature_group_{idx}"
     feature_group_to_add.add_to(map)
     feature_group_to_add.render()
     feature_group_string = generate_leaflet_string(
-        feature_group_to_add, base_id="feature_group"
+        feature_group_to_add, base_id=f"feature_group_{idx}"
     )
     m_id = get_full_id(map)
     feature_group_string = feature_group_string.replace(m_id, "map_div")
     feature_group_string = dedent(feature_group_string)
 
     feature_group_string += dedent(
-        """
-        map_div.addLayer(feature_group_feature_group);
-        window.feature_group = feature_group_feature_group;
+        f"""
+        map_div.addLayer(feature_group_feature_group_{idx});
+        window.feature_group = window.feature_group || [];
+        window.feature_group.push(feature_group_feature_group_{idx});
         """
     )
+
     return feature_group_string
 
 
@@ -182,7 +185,7 @@ def st_folium(
     returned_objects: Iterable[str] | None = None,
     zoom: int | None = None,
     center: tuple[float, float] | None = None,
-    feature_group_to_add: folium.FeatureGroup | None = None,
+    feature_group_to_add: list[folium.FeatureGroup] | folium.FeatureGroup | None = None,
     return_on_hover: bool = False,
     use_container_width: bool = False,
     debug: bool = False,
@@ -212,7 +215,7 @@ def st_folium(
         The center of the map. If None, the center will be set to the default
         center of the map. NOTE that if this center is changed, it will *not* reload
         the map, but simply dynamically change the center.
-    feature_group_to_add: folium.FeatureGroup or None
+    feature_group_to_add: List[folium.FeatureGroup] or folium.FeatureGroup or None
         If you want to dynamically add features to a feature group, you can pass
         the feature group here. NOTE that if you add a feature to the map, it
         will *not* reload the map, but simply dynamically add the feature.
@@ -257,7 +260,7 @@ def st_folium(
 
     m_id = get_full_id(folium_map)
 
-    def bounds_to_dict(bounds_list: List[List[float]]) -> Dict[str, Dict[str, float]]:
+    def bounds_to_dict(bounds_list: list[list[float]]) -> dict[str, dict[str, float]]:
         southwest, northeast = bounds_list
         return {
             "_southWest": {
@@ -302,10 +305,15 @@ def st_folium(
     # on the frontend.
     feature_group_string = None
     if feature_group_to_add is not None:
-        feature_group_string = _get_feature_group_string(
-            feature_group_to_add,
-            map=folium_map,
-        )
+        if isinstance(feature_group_to_add, folium.FeatureGroup):
+            feature_group_to_add = [feature_group_to_add]
+        feature_group_string = ""
+        for idx, feature_group in enumerate(feature_group_to_add):
+            feature_group_string += _get_feature_group_string(
+                feature_group,
+                map=folium_map,
+                idx=idx,
+            )
 
     if debug:
         with st.expander("Show generated code"):
diff --git a/streamlit_folium/frontend/src/index.tsx b/streamlit_folium/frontend/src/index.tsx
index 58ada95..d43d776 100644
--- a/streamlit_folium/frontend/src/index.tsx
+++ b/streamlit_folium/frontend/src/index.tsx
@@ -1,3 +1,4 @@
+import { Layer } from "leaflet"
 import { RenderData, Streamlit } from "streamlit-component-lib"
 import { debounce } from "underscore"
 import { circleToPolygon } from "./circle-to-polygon"
@@ -239,28 +240,31 @@ function onRender(event: Event): void {
     }
   }
 
-  if (
-    feature_group &&
-    feature_group !== window.__GLOBAL_DATA__.last_feature_group
-  ) {
-    if (window.feature_group) {
-      window.map.removeLayer(window.feature_group)
+  if (feature_group !== window.__GLOBAL_DATA__.last_feature_group) {
+    if (window.feature_group && window.feature_group.length > 0) {
+      window.feature_group.forEach((layer: Layer) => {
+        window.map.removeLayer(layer)
+      })
     }
-    // Though using `eval` is generally a bad idea, we're using it here
-    // because we're evaluating code that we've generated ourselves on the
-    // Python side. This is safe because we're not evaluating user input, so this
-    // couldn't be used to execute arbitrary code.
 
-    // eslint-disable-next-line
-    eval(feature_group)
     window.__GLOBAL_DATA__.last_feature_group = feature_group
-    for (let key in window.map._layers) {
-      let layer = window.map._layers[key]
-      layer.off("click", onLayerClick)
-      layer.on("click", onLayerClick)
-      if (return_on_hover) {
-        layer.off("mouseover", onLayerClick)
-        layer.on("mouseover", onLayerClick)
+
+    if (feature_group) {
+      // Though using `eval` is generally a bad idea, we're using it here
+      // because we're evaluating code that we've generated ourselves on the
+      // Python side. This is safe because we're not evaluating user input, so this
+      // couldn't be used to execute arbitrary code.
+
+      // eslint-disable-next-line
+      eval(feature_group)
+      for (let key in window.map._layers) {
+        let layer = window.map._layers[key]
+        layer.off("click", onLayerClick)
+        layer.on("click", onLayerClick)
+        if (return_on_hover) {
+          layer.off("mouseover", onLayerClick)
+          layer.on("mouseover", onLayerClick)
+        }
       }
     }
   }


Test patch:
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index 09a31ff..85db609 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -268,3 +268,67 @@ def test_grouped_layer_control(page: Page):
     page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_label(
         "g2"
     ).check()
+
+
+def test_dynamic_feature_group_update(page: Page):
+    page.get_by_role("link", name="dynamic updates").click()
+    page.get_by_text("Show generated code").click()
+
+    # Test showing only Parcel layer
+    page.get_by_test_id("stRadio").get_by_text("Parcels").click()
+    expect(
+        page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1')
+        .locator("path")
+        .first
+    ).to_be_visible()
+    expect(
+        page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1')
+        .locator("path")
+        .nth(1)
+    ).to_be_hidden()
+    expect(
+        page.get_by_text('"fillColor"')
+    ).to_be_visible()  # fillColor only present in parcel style
+    expect(
+        page.get_by_text('"dashArray"')
+    ).to_be_hidden()  # dashArray only present in building style
+
+    # Test showing only Building layer
+    page.get_by_test_id("stRadio").get_by_text("Buildings").click()
+    expect(
+        page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1')
+        .locator("path")
+        .first
+    ).to_be_visible()
+    expect(
+        page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1')
+        .locator("path")
+        .nth(1)
+    ).to_be_hidden()
+    expect(page.get_by_text("fillColor")).to_be_hidden()
+    expect(page.get_by_text("dashArray")).to_be_visible()
+
+    # Test showing no layers
+    page.get_by_test_id("stRadio").get_by_text("None").click()
+    expect(
+        page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1')
+        .locator("path")
+        .first
+    ).to_be_hidden()
+    expect(page.get_by_text("fillColor")).to_be_hidden()
+    expect(page.get_by_text("dashArray")).to_be_hidden()
+
+    # Test showing both layers
+    page.get_by_test_id("stRadio").get_by_text("Both").click()
+    expect(
+        page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1')
+        .locator("path")
+        .first
+    ).to_be_visible()
+    expect(
+        page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1')
+        .locator("path")
+        .nth(1)
+    ).to_be_visible()
+    expect(page.get_by_text("fillColor")).to_be_visible()
+    expect(page.get_by_text("dashArray")).to_be_visible()
diff --git a/tests/test_package.py b/tests/test_package.py
index 806cced..b196529 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -89,7 +89,7 @@ def test_feature_group():
 
     fg_str = _get_feature_group_string(fg, m)
 
-    assert "var feature_group_feature_group = L.featureGroup(" in fg_str
+    assert "var feature_group_feature_group_0 = L.featureGroup(" in fg_str
     assert ".addTo(map_div);" in fg_str