diff --git a/docs/images/recipes/component_apps/arcgis.png b/docs/images/recipes/component_apps/arcgis.png
new file mode 100644
index 000000000..02b0aa7d0
--- /dev/null
+++ b/docs/images/recipes/component_apps/arcgis.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5ef00bf5c9727ec1a8149ad1e6ba0cc79c68be40dbf05538d6ba04842dec13eb
+size 10058
diff --git a/docs/images/recipes/component_apps/geojson.png b/docs/images/recipes/component_apps/geojson.png
new file mode 100644
index 000000000..0863c152d
--- /dev/null
+++ b/docs/images/recipes/component_apps/geojson.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a3b844faec5d9bd7be08fc0bc736cc2d83b39791b4b2e32a8c5c31911249e190
+size 29031
diff --git a/docs/images/recipes/component_apps/overlay_popup.png b/docs/images/recipes/component_apps/overlay_popup.png
new file mode 100644
index 000000000..429ccd4bb
--- /dev/null
+++ b/docs/images/recipes/component_apps/overlay_popup.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:220a1bdf0dd42c127097367e67a3466019cfdaffef41fbb37cd025a852f0329b
+size 20713
diff --git a/docs/images/recipes/component_apps/wms.png b/docs/images/recipes/component_apps/wms.png
new file mode 100644
index 000000000..5c8fcb40f
--- /dev/null
+++ b/docs/images/recipes/component_apps/wms.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:563c60eb7046db82f3a54ef2713476829e46182385ed2b178472c07b0a0f5ced
+size 31814
diff --git a/docs/recipes.rst b/docs/recipes.rst
index 98764d79a..040ddf4d4 100644
--- a/docs/recipes.rst
+++ b/docs/recipes.rst
@@ -58,3 +58,14 @@ Databases
recipes/databases/persistent_service images/recipes/persistent_store_icon.png [database, persistent store, service]
recipes/databases/create_database_models images/recipes/database_model_icon.png [database, models, persistent store]
recipes/databases/working_with_database_models images/recipes/working_with_database_icon.png [database, models, persistent store]
+
+Component Apps
+++++++++++++++
+
+.. recipe-gallery::
+ :layout: multi-row
+
+ recipes/component_apps/mapping_with_geojson images/recipes/component_apps/geojson.png [component, app, geojson, map, server]
+ recipes/component_apps/mapping_with_wms images/recipes/component_apps/wms.png [component, app, wms, map, service]
+ recipes/component_apps/mapping_with_arcgis_rest images/recipes/component_apps/arcgis.png [component, app, arcgis, rest, map, server, service]
+ recipes/component_apps/mapping_with_popups_and_overlays images/recipes/component_apps/overlay_popup.png [component, app, arcgis, rest, map, server, service]
\ No newline at end of file
diff --git a/docs/recipes/component_apps/mapping_with_arcgis_rest.rst b/docs/recipes/component_apps/mapping_with_arcgis_rest.rst
new file mode 100644
index 000000000..b769dbca4
--- /dev/null
+++ b/docs/recipes/component_apps/mapping_with_arcgis_rest.rst
@@ -0,0 +1,89 @@
+.. _component_app__mapping_with_argis_rest :
+
+
+
+*************************************************
+Component Apps: Mapping with ArcGIS REST Services
+*************************************************
+
+.. important::
+
+ These recipes only apply to Component App development and will not work for Standard Apps.
+
+**Last Updated:** December 2025
+
+Image ArcGIS MapServer
+======================
+
+.. code-block:: python
+
+ @App.page
+ def image_arcgis_mapserver(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ center=[-10997148, 4569099],
+ zoom=4
+ )(
+ lib.ol.layer.Image(
+ lib.ol.source.ImageArcGISRest(
+ options=lib.Props(
+ ratio=1,
+ params={},
+ url="https://sampleserver6.arcgisonline.com/ArcGIS/rest/services/USA/MapServer"
+ )
+ )
+ )
+ )
+ )
+
+
+Tile ArcGIS MapServer
+=====================
+
+.. code-block:: python
+
+ @App.page
+ def tile_arcgis_mapserver(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ center=[-10997148, 4569099],
+ zoom=4
+ )(
+ lib.ol.layer.Tile(
+ extent=[-13884991, 2870341, -7455066, 6338219]
+ )(
+ lib.ol.source.TileArcGISRest(
+ url="https://sampleserver6.arcgisonline.com/ArcGIS/rest/services/USA/MapServer"
+ )
+ )
+ )
+ )
+
+XYZ Esri
+========
+
+.. note::
+
+ This example sets ``default_basemap=None`` since the XYZ Esri layer is a basemap.
+
+.. code-block:: python
+
+ @App.page
+ return lib.tethys.Display(
+ lib.tethys.Map(default_basemap=None)(
+ lib.ol.layer.WebGLTile(
+ lib.ol.source.ImageTile(
+ options=lib.Props(
+ attributions=(
+ 'Tiles \u00A9 ArcGIS'
+ ),
+ url=(
+ 'https://server.arcgisonline.com/ArcGIS/rest/services/' +
+ 'World_Topo_Map/MapServer/tile/{z}/{y}/{x}'
+ )
+ )
+ )
+ )
+ )
+ )
\ No newline at end of file
diff --git a/docs/recipes/component_apps/mapping_with_geojson.rst b/docs/recipes/component_apps/mapping_with_geojson.rst
new file mode 100644
index 000000000..b08119385
--- /dev/null
+++ b/docs/recipes/component_apps/mapping_with_geojson.rst
@@ -0,0 +1,228 @@
+.. _component_app__mapping_with_geojson :
+
+
+
+************************************
+Component Apps: Mapping with GeoJSON
+************************************
+
+.. important::
+
+ These recipes only apply to Component App development and will not work for Standard Apps.
+
+**Last Updated:** December 2025
+
+GeoJSON from a URL
+==================
+
+.. code-block:: python
+
+ @App.page
+ def geojson_from_url(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ lib.ol.layer.Vector(
+ lib.ol.source.Vector(
+ options=lib.Props(
+ url="https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_urban_areas.geojson",
+ format_="GeoJSON"
+ )
+ )
+ )
+ )
+ )
+
+GeoJSON from explicit inline
+============================
+
+.. code-block:: python
+
+ @App.page
+ def geojson_from_inline(lib):
+ features, set_features = lib.hooks.use_state({
+ 'type': 'FeatureCollection',
+ 'crs': {
+ 'type': 'name',
+ 'properties': {
+ 'name': 'EPSG:3857',
+ },
+ },
+ 'features': [
+ {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'Point',
+ 'coordinates': [0, 0],
+ },
+ },
+ {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'LineString',
+ 'coordinates': [
+ [4e6, -2e6],
+ [8e6, 2e6],
+ ],
+ },
+ },
+ {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'LineString',
+ 'coordinates': [
+ [4e6, 2e6],
+ [8e6, -2e6],
+ ],
+ },
+ },
+ {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'Polygon',
+ 'coordinates': [
+ [
+ [-5e6, -1e6],
+ [-3e6, -1e6],
+ [-4e6, 1e6],
+ [-5e6, -1e6],
+ ],
+ ],
+ },
+ },
+ {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'MultiLineString',
+ 'coordinates': [
+ [
+ [-1e6, -7.5e5],
+ [-1e6, 7.5e5],
+ ],
+ [
+ [1e6, -7.5e5],
+ [1e6, 7.5e5],
+ ],
+ [
+ [-7.5e5, -1e6],
+ [7.5e5, -1e6],
+ ],
+ [
+ [-7.5e5, 1e6],
+ [7.5e5, 1e6],
+ ],
+ ],
+ },
+ },
+ {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'MultiPolygon',
+ 'coordinates': [
+ [
+ [
+ [-5e6, 6e6],
+ [-3e6, 6e6],
+ [-3e6, 8e6],
+ [-5e6, 8e6],
+ [-5e6, 6e6],
+ ],
+ ],
+ [
+ [
+ [-2e6, 6e6],
+ [0, 6e6],
+ [0, 8e6],
+ [-2e6, 8e6],
+ [-2e6, 6e6],
+ ],
+ ],
+ [
+ [
+ [1e6, 6e6],
+ [3e6, 6e6],
+ [3e6, 8e6],
+ [1e6, 8e6],
+ [1e6, 6e6],
+ ],
+ ],
+ ],
+ },
+ },
+ {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'GeometryCollection',
+ 'geometries': [
+ {
+ 'type': 'LineString',
+ 'coordinates': [
+ [-5e6, -5e6],
+ [0, -5e6],
+ ],
+ },
+ {
+ 'type': 'Point',
+ 'coordinates': [4e6, -5e6],
+ },
+ {
+ 'type': 'Polygon',
+ 'coordinates': [
+ [
+ [1e6, -6e6],
+ [3e6, -6e6],
+ [2e6, -4e6],
+ [1e6, -6e6],
+ ],
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ })
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ lib.ol.layer.Vector(
+ lib.ol.source.Vector(
+ options=lib.Props(
+ features=features,
+ format_="GeoJSON"
+ )
+ )
+ )
+ )
+ )
+
+
+GeoJSON from File / Pandas Dataframe
+====================================
+
+.. code-block:: python
+
+ import pandas as pd
+ import geopandas as gpd
+ from shapely.geometry import Point
+ from tethys_sdk.components.utils import transform_coordinate
+
+ def csv_to_geojson(csv_path):
+ df = pd.read_csv(csv_path)
+ geometry = [Point(transform_coordinate(xy, "EPSG:4326", "EPSG:3857")) for xy in zip(df['lat'], df['lon'])]
+ gdf = gpd.GeoDataFrame(df, geometry=geometry, crs="EPSG:3857")
+ return gdf.to_json()
+
+ @App.page
+ def geojson_from_csv(lib):
+ resources = lib.hooks.use_resources()
+ geojson = csv_to_geojson(resources.path / "points.csv")
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ lib.ol.layer.Vector(
+ lib.ol.source.Vector(
+ options=lib.Props(
+ features=geojson,
+ format_="GeoJSON"
+ )
+ )
+ )
+ )
+ )
diff --git a/docs/recipes/component_apps/mapping_with_popups_and_overlays.rst b/docs/recipes/component_apps/mapping_with_popups_and_overlays.rst
new file mode 100644
index 000000000..081c02a59
--- /dev/null
+++ b/docs/recipes/component_apps/mapping_with_popups_and_overlays.rst
@@ -0,0 +1,105 @@
+.. _component_app__mapping_with_popups_and_overlays :
+
+
+
+************************************************
+Component Apps: Mapping with Popups and Overlays
+************************************************
+
+.. important::
+
+ These recipes only apply to Component App development and will not work for Standard Apps.
+
+**Last Updated:** December 2025
+
+Image ArcGIS MapServer
+======================
+
+.. code-block:: python
+
+ @App.page
+ def map_overlays(lib):
+ position, set_position = lib.hooks.use_state(None)
+ static_position = lib.utils.transform_coordinate([48.208889, 16.3725], "EPSG:4326", "EPSG:3857")
+
+ return lib.tethys.Display(
+ lib.html.a(
+ id_="vienna", # id_ is essential, as it's referenced in the associated "element" attribute below
+ className="overlay",
+ target="_blank",
+ href="https://en.wikipedia.org/wiki/Vienna",
+ style=lib.Style(
+ text_decoration=None,
+ color="white",
+ font_size="11pt",
+ font_weight="bold",
+ text_shadow="black 0.1em 0.1em 0.2em",
+ )
+ )("Vienna"),
+ lib.html.div(
+ id_="marker", # id_ is essential, as it's referenced in the associated "element" attribute below
+ style=lib.Style(
+ width="20px",
+ height="20px",
+ border="1px solid #088",
+ border_radius="10px",
+ background_color="#0FF",
+ opacity="0.5",
+ )
+ ),
+ lib.html.div(
+ id_="dynamic", # id_ is essential, as it's referenced in the associated "element" attribute below
+ style=lib.Style(
+ position="relative",
+ )
+ )(
+ lib.html.div(
+ style=lib.Style(
+ position="absolute",
+ left="-6px",
+ width=0,
+ height=0,
+ border_left="6px solid transparent", # Controls the width of the triangle
+ border_right="6px solid transparent", # Controls the width of the triangle
+ border_bottom="6px solid #ff0000",
+ )
+ ),
+ lib.html.div(
+ style=lib.Style(
+ position="absolute",
+ left="-6px",
+ top="6px",
+ width="200px",
+ background_color="lightblue",
+ padding="1em",
+ border="1px black solid"
+ )
+ )(
+ f"You clicked at: {", ".join(map(str, position))}"
+ )
+ ) if position else lib.html.div(),
+ lib.tethys.Map(
+ key="map",
+ onClick=lambda e: set_position(e.coordinate)
+ )(
+ lib.ol.Overlay(
+ options=lib.Props(
+ stopEvent=False
+ ),
+ position=static_position,
+ element="vienna" # The id_ of the above component to be used for this Overlay
+ ),
+ lib.ol.Overlay(
+ options=lib.Props(
+ stopEvent=False
+ ),
+ position=static_position,
+ positioning="center-center",
+ element="marker" # The id_ of the above component to be used for this Overlay
+ ),
+ lib.ol.Overlay(
+ position=position,
+ element="dynamic" # The id_ of the above component to be used for this Overlay
+ ) if position else None
+ )
+ )
\ No newline at end of file
diff --git a/docs/recipes/component_apps/mapping_with_wms.rst b/docs/recipes/component_apps/mapping_with_wms.rst
new file mode 100644
index 000000000..94786a032
--- /dev/null
+++ b/docs/recipes/component_apps/mapping_with_wms.rst
@@ -0,0 +1,487 @@
+.. _component_app__mapping_with_wms :
+
+
+
+********************************
+Component Apps: Mapping with WMS
+********************************
+
+.. important::
+
+ These recipes only apply to Component App development and will not work for Standard Apps.
+
+**Last Updated:** December 2025
+
+Single Image WMS
+================
+
+.. code-block:: python
+
+ @App.page
+ def single_image_wms(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ lib.ol.layer.Image(
+ lib.ol.source.ImageWMS(
+ options=lib.Props(
+ url="https://ahocevar.com/geoserver/wms",
+ params=lib.Props(LAYERS='topp:states'),
+ ratio=1,
+ serverType="geoserver"
+ )
+ )
+ )
+ )
+ )
+
+Tiled WMS
+=========
+
+.. code-block:: python
+
+ @App.page
+ def tiled_wms(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ lib.ol.layer.WebGLTile(
+ lib.ol.source.TileWMS(
+ options=lib.Props(
+ url="https://ahocevar.com/geoserver/wms",
+ params=lib.Props(LAYERS='topp:states', TILED=True),
+ serverType="geoserver",
+ # Countries have transparency, so do not fade tiles:
+ transition=0
+ )
+ )
+ )
+ )
+ )
+
+MapServer WMS
+=============
+
+.. code-block:: python
+
+ @App.page
+ def tiled_wms(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(projection="EPSG:4326")(
+ lib.ol.layer.Image(
+ lib.ol.source.Image(
+ options=lib.Props(
+ loader=lib.Props(
+ url="https://demo.mapserver.org/cgi-bin/wms?",
+ params=lib.Props(
+ LAYERS=['bluemarble,country_bounds,cities'],
+ VERSION="1.3.0",
+ FORMAT="image/png"
+ ),
+ projection="EPSG:4326",
+ # note: serverType only needs to be set when hidpi is True
+ hidpi=True,
+ serverType="mapserver"
+ )
+ )
+ )
+ )
+ )
+ )
+
+WMS Custom-Sized Tiles
+======================
+
+.. code-block:: python
+
+ from pyproj import CRS, Transformer
+ import math
+
+ def get_resolutions():
+ resolutions = []
+ crs_3857 = CRS("EPSG:3857")
+ geographic_bounds = crs_3857.area_of_use.bounds
+ transformer = Transformer.from_crs(crs_3857.geodetic_crs, crs_3857, always_xy=True)
+ proj_extent = transformer.transform_bounds(*geographic_bounds)
+ start_resolution = (proj_extent[2] - proj_extent[0]) / 256
+ for i in range(22):
+ resolutions.append(start_resolution / math.pow(2, i))
+ return resolutions
+
+ @App.page
+ def wms_custom_sized_tiles(lib):
+ get_resolutions()
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ lib.ol.layer.Tile(
+ lib.ol.source.TileWMS(
+ options=lib.Props(
+ url="https://ahocevar.com/geoserver/wms",
+ params=lib.Props(
+ LAYERS=['topp:states'],
+ TILED=True
+ ),
+ serverType="mapserver",
+ tileGrid=lib.Props(
+ extent=[-13884991, 2870341, -7455066, 6338219],
+ resolutions=get_resolutions(),
+ tileSize=[512, 256]
+ )
+ )
+ )
+ )
+ )
+ )
+
+WMS Legend
+==========
+
+.. important::
+
+ This example uses ``lib.ol.Map`` rather than ``lib.tethys.Map``.
+ This is because ``lib.tethys.Map`` does not provide the granular control over the underlying ``lib.ol.View`` that is required by this example.
+
+.. code-block:: python
+
+ @App.page
+ def wms_legend(lib):
+ legend_url, set_legend_url = lib.hooks.use_state("")
+
+ wms_source = lib.ol.source.ImageWMS(
+ options=lib.Props(
+ url="https://ahocevar.com/geoserver/wms",
+ params=lib.Props(LAYERS='topp:states'),
+ ratio=1,
+ serverType="geoserver"
+ )
+ )
+
+ lib.hooks.use_effect(lambda: set_legend_url(wms_source.get_legend_url()), dependencies=[])
+
+ return lib.tethys.Display(style=lib.Style(position="relative"))(
+ lib.html.div(style=lib.Style(position="absolute", top="5px", right="20px", zIndex=1))(
+ lib.html.img(src=legend_url) if legend_url else None
+ ),
+ lib.ol.Map(
+ lib.ol.View(
+ onChange=lambda e: set_legend_url(wms_source.get_legend_url(e.target.values_.resolution)),
+ center=[-10997148, 4569099],
+ zoom=4
+ ),
+ lib.ol.layer.Tile(lib.ol.source.OSM()),
+ lib.ol.layer.Image(
+ wms_source
+ )
+ )
+ )
+
+WMS Loader with SVG Format
+==========================
+
+.. code-block:: python
+
+ @App.page
+ def wms_loader_with_svg_format(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ lib.ol.layer.Image(
+ lib.ol.source.Image(
+ options=lib.Props(
+ loader=lib.Props(
+ url="https://ahocevar.com/geoserver/wms",
+ params=lib.Props(
+ LAYERS=['topp:states'],
+ FORMAT="image/svg+xml"
+ ),
+ ratio=1,
+ load=True
+ )
+ )
+ )
+ )
+ )
+ )
+
+WMS Time
+========
+
+.. code-block:: python
+
+ import datetime as dt
+ import random
+
+ def three_hours_ago():
+ now = dt.datetime.now()
+ return (now - dt.timedelta(hours=3)).replace(
+ minute=int(now.minute/15)*15,
+ second=0,
+ microsecond=0
+ )
+
+ def wms_time(lib):
+ frame_rate = 0.5 # frames per second
+ wms_time, set_wms_time = lib.hooks.use_state(lambda: three_hours_ago())
+ force_update, set_force_update = lib.hooks.use_state(False)
+ timer, set_timer = lib.hooks.use_state(None)
+
+ return lib.tethys.Display(style=lib.Style(position="relative"))(
+ lib.html.div(
+ style=lib.Style(
+ zIndex=1,
+ position="absolute",
+ top="5px",
+ right="20px"
+ )
+ )(
+ lib.html.div(style=lib.Style(display="flex", justify_content="center"))(
+ lib.html.button(
+ on_click=lambda _: set_timer(
+ lib.utils.background_execute(
+ lambda: set_wms_time(lambda old: three_hours_ago() if old + dt.timedelta(minutes=15) > dt.datetime.now() else old + dt.timedelta(minutes=15)),
+ repeat_seconds=1/frame_rate
+ )
+ ),
+ disabled=timer is not None
+ )(
+ "Play"
+ ),
+ lib.html.button(
+ on_click=lambda _: (
+ timer.cancel() if timer else None,
+ set_timer(None)
+ ),
+ disabled=timer is None
+ )(
+ "Stop"
+ )
+ ),
+ lib.html.div(
+ f"Time: {wms_time.isoformat()}"
+ )
+ ),
+ lib.tethys.Map(
+ lib.ol.layer.Tile(
+ lib.ol.source.TileWMS(
+ options=lib.Props(
+ url="https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r-t.cgi"
+ ),
+ params=lib.Props(
+ LAYERS=['nexrad-n0r-wmst'],
+ TIME=wms_time.isoformat()
+ )
+ )
+ )
+ )
+ )
+
+WMS without Projection
+======================
+
+As long as no coordinate transformations are required, the underlying OpenLayers mapping engine works fine with projections that are only configured with a code and units.
+
+.. important::
+
+ This example uses ``lib.ol.Map`` rather than ``lib.tethys.Map``.
+ This is because ``lib.tethys.Map`` provides a default suite of basemaps which are not compatible with projections lacking a full definition, like the one used in this example.
+
+.. code-block:: python
+
+ @App.page
+ def wms_without_projection(lib):
+ return lib.tethys.Display(
+ lib.ol.Map(
+ lib.ol.View(
+ options=lib.Props(
+ projection=lib.Props(code="EPSG:21781", units="m"),
+ ),
+ center=[660000, 190000],
+ zoom=9
+ ),
+ lib.ol.layer.Group(options=lib.Props(title="Overlays", fold="open"))(
+ lib.ol.layer.Tile(options=lib.Props(title="Custom Projection Basemap"))(
+ lib.ol.source.TileWMS(
+ options=lib.Props(
+ attributions=(
+ '\u00A9 Pixelmap 1:1000000 / geo.admin.ch'
+ ),
+ crossOrigin="anonymous",
+ params=lib.Props(
+ LAYERS="ch.swisstopo.pixelkarte-farbe-pk1000.noscale",
+ FORMAT="image/jpeg"
+ ),
+ url="https://wms.geo.admin.ch/",
+ ),
+ )
+ ),
+ lib.ol.layer.Image(options=lib.Props(title="Flood Alert"))(
+ lib.ol.source.ImageWMS(
+ options=lib.Props(
+ attributions=(
+ '\u00A9 Flood Alert / geo.admin.ch'
+ ),
+ crossOrigin="anonymous",
+ params=lib.Props(LAYERS="ch.bafu.hydroweb-warnkarte_national"),
+ serverType="mapserver",
+ url="https://wms.geo.admin.ch/"
+ )
+ )
+ ),
+ ),
+ lib.ol.control.ScaleLine(),
+ lib.tethys.LayerPanel(),
+ )
+ )
+
+Single Image WMS with Custom Projection
+========================================
+
+.. code-block:: python
+
+ CUSTOM_PRJ = (
+ '+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 ' +
+ '+x_0=600000 +y_0=200000 +ellps=bessel ' +
+ '+towgs84=660.077,13.551,369.344,2.484,1.783,2.939,5.66 +units=m +no_defs'
+ )
+
+ @App.page
+ def wms_image_with_custom_projection(lib):
+ return lib.tethys.Display(
+ lib.tethys.Map(
+ projection=lib.Props(
+ code="EPSG:21781",
+ extent=[485869.5728, 76443.1884, 837076.5648, 299941.7864],
+ definition=CUSTOM_PRJ
+ ),
+ zoom=2,
+ center=lib.utils.transform_coordinate([8.23, 46.86], "EPSG:4326", CUSTOM_PRJ)
+ )(
+ lib.ol.layer.Tile(options=lib.Props(title="Custom Projection Basemap"))(
+ lib.ol.source.TileWMS(
+ options=lib.Props(
+ attributions=(
+ '\u00A9 Pixelmap 1:1000000 / geo.admin.ch'
+ ),
+ crossOrigin="anonymous",
+ params=lib.Props(
+ LAYERS="ch.swisstopo.pixelkarte-farbe-pk1000.noscale",
+ FORMAT="image/jpeg"
+ ),
+ url="https://wms.geo.admin.ch/",
+ ),
+ )
+ ),
+ lib.ol.layer.Image(options=lib.Props(title="Flood Alert"))(
+ lib.ol.source.ImageWMS(
+ options=lib.Props(
+ attributions=(
+ '\u00A9 Flood Alert / geo.admin.ch'
+ ),
+ crossOrigin="anonymous",
+ params=lib.Props(LAYERS="ch.bafu.hydroweb-warnkarte_national"),
+ serverType="mapserver",
+ url="https://wms.geo.admin.ch/"
+ )
+ )
+ )
+ )
+ )
+
+WMS GetFeatureInfo (Image Layer)
+================================
+
+.. code-block:: python
+
+ @App.page
+ def wms_get_feature_info(lib):
+ props, set_props = lib.hooks.use_state(None)
+
+ wms_source = lib.ol.source.ImageWMS(
+ options=lib.Props(
+ url="https://ahocevar.com/geoserver/wms",
+ params=lib.Props(LAYERS="ne:ne"),
+ serverType="geoserver",
+ crossOrigin="anonymous"
+ )
+ )
+
+ return lib.tethys.Display(
+ lib.html.div(
+ style=lib.Style(height="85vh")
+ )(
+ lib.tethys.Map(
+ default_basemap=None,
+ on_click=lambda e: (
+ set_props(
+ lib.utils.fetch(
+ wms_source.get_feature_info_url(
+ e.coordinate,
+ e.target.frameState_.viewState.resolution,
+ e.target.values_.view.projection_.code_,
+ "EPSG:3857",
+ {"INFO_FORMAT": "text/html"}
+ )
+ )
+ )
+ )
+ )(
+ lib.ol.layer.Image(wms_source)
+ )
+ ),
+ lib.html.div(
+ style=lib.Style(height="15vh", width="95vw")
+ )(
+ lib.html.iframe(width="100%", srcdoc=props) if props else lib.html.h1("Click Map For Feature Info")
+ )
+ )
+
+WMS GetFeatureInfo (Tile Layer)
+================================
+
+.. code-block:: python
+
+ @App.page
+ def wms_get_feature_info(lib):
+ props, set_props = lib.hooks.use_state(None)
+
+ wms_source = lib.ol.source.TileWMS(
+ options=lib.Props(
+ url="https://ahocevar.com/geoserver/wms",
+ params=lib.Props(LAYERS="ne:ne", TILED=True),
+ serverType="geoserver",
+ crossOrigin="anonymous"
+ ),
+ )
+
+ return lib.tethys.Display(
+ lib.html.div(
+ style=lib.Style(height="85vh")
+ )(
+ lib.tethys.Map(
+ default_basemap=None,
+ on_click=lambda e: (
+ set_props(
+ lib.utils.fetch(
+ wms_source.get_feature_info_url(
+ e.coordinate,
+ e.target.frameState_.viewState.resolution,
+ e.target.values_.view.projection_.code_,
+ "EPSG:3857",
+ {"INFO_FORMAT": "text/html"}
+ )
+ )
+ )
+ )
+ )(
+ lib.ol.layer.Tile(
+ wms_source
+ )
+ )
+ ),
+ lib.html.div(
+ style=lib.Style(height="15vh", width="95vw")
+ )(
+ lib.html.iframe(width="100%", srcdoc=props) if props else lib.html.h1("Click Map For Feature Info")
+ )
+ )
diff --git a/tests/unit_tests/test_tethys_components/test_custom_and_layouts.py b/tests/unit_tests/test_tethys_components/test_custom_and_layouts.py
index c3ec0aa76..2e7155b80 100644
--- a/tests/unit_tests/test_tethys_components/test_custom_and_layouts.py
+++ b/tests/unit_tests/test_tethys_components/test_custom_and_layouts.py
@@ -30,6 +30,7 @@ def setUpClass(cls):
"NavHeader": [{"app": cls.mock_all, "user": cls.mock_all}],
"PageLoader": [{"content": "TEST"}],
"Chart": [{"data": [{"x": 1, "y": 2}, {"x": 2, "y": 10}]}, {"data": None}],
+ "Display": [{"style": {"color": "black"}}],
}
def json_serializer(self, obj):
@@ -108,3 +109,7 @@ def test_navheader_special_case_1(self):
vdom_content = layout["children"][1]["children"]
self.assertIsInstance(vdom_content, list)
self.assertListEqual([content], vdom_content)
+
+ def test_map_invalid_projection_dict(self):
+ with self.assertRaises(ValueError):
+ custom.Map(self.lib, projection={})
diff --git a/tests/unit_tests/test_tethys_components/test_library.py b/tests/unit_tests/test_tethys_components/test_library.py
index 34831c212..dd6859296 100644
--- a/tests/unit_tests/test_tethys_components/test_library.py
+++ b/tests/unit_tests/test_tethys_components/test_library.py
@@ -2,7 +2,7 @@
from json import dumps
from unittest import TestCase, mock
from pathlib import Path
-from tethys_components import library
+from tethys_components import library, utils
import reactpy
THIS_DIR = Path(__file__).parent
@@ -12,7 +12,7 @@
class TestComponentLibrary(TestCase):
@classmethod
def setUpClass(cls):
- cls.test_pages = list(LIBRARY_EVAL_DIR.glob("test_page_*.py"))
+ cls.test_pages = list(LIBRARY_EVAL_DIR.glob("_test_page_*.py"))
sys.path.append(str(LIBRARY_EVAL_DIR))
@classmethod
@@ -115,3 +115,15 @@ def test_lib_hooks_are_reactpy_hooks(self):
lib = library.ComponentLibrary("hooks_test")
self.assertEqual(lib.hooks, hooks)
+
+ def test_callable_vdom_as_dict(self):
+ test_dict = {"test": 1, "foo": 2, "bar": 3}
+ instance = library._CallableVdom(**test_dict)
+ self.assertDictEqual(instance.as_dict(), test_dict)
+
+ @mock.patch("tethys_components.library.partial")
+ def test_callable_vdom_custom_util_func(self, mock_partial):
+ test_dict = {"test": 1, "foo": 2, "bar": 3}
+ instance = library._CallableVdom(**test_dict)
+ instance.get_feature_info_url
+ mock_partial.assert_called_once_with(utils._get_feature_info_url_, instance)
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Display_1_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Display_1_expected.json
index 8ada0bf50..32589b2ed 100644
--- a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Display_1_expected.json
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Display_1_expected.json
@@ -1 +1 @@
-{"tagName": "Container", "attributes": {"fluid": true, "style": {"height": "100%"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": []}
\ No newline at end of file
+{"tagName": "Container", "attributes": {"fluid": true, "style": {"height": "100%", "color": "black"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": []}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__HeaderWithNavBar_1_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__HeaderWithNavBar_1_expected.json
index 59b5c9416..33aad9e59 100644
--- a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__HeaderWithNavBar_1_expected.json
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__HeaderWithNavBar_1_expected.json
@@ -1 +1 @@
-{"tagName": "div", "children": [{"tagName": "script", "children": ["window.setTimeout(function () {window.location = \"MOCK\";}, 200);"], "key": "window.setTimeout(function () {window.location = \"MOCK\";}, 200);"}, {"tagName": "Navbar", "attributes": {"fixed": "top", "className": "shadow", "expand": false, "variant": "dark", "style": {"background": "MOCK", "height": "56px", "margin-top": "MOCK", "transition": "margin 0.4s ease", "-webkit-transition": "margin 0.4s ease", "-moz-transition": "margin 0.4s ease", "-o-transition": "margin 0.4s ease"}}, "key": "navbar", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Container", "attributes": {"as": "header", "fluid": true, "className": "px-4"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "NavbarToggle", "attributes": {"ariaControls": "offcanvasNavbar", "className": "styled-header-button"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "NavbarBrand", "attributes": {"href": "/apps/MOCK/", "className": "mx-0 d-none d-sm-block", "style": {"color": "white"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "img", "attributes": {"src": "/static/MOCK", "className": "d-inline-block align-top", "style": {"padding": 0, "height": "30px", "border-radius": "50%", "background-color": "MOCK"}}}, " MOCK"]}, {"tagName": "Form", "attributes": {"inline": "true"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": "me-2", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-app-settings", "href": "/admin/tethys_apps/tethysapp/999/change/", "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": " styled-header-button", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-exit-app", "href": "#", "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "eventHandlers": {"onClick": "EventHandler"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "NavbarOffcanvas", "attributes": {"id": "offcanvasNavbar", "ariaLabelledby": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasHeader", "attributes": {"closeButton": true}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasTitle", "attributes": {"id": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Navigation"]}]}, {"tagName": "OffcanvasBody", "children": [{"tagName": "Nav", "attributes": {"variant": "pills", "defaultActiveKey": "/apps/MOCK", "class_name": "flex-column"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}]}]}
\ No newline at end of file
+{"tagName": "div", "children": [{"tagName": "script", "children": ["window.setTimeout(function () {window.location = \"MOCK\";}, 200);"], "key": "window.setTimeout(function () {window.location = \"MOCK\";}, 200);"}, {"tagName": "Navbar", "attributes": {"fixed": "top", "className": "shadow", "expand": false, "variant": "dark", "style": {"background": "MOCK", "height": "56px", "margin-top": "MOCK", "transition": "margin 0.4s ease", "-webkit-transition": "margin 0.4s ease", "-moz-transition": "margin 0.4s ease", "-o-transition": "margin 0.4s ease"}}, "key": "navbar", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Container", "attributes": {"as": "header", "fluid": true, "className": "px-4"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "span"}, {"tagName": "NavbarBrand", "attributes": {"href": "/apps/MOCK/", "className": "mx-0 d-none d-sm-block", "style": {"color": "white"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "img", "attributes": {"src": "/static/MOCK", "className": "d-inline-block align-top", "style": {"padding": 0, "height": "30px", "border-radius": "50%", "background-color": "MOCK"}}}, " MOCK"]}, {"tagName": "Form", "attributes": {"inline": "true"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": "me-2", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-app-settings", "href": "/admin/tethys_apps/tethysapp/999/change/", "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": " styled-header-button", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-exit-app", "href": "#", "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "eventHandlers": {"onClick": "EventHandler"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "NavbarOffcanvas", "attributes": {"id": "offcanvasNavbar", "ariaLabelledby": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasHeader", "attributes": {"closeButton": true}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasTitle", "attributes": {"id": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Navigation"]}]}, {"tagName": "OffcanvasBody", "children": [{"tagName": "Nav", "attributes": {"variant": "pills", "defaultActiveKey": "/apps/MOCK", "class_name": "flex-column"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}]}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Map_1_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Map_1_expected.json
index d19c6265a..2d1789f49 100644
--- a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Map_1_expected.json
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__Map_1_expected.json
@@ -1 +1 @@
-{"tagName": "Map", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "View", "attributes": {"options": {"projection": "EPSG:3857", "center": [-100, 40], "zoom": 3.5}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Basemap", "fold": "close"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "None"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": true, "type": "base", "title": "OpenStreetMap"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OSMSource", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "XYZ"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "NatGeo World Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "USA Topo Maps"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/USA_Topo_Maps/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Imagery"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Physical Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Physical_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Shaded Relief"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Street Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Terrain Base"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Terrain_Base/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Topo Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_all)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_nolabels)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_all)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_nolabels)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Overlays", "fold": "open"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": []}, {"tagName": "ScaleLineControl", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "LayerPanel", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}
\ No newline at end of file
+{"tagName": "Map", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "View", "attributes": {"options": {"projection": "EPSG:3857"}, "center": [-100, 40], "zoom": 1}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Basemap", "fold": "close"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "None"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": true, "type": "base", "title": "OpenStreetMap"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OSMSource", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "XYZ"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "NatGeo World Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "USA Topo Maps"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/USA_Topo_Maps/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Imagery"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Physical Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Physical_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Shaded Relief"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Street Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Terrain Base"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Terrain_Base/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Topo Map"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_all)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_nolabels)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_all)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_nolabels)"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Overlays", "fold": "open"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": []}, {"tagName": "ScaleLineControl", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "LayerPanel", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__PageLoader_1_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__PageLoader_1_expected.json
index 6e483ecbe..3dfa4c456 100644
--- a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__PageLoader_1_expected.json
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/custom__PageLoader_1_expected.json
@@ -1 +1 @@
-{"tagName": "div", "attributes": {"style": {"height": "100%", "width": "100%"}}, "children": [null, {"tagName": "div", "attributes": {"style": {"display": null, "height": "100%", "width": "100%"}}, "key": "page-content", "children": ["TEST"]}]}
\ No newline at end of file
+{"tagName": "div", "attributes": {"style": {"height": "100%", "width": "100%"}}, "children": [null, {"tagName": "div", "attributes": {"style": {"display": null, "height": "100%", "width": "100%"}}, "key": "page-content", "children": ["TEST", null]}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/layouts__NavHeader_1_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/layouts__NavHeader_1_expected.json
index 1e4da8ab4..b068338c8 100644
--- a/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/layouts__NavHeader_1_expected.json
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_custom_and_layouts/layouts__NavHeader_1_expected.json
@@ -1 +1 @@
-{"tagName": "div", "attributes": {"style": {"height": "100vh"}}, "children": [{"tagName": "div", "children": [{"tagName": "script", "children": ["window.setTimeout(function () {window.location = \"MOCK\";}, 200);"], "key": "window.setTimeout(function () {window.location = \"MOCK\";}, 200);"}, {"tagName": "Navbar", "attributes": {"fixed": "top", "className": "shadow", "expand": false, "variant": "dark", "style": {"background": "MOCK", "height": "56px", "margin-top": "MOCK", "transition": "margin 0.4s ease", "-webkit-transition": "margin 0.4s ease", "-moz-transition": "margin 0.4s ease", "-o-transition": "margin 0.4s ease"}}, "key": "navbar", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Container", "attributes": {"as": "header", "fluid": true, "className": "px-4"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "NavbarToggle", "attributes": {"ariaControls": "offcanvasNavbar", "className": "styled-header-button"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "NavbarBrand", "attributes": {"href": "/apps/MOCK/", "className": "mx-0 d-none d-sm-block", "style": {"color": "white"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "img", "attributes": {"src": "/static/MOCK", "className": "d-inline-block align-top", "style": {"padding": 0, "height": "30px", "border-radius": "50%", "background-color": "MOCK"}}}, " MOCK"]}, {"tagName": "Form", "attributes": {"inline": "true"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": "me-2", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-app-settings", "href": "/admin/tethys_apps/tethysapp/999/change/", "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": " styled-header-button", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-exit-app", "href": "#", "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "eventHandlers": {"onClick": "EventHandler"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "NavbarOffcanvas", "attributes": {"id": "offcanvasNavbar", "ariaLabelledby": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasHeader", "attributes": {"closeButton": true}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasTitle", "attributes": {"id": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Navigation"]}]}, {"tagName": "OffcanvasBody", "children": [{"tagName": "Nav", "attributes": {"variant": "pills", "defaultActiveKey": "/apps/MOCK", "class_name": "flex-column"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}]}]}, {"tagName": "div", "attributes": {"style": {"paddingTop": "56px", "height": "100%", "width": "100%"}}, "children": []}]}
\ No newline at end of file
+{"tagName": "div", "attributes": {"style": {"height": "100vh"}}, "children": [{"tagName": "div", "children": [{"tagName": "script", "children": ["window.setTimeout(function () {window.location = \"MOCK\";}, 200);"], "key": "window.setTimeout(function () {window.location = \"MOCK\";}, 200);"}, {"tagName": "Navbar", "attributes": {"fixed": "top", "className": "shadow", "expand": false, "variant": "dark", "style": {"background": "MOCK", "height": "56px", "margin-top": "MOCK", "transition": "margin 0.4s ease", "-webkit-transition": "margin 0.4s ease", "-moz-transition": "margin 0.4s ease", "-o-transition": "margin 0.4s ease"}}, "key": "navbar", "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Container", "attributes": {"as": "header", "fluid": true, "className": "px-4"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "span"}, {"tagName": "NavbarBrand", "attributes": {"href": "/apps/MOCK/", "className": "mx-0 d-none d-sm-block", "style": {"color": "white"}}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "img", "attributes": {"src": "/static/MOCK", "className": "d-inline-block align-top", "style": {"padding": 0, "height": "30px", "border-radius": "50%", "background-color": "MOCK"}}}, " MOCK"]}, {"tagName": "Form", "attributes": {"inline": "true"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": "me-2", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-app-settings", "href": "/admin/tethys_apps/tethysapp/999/change/", "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Gear", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "Button", "attributes": {"variant": "light", "size": "sm", "className": " styled-header-button", "style": {"background-color": "rgba(255, 255, 255, 0.1)", "border": "none", "color": "white", "border-radius": "unset"}, "id": "btn-exit-app", "href": "#", "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, "eventHandlers": {"onClick": "EventHandler"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "X", "attributes": {"size": "1.5rem"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "NavbarOffcanvas", "attributes": {"id": "offcanvasNavbar", "ariaLabelledby": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasHeader", "attributes": {"closeButton": true}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OffcanvasTitle", "attributes": {"id": "offcanvasNavbarLabel"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Navigation"]}]}, {"tagName": "OffcanvasBody", "children": [{"tagName": "Nav", "attributes": {"variant": "pills", "defaultActiveKey": "/apps/MOCK", "class_name": "flex-column"}, "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "components-test-lib.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}]}]}, {"tagName": "div", "attributes": {"style": {"paddingTop": "56px", "height": "100%", "width": "100%"}}, "children": []}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1.py b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1.py
similarity index 100%
rename from tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1.py
rename to tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1.py
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1_expected.js b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1_expected.js
new file mode 100644
index 000000000..cc919e8d4
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1_expected.js
@@ -0,0 +1,117 @@
+
+import {Container, Row, Button, Col} from "https://esm.sh/react-bootstrap/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=Container,Row,Button,Col";
+export {Container, Row, Button, Col};
+loadCSS("https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css");
+import Editor from "https://esm.sh/@monaco-editor/react/?deps=react@19.0,react-dom@19.0,react-is@19.0";
+export {Editor};
+
+function loadCSS(href) {
+ var head = document.getElementsByTagName('head')[0];
+
+ if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
+ // Creating link element
+ var style = document.createElement('link');
+ style.id = href;
+ style.href = href;
+ style.type = 'text/css';
+ style.rel = 'stylesheet';
+ head.append(style);
+ }
+}
+
+
+export function bind(node, config) {
+ const root = ReactDOM.createRoot(node);
+ return {
+ create: (component, props, children) =>
+ React.createElement(component, wrapEventHandlers(props), ...children),
+ render: (element) => root.render(element),
+ unmount: () => root.unmount()
+ };
+}
+
+
+function wrapEventHandlers(props) {
+ const newProps = Object.assign({}, props);
+ for (const [key, value] of Object.entries(props)) {
+ if (typeof value === "function") {
+ newProps[key] = makeJsonSafeEventHandler(value);
+ }
+ }
+ return newProps;
+}
+
+/**
+ * Converts an HTML element and its children into a structured JavaScript object.
+ * @param {HTMLElement} element The HTML element to convert.
+ * @return {object} The structured object.
+ */
+function htmlToJsonObject(element) {
+ if (!element) return null;
+
+ const obj = {
+ tagName: element.tagName.toLowerCase(),
+ attributes: {}
+ };
+
+ // Get attributes
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attr = element.attributes[i];
+ obj.attributes[attr.name] = attr.value;
+ }
+
+ return obj;
+}
+
+function jsonSanitizeObject(obj, maxDepth, refs, depth) {
+ if (!maxDepth) {
+ maxDepth = 4;
+ }
+ if (!depth) {
+ depth = 0;
+ }
+ if (!refs) {
+ refs = [];
+ }
+ if (typeof obj === 'string' || typeof obj === 'number' || obj == null || typeof obj === 'boolean') {
+ return obj;
+ }
+ if (typeof obj === 'function') {
+ return undefined;
+ }
+ if (obj.constructor === Window) {
+ return undefined;
+ }
+ if (refs.includes(obj)) {
+ return undefined;
+ }
+ refs.push(obj);
+ delete obj.nativeEvent;
+ let newObj = Array.isArray(obj) ? [] : {};
+ if (depth > maxDepth) {
+ newObj = "BEYOND MAX DEPTH";
+ } else {
+ for (const [key, value] of Object.entries(obj)) {
+ if (refs.includes(value)) continue;
+ newObj[key] = jsonSanitizeObject(value, maxDepth, refs, depth+1);
+ }
+ if (obj.__proto__) {
+ Object.getOwnPropertyNames(obj.__proto__).forEach(function (propName) {
+ newObj[propName] = jsonSanitizeObject(obj[propName], maxDepth, refs, depth+1);
+ });
+ }
+ if (obj instanceof Element) {
+ newObj = {...newObj, ...htmlToJsonObject(obj)}
+ }
+ }
+ return newObj;
+}
+
+function makeJsonSafeEventHandler(oldHandler) {
+ // Since we can't really know what the event handlers get passed we have to check if
+ // they are JSON serializable or not. We can allow normal synthetic events to pass
+ // through since the original handler already knows how to serialize those for us.
+ return function safeEventHandler() {
+ oldHandler(...Array.from(arguments).map((x) => jsonSanitizeObject(x, 4)));
+ };
+}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1_expected.json
new file mode 100644
index 000000000..d5d4cae05
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_1_expected.json
@@ -0,0 +1 @@
+{"tagName": "div", "children": [{"tagName": "Container", "attributes": {"fluid": true, "style": {"height": "calc(100vh - 75px"}}, "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Row", "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Render"]}]}, {"tagName": "Row", "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Col", "attributes": {"style": {"width": "50vw"}}, "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Editor", "attributes": {"height": "80vh", "language": "python", "theme": "vs-dark", "value": "", "options": {"inlineSuggest": true, "fontSize": "16px", "formatOnType": true}}, "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "Col", "attributes": {"style": {"width": "50vw"}}, "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "iframe", "attributes": {"style": {"height": "100%", "width": "100%"}, "src": "/apps/component-app-playground/render", "title": "Result"}}]}]}, {"tagName": "Row", "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "importSource": {"source": "_test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Render"]}]}]}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2.py b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2.py
similarity index 76%
rename from tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2.py
rename to tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2.py
index 5c0e5aee2..f21c82dff 100644
--- a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2.py
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2.py
@@ -8,7 +8,17 @@ def page_test(lib):
lib.tethys.Map(
lib.ol.layer.Image(title="GEOGLOWS Streamflow Service")(
lib.ol.source.ImageArcGISRest(
- url="https://livefeeds3.arcgis.com/arcgis/rest/services/GEOGLOWS/GlobalWaterModel_Medium/MapServer"
+ options=lib.Props(
+ url="https://livefeeds3.arcgis.com/arcgis/rest/services/GEOGLOWS/GlobalWaterModel_Medium/MapServer"
+ )
+ )
+ ),
+ lib.ol.layer.Vector(
+ lib.ol.source.Vector(
+ options=lib.Props(
+ url="https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_110m_ocean.geojson",
+ format_="GeoJSON",
+ )
)
),
),
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2_expected.js b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2_expected.js
new file mode 100644
index 000000000..ba03ad1da
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2_expected.js
@@ -0,0 +1,135 @@
+
+import {Col, Row, Container} from "https://esm.sh/react-bootstrap/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=Col,Row,Container";
+export {Col, Row, Container};
+loadCSS("https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css");
+import ImageLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Image.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import ImageArcGISRestSource from "https://esm.sh/@planet/maps@11.2.0/source/ImageArcGISRest.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import VectorLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Vector.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import Map from "https://esm.sh/@planet/maps@11.2.0/Map.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import XYZSource from "https://esm.sh/@planet/maps@11.2.0/source/XYZ.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import OSMSource from "https://esm.sh/@planet/maps@11.2.0/source/OSM.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import GroupLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Group.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import TileLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Tile.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import ScaleLineControl from "https://esm.sh/@planet/maps@11.2.0/control/ScaleLine.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+export {ImageLayer, ImageArcGISRestSource, VectorLayer, Map, XYZSource, OSMSource, GroupLayer, TileLayer, ScaleLineControl};
+loadCSS("https://esm.sh/ol@10.7.0/ol.css");
+import {LayerPanel} from "/static/tethys_apps/js/layer-panel.js/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=LayerPanel";
+export {LayerPanel};
+loadCSS("https://esm.sh/ol-layerswitcher@4.1.2/dist/ol-layerswitcher.css");
+loadCSS("https://esm.sh/ol-side-panel@1.0.6/src/SidePanel.css");
+import VectorSource from "/static/tethys_apps/js/ol-mods/source/Vector.js?deps=react@19.0,react-dom@19.0,react-is@19.0";
+import View from "/static/tethys_apps/js/ol-mods/View.js?deps=react@19.0,react-dom@19.0,react-is@19.0";
+export {VectorSource, View};
+import {LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line} from "https://esm.sh/recharts/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=LineChart,CartesianGrid,XAxis,YAxis,Tooltip,Line";
+export {LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line};
+
+function loadCSS(href) {
+ var head = document.getElementsByTagName('head')[0];
+
+ if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
+ // Creating link element
+ var style = document.createElement('link');
+ style.id = href;
+ style.href = href;
+ style.type = 'text/css';
+ style.rel = 'stylesheet';
+ head.append(style);
+ }
+}
+
+
+export function bind(node, config) {
+ const root = ReactDOM.createRoot(node);
+ return {
+ create: (component, props, children) =>
+ React.createElement(component, wrapEventHandlers(props), ...children),
+ render: (element) => root.render(element),
+ unmount: () => root.unmount()
+ };
+}
+
+
+function wrapEventHandlers(props) {
+ const newProps = Object.assign({}, props);
+ for (const [key, value] of Object.entries(props)) {
+ if (typeof value === "function") {
+ newProps[key] = makeJsonSafeEventHandler(value);
+ }
+ }
+ return newProps;
+}
+
+/**
+ * Converts an HTML element and its children into a structured JavaScript object.
+ * @param {HTMLElement} element The HTML element to convert.
+ * @return {object} The structured object.
+ */
+function htmlToJsonObject(element) {
+ if (!element) return null;
+
+ const obj = {
+ tagName: element.tagName.toLowerCase(),
+ attributes: {}
+ };
+
+ // Get attributes
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attr = element.attributes[i];
+ obj.attributes[attr.name] = attr.value;
+ }
+
+ return obj;
+}
+
+function jsonSanitizeObject(obj, maxDepth, refs, depth) {
+ if (!maxDepth) {
+ maxDepth = 4;
+ }
+ if (!depth) {
+ depth = 0;
+ }
+ if (!refs) {
+ refs = [];
+ }
+ if (typeof obj === 'string' || typeof obj === 'number' || obj == null || typeof obj === 'boolean') {
+ return obj;
+ }
+ if (typeof obj === 'function') {
+ return undefined;
+ }
+ if (obj.constructor === Window) {
+ return undefined;
+ }
+ if (refs.includes(obj)) {
+ return undefined;
+ }
+ refs.push(obj);
+ delete obj.nativeEvent;
+ let newObj = Array.isArray(obj) ? [] : {};
+ if (depth > maxDepth) {
+ newObj = "BEYOND MAX DEPTH";
+ } else {
+ for (const [key, value] of Object.entries(obj)) {
+ if (refs.includes(value)) continue;
+ newObj[key] = jsonSanitizeObject(value, maxDepth, refs, depth+1);
+ }
+ if (obj.__proto__) {
+ Object.getOwnPropertyNames(obj.__proto__).forEach(function (propName) {
+ newObj[propName] = jsonSanitizeObject(obj[propName], maxDepth, refs, depth+1);
+ });
+ }
+ if (obj instanceof Element) {
+ newObj = {...newObj, ...htmlToJsonObject(obj)}
+ }
+ }
+ return newObj;
+}
+
+function makeJsonSafeEventHandler(oldHandler) {
+ // Since we can't really know what the event handlers get passed we have to check if
+ // they are JSON serializable or not. We can allow normal synthetic events to pass
+ // through since the original handler already knows how to serialize those for us.
+ return function safeEventHandler() {
+ oldHandler(...Array.from(arguments).map((x) => jsonSanitizeObject(x, 4)));
+ };
+}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2_expected.json
new file mode 100644
index 000000000..5bd139438
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_2_expected.json
@@ -0,0 +1 @@
+{"tagName": "div", "attributes": {"style": {"width": "100vw", "height": "calc(100vh - 57px)"}}, "children": [{"tagName": "Map", "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "View", "attributes": {"options": {"projection": "EPSG:3857"}, "center": [-100, 40], "zoom": 1}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Basemap", "fold": "close"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "None"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": true, "type": "base", "title": "OpenStreetMap"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OSMSource", "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "XYZ"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "NatGeo World Map"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "USA Topo Maps"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/USA_Topo_Maps/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Imagery"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Physical Map"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Physical_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Shaded Relief"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Street Map"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Terrain Base"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Terrain_Base/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Topo Map"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_all)"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_nolabels)"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_all)"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_nolabels)"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Overlays", "fold": "open"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "ImageLayer", "attributes": {"title": "GEOGLOWS Streamflow Service"}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "ImageArcGISRestSource", "attributes": {"options": {"url": "https://livefeeds3.arcgis.com/arcgis/rest/services/GEOGLOWS/GlobalWaterModel_Medium/MapServer"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "VectorLayer", "children": [{"tagName": "VectorSource", "attributes": {"options": {"url": "https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_110m_ocean.geojson", "format": "GeoJSON"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "ScaleLineControl", "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "LayerPanel", "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "div", "attributes": {"role": "dialog", "aria-modal": "true", "className": "offcanvas offcanvas-end show", "tabIndex": "-1", "style": {"visibility": "visible", "width": "50vw"}}, "children": [{"tagName": "div", "attributes": {"className": "offcanvas-header"}, "children": [{"tagName": "div", "attributes": {"className": "offcanvas-title h5"}, "children": ["Forecast"]}, {"tagName": "button", "attributes": {"type": "button", "className": "btn-close", "aria-label": "true"}, "eventHandlers": {"onClick": null}}]}, {"tagName": "div", "attributes": {"className": "offcanvas-body"}, "children": [{"tagName": "Container", "children": [{"tagName": "Row", "children": [{"tagName": "Col", "children": [{"tagName": "h2", "children": ["Streamflow @ Test 123"]}, {"tagName": "LineChart", "attributes": {"width": 700, "height": 500, "data": [{"x": 1, "y": 100}, {"x": 2, "y": 200}]}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "CartesianGrid", "attributes": {"strokeDasharray": "3 3"}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "XAxis", "attributes": {"label": "Date"}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "YAxis", "attributes": {"label": {"value": "Streamflow", "angle": -90, "position": "insideLeft"}}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "Tooltip", "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "Line", "attributes": {"type": "monotone", "dataKey": "y"}, "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}], "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "_test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3.py b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3.py
similarity index 100%
rename from tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3.py
rename to tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3.py
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3_expected.js b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3_expected.js
new file mode 100644
index 000000000..cb7fb77a9
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3_expected.js
@@ -0,0 +1,114 @@
+
+import ReactPlayer from "https://esm.sh/react-player/?deps=react@19.0,react-dom@19.0,react-is@19.0";
+export {ReactPlayer};
+
+function loadCSS(href) {
+ var head = document.getElementsByTagName('head')[0];
+
+ if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
+ // Creating link element
+ var style = document.createElement('link');
+ style.id = href;
+ style.href = href;
+ style.type = 'text/css';
+ style.rel = 'stylesheet';
+ head.append(style);
+ }
+}
+
+
+export function bind(node, config) {
+ const root = ReactDOM.createRoot(node);
+ return {
+ create: (component, props, children) =>
+ React.createElement(component, wrapEventHandlers(props), ...children),
+ render: (element) => root.render(element),
+ unmount: () => root.unmount()
+ };
+}
+
+
+function wrapEventHandlers(props) {
+ const newProps = Object.assign({}, props);
+ for (const [key, value] of Object.entries(props)) {
+ if (typeof value === "function") {
+ newProps[key] = makeJsonSafeEventHandler(value);
+ }
+ }
+ return newProps;
+}
+
+/**
+ * Converts an HTML element and its children into a structured JavaScript object.
+ * @param {HTMLElement} element The HTML element to convert.
+ * @return {object} The structured object.
+ */
+function htmlToJsonObject(element) {
+ if (!element) return null;
+
+ const obj = {
+ tagName: element.tagName.toLowerCase(),
+ attributes: {}
+ };
+
+ // Get attributes
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attr = element.attributes[i];
+ obj.attributes[attr.name] = attr.value;
+ }
+
+ return obj;
+}
+
+function jsonSanitizeObject(obj, maxDepth, refs, depth) {
+ if (!maxDepth) {
+ maxDepth = 4;
+ }
+ if (!depth) {
+ depth = 0;
+ }
+ if (!refs) {
+ refs = [];
+ }
+ if (typeof obj === 'string' || typeof obj === 'number' || obj == null || typeof obj === 'boolean') {
+ return obj;
+ }
+ if (typeof obj === 'function') {
+ return undefined;
+ }
+ if (obj.constructor === Window) {
+ return undefined;
+ }
+ if (refs.includes(obj)) {
+ return undefined;
+ }
+ refs.push(obj);
+ delete obj.nativeEvent;
+ let newObj = Array.isArray(obj) ? [] : {};
+ if (depth > maxDepth) {
+ newObj = "BEYOND MAX DEPTH";
+ } else {
+ for (const [key, value] of Object.entries(obj)) {
+ if (refs.includes(value)) continue;
+ newObj[key] = jsonSanitizeObject(value, maxDepth, refs, depth+1);
+ }
+ if (obj.__proto__) {
+ Object.getOwnPropertyNames(obj.__proto__).forEach(function (propName) {
+ newObj[propName] = jsonSanitizeObject(obj[propName], maxDepth, refs, depth+1);
+ });
+ }
+ if (obj instanceof Element) {
+ newObj = {...newObj, ...htmlToJsonObject(obj)}
+ }
+ }
+ return newObj;
+}
+
+function makeJsonSafeEventHandler(oldHandler) {
+ // Since we can't really know what the event handlers get passed we have to check if
+ // they are JSON serializable or not. We can allow normal synthetic events to pass
+ // through since the original handler already knows how to serialize those for us.
+ return function safeEventHandler() {
+ oldHandler(...Array.from(arguments).map((x) => jsonSanitizeObject(x, 4)));
+ };
+}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3_expected.json
similarity index 66%
rename from tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3_expected.json
rename to tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3_expected.json
index 157681de0..8a04d49de 100644
--- a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3_expected.json
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_3_expected.json
@@ -1 +1 @@
-{"tagName": "div", "children": [{"tagName": "ReactPlayer", "attributes": {"url": "https://www.youtube.com/watch?v=xvFZjo5PgG0"}, "eventHandlers": {"onReady": null}, "importSource": {"source": "test_page_3.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}
\ No newline at end of file
+{"tagName": "div", "children": [{"tagName": "ReactPlayer", "attributes": {"url": "https://www.youtube.com/watch?v=xvFZjo5PgG0"}, "eventHandlers": {"onReady": null}, "importSource": {"source": "_test_page_3.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4.py b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4.py
new file mode 100644
index 000000000..c7b622397
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4.py
@@ -0,0 +1,93 @@
+def page_test(lib):
+ position, set_position = lib.hooks.use_state(None)
+ static_position = [48.208889, 16.3725]
+
+ return lib.tethys.Display(
+ lib.html.a(
+ id_="vienna", # id_ is essential, as it's referenced in the associated "element" attribute below
+ className="overlay",
+ target="_blank",
+ href="https://en.wikipedia.org/wiki/Vienna",
+ style=lib.Style(
+ text_decoration=None,
+ color="white",
+ font_size="11pt",
+ font_weight="bold",
+ text_shadow="black 0.1em 0.1em 0.2em",
+ ),
+ )("Vienna"),
+ lib.html.div(
+ id_="marker", # id_ is essential, as it's referenced in the associated "element" attribute below
+ style=lib.Style(
+ width="20px",
+ height="20px",
+ border="1px solid #088",
+ border_radius="10px",
+ background_color="#0FF",
+ opacity="0.5",
+ ),
+ ),
+ (
+ lib.html.div(
+ id_="dynamic", # id_ is essential, as it's referenced in the associated "element" attribute below
+ style=lib.Style(
+ position="relative",
+ ),
+ )(
+ lib.html.div(
+ style=lib.Style(
+ position="absolute",
+ left="-6px",
+ width=0,
+ height=0,
+ border_left="6px solid transparent", # Controls the width of the triangle
+ border_right="6px solid transparent", # Controls the width of the triangle
+ border_bottom="6px solid #ff0000",
+ )
+ ),
+ lib.html.div(
+ style=lib.Style(
+ position="absolute",
+ left="-6px",
+ top="6px",
+ width="200px",
+ background_color="lightblue",
+ padding="1em",
+ border="1px black solid",
+ )
+ )(f"You clicked at: {', '.join(map(str, position))}"),
+ )
+ if position
+ else lib.html.div()
+ ),
+ lib.tethys.Map(
+ key="map",
+ projection=lib.Props(
+ code="EPSG:21781",
+ extent=[485869.5728, 76443.1884, 837076.5648, 299941.7864],
+ ),
+ center=[660000, 190000],
+ onClick=lambda e: set_position(e.coordinate),
+ )(
+ lib.ol.Overlay(
+ options=lib.Props(stopEvent=False),
+ position=static_position,
+ element="vienna", # The id_ of the above component to be used for this Overlay
+ ),
+ lib.ol.Overlay(
+ options=lib.Props(stopEvent=False),
+ position=static_position,
+ positioning="center-center",
+ element="marker", # The id_ of the above component to be used for this Overlay
+ ),
+ (
+ lib.ol.Overlay(
+ position=position,
+ element="dynamic", # The id_ of the above component to be used for this Overlay
+ )
+ if position
+ else None
+ ),
+ None,
+ ),
+ )
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4_expected.js b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4_expected.js
new file mode 100644
index 000000000..2aed45f96
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4_expected.js
@@ -0,0 +1,130 @@
+
+import {Container} from "https://esm.sh/react-bootstrap/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=Container";
+export {Container};
+loadCSS("https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css");
+import Map from "https://esm.sh/@planet/maps@11.2.0/Map.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import XYZSource from "https://esm.sh/@planet/maps@11.2.0/source/XYZ.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import OSMSource from "https://esm.sh/@planet/maps@11.2.0/source/OSM.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import GroupLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Group.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import TileLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Tile.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+import ScaleLineControl from "https://esm.sh/@planet/maps@11.2.0/control/ScaleLine.js?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0";
+export {Map, XYZSource, OSMSource, GroupLayer, TileLayer, ScaleLineControl};
+loadCSS("https://esm.sh/ol@10.7.0/ol.css");
+import {LayerPanel} from "/static/tethys_apps/js/layer-panel.js/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=LayerPanel";
+export {LayerPanel};
+loadCSS("https://esm.sh/ol-layerswitcher@4.1.2/dist/ol-layerswitcher.css");
+loadCSS("https://esm.sh/ol-side-panel@1.0.6/src/SidePanel.css");
+import View from "/static/tethys_apps/js/ol-mods/View.js?deps=react@19.0,react-dom@19.0,react-is@19.0";
+import Overlay from "/static/tethys_apps/js/ol-mods/Overlay.js?deps=react@19.0,react-dom@19.0,react-is@19.0";
+export {View, Overlay};
+
+function loadCSS(href) {
+ var head = document.getElementsByTagName('head')[0];
+
+ if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
+ // Creating link element
+ var style = document.createElement('link');
+ style.id = href;
+ style.href = href;
+ style.type = 'text/css';
+ style.rel = 'stylesheet';
+ head.append(style);
+ }
+}
+
+
+export function bind(node, config) {
+ const root = ReactDOM.createRoot(node);
+ return {
+ create: (component, props, children) =>
+ React.createElement(component, wrapEventHandlers(props), ...children),
+ render: (element) => root.render(element),
+ unmount: () => root.unmount()
+ };
+}
+
+
+function wrapEventHandlers(props) {
+ const newProps = Object.assign({}, props);
+ for (const [key, value] of Object.entries(props)) {
+ if (typeof value === "function") {
+ newProps[key] = makeJsonSafeEventHandler(value);
+ }
+ }
+ return newProps;
+}
+
+/**
+ * Converts an HTML element and its children into a structured JavaScript object.
+ * @param {HTMLElement} element The HTML element to convert.
+ * @return {object} The structured object.
+ */
+function htmlToJsonObject(element) {
+ if (!element) return null;
+
+ const obj = {
+ tagName: element.tagName.toLowerCase(),
+ attributes: {}
+ };
+
+ // Get attributes
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attr = element.attributes[i];
+ obj.attributes[attr.name] = attr.value;
+ }
+
+ return obj;
+}
+
+function jsonSanitizeObject(obj, maxDepth, refs, depth) {
+ if (!maxDepth) {
+ maxDepth = 4;
+ }
+ if (!depth) {
+ depth = 0;
+ }
+ if (!refs) {
+ refs = [];
+ }
+ if (typeof obj === 'string' || typeof obj === 'number' || obj == null || typeof obj === 'boolean') {
+ return obj;
+ }
+ if (typeof obj === 'function') {
+ return undefined;
+ }
+ if (obj.constructor === Window) {
+ return undefined;
+ }
+ if (refs.includes(obj)) {
+ return undefined;
+ }
+ refs.push(obj);
+ delete obj.nativeEvent;
+ let newObj = Array.isArray(obj) ? [] : {};
+ if (depth > maxDepth) {
+ newObj = "BEYOND MAX DEPTH";
+ } else {
+ for (const [key, value] of Object.entries(obj)) {
+ if (refs.includes(value)) continue;
+ newObj[key] = jsonSanitizeObject(value, maxDepth, refs, depth+1);
+ }
+ if (obj.__proto__) {
+ Object.getOwnPropertyNames(obj.__proto__).forEach(function (propName) {
+ newObj[propName] = jsonSanitizeObject(obj[propName], maxDepth, refs, depth+1);
+ });
+ }
+ if (obj instanceof Element) {
+ newObj = {...newObj, ...htmlToJsonObject(obj)}
+ }
+ }
+ return newObj;
+}
+
+function makeJsonSafeEventHandler(oldHandler) {
+ // Since we can't really know what the event handlers get passed we have to check if
+ // they are JSON serializable or not. We can allow normal synthetic events to pass
+ // through since the original handler already knows how to serialize those for us.
+ return function safeEventHandler() {
+ oldHandler(...Array.from(arguments).map((x) => jsonSanitizeObject(x, 4)));
+ };
+}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4_expected.json
new file mode 100644
index 000000000..bacac2691
--- /dev/null
+++ b/tests/unit_tests/test_tethys_components/test_resources/test_library/_test_page_4_expected.json
@@ -0,0 +1 @@
+{"tagName": "Container", "attributes": {"fluid": true, "style": {"height": "100%"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "a", "attributes": {"id": "vienna", "className": "overlay", "target": "_blank", "href": "https://en.wikipedia.org/wiki/Vienna", "style": {"text-decoration": null, "color": "white", "font-size": "11pt", "font-weight": "bold", "text-shadow": "black 0.1em 0.1em 0.2em"}}, "children": ["Vienna"]}, {"tagName": "div", "attributes": {"id": "marker", "style": {"width": "20px", "height": "20px", "border": "1px solid #088", "border-radius": "10px", "background-color": "#0FF", "opacity": "0.5"}}}, {"tagName": "div", "attributes": {"id": "dynamic", "style": {"position": "relative"}}, "children": [{"tagName": "div", "attributes": {"style": {"position": "absolute", "left": "-6px", "width": 0, "height": 0, "border-left": "6px solid transparent", "border-right": "6px solid transparent", "border-bottom": "6px solid #ff0000"}}}, {"tagName": "div", "attributes": {"style": {"position": "absolute", "left": "-6px", "top": "6px", "width": "200px", "background-color": "lightblue", "padding": "1em", "border": "1px black solid"}}, "children": ["You clicked at: "]}]}, {"tagName": "Map", "key": "map", "eventHandlers": {"onClick": null}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "View", "attributes": {"options": {"projection": {"code": "EPSG:21781", "extent": [485869.5728, 76443.1884, 837076.5648, 299941.7864]}, "extent": [485869.5728, 76443.1884, 837076.5648, 299941.7864]}, "center": [660000, 190000], "zoom": 1}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Basemap", "fold": "close"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "None"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": true, "type": "base", "title": "OpenStreetMap"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OSMSource", "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "XYZ"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "NatGeo World Map"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "USA Topo Maps"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/USA_Topo_Maps/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Imagery"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Physical Map"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Physical_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Shaded Relief"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Street Map"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Terrain Base"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Terrain_Base/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Topo Map"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_all)"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_nolabels)"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_all)"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_nolabels)"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "Overlay", "attributes": {"options": {"stopEvent": false}, "position": [48.208889, 16.3725], "element": "vienna"}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "Overlay", "attributes": {"options": {"stopEvent": false}, "position": [48.208889, 16.3725], "positioning": "center-center", "element": "marker"}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "Overlay", "attributes": {"element": "dynamic"}, "eventHandlers": {"position": null}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Overlays", "fold": "open"}}, "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": []}, {"tagName": "ScaleLineControl", "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "LayerPanel", "importSource": {"source": "_test_page_4.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1_expected.js b/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1_expected.js
deleted file mode 100644
index eb6bdaf4e..000000000
--- a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1_expected.js
+++ /dev/null
@@ -1,94 +0,0 @@
-
-import {Container, Row, Button, Col} from "https://esm.sh/react-bootstrap/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=Container,Row,Button,Col";
-export {Container, Row, Button, Col};
-loadCSS("https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css");
-import Editor from "https://esm.sh/@monaco-editor/react/?deps=react@19.0,react-dom@19.0,react-is@19.0";
-export {Editor};
-
-function loadCSS(href) {
- var head = document.getElementsByTagName('head')[0];
-
- if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
- // Creating link element
- var style = document.createElement('link');
- style.id = href;
- style.href = href;
- style.type = 'text/css';
- style.rel = 'stylesheet';
- head.append(style);
- }
-}
-
-
-export function bind(node, config) {
- const root = ReactDOM.createRoot(node);
- return {
- create: (component, props, children) =>
- React.createElement(component, wrapEventHandlers(props), ...children),
- render: (element) => root.render(element),
- unmount: () => root.unmount()
- };
-}
-
-
-function wrapEventHandlers(props) {
- const newProps = Object.assign({}, props);
- for (const [key, value] of Object.entries(props)) {
- if (typeof value === "function") {
- newProps[key] = makeJsonSafeEventHandler(value);
- }
- }
- return newProps;
-}
-
-function stringifyToDepth(val, depth, replacer, space) {
- depth = isNaN(+depth) ? 1 : depth;
- function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
- return !val || typeof val != 'object' ? val : (a=Array.isArray(val), JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(k, v, a?depth:depth-1); } }), o||(a?[]:{}));
- }
- return JSON.stringify(_build('', val, depth), null, space);
-}
-
-function stringifyReplacer (key, value) {
- if (key === '') return value;
- try {
- JSON.stringify(value);
- return value;
- } catch (err) {
- return (typeof value === 'object') ? value : undefined;
- }
-}
-
-function makeJsonSafeEventHandler(oldHandler) {
- // Since we can't really know what the event handlers get passed we have to check if
- // they are JSON serializable or not. We can allow normal synthetic events to pass
- // through since the original handler already knows how to serialize those for us.
- return function safeEventHandler() {
-
- var filteredArguments = [];
- Array.from(arguments).forEach(function (arg) {
- let filteredArg = arg;
- if (typeof arg === "object") {
- if (arg.nativeEvent) {
- // this is probably a standard React synthetic event
- filteredArg = arg;
- } else {
- filteredArg = JSON.parse(stringifyToDepth(arg, 3, stringifyReplacer));
- }
-
- if (arg.__proto__) {
- Object.getOwnPropertyNames(arg.__proto__).forEach(function (propName) {
- if (propName == 'constructor') return;
- if (!arg.hasOwnProperty(propName) && arg[propName]) {
- filteredArg[propName] = arg[propName];
- delete filteredArg[propName + '_'];
- }
- });
- }
- }
- // Add non-enumerable properties
- filteredArguments.push(filteredArg);
- });
- oldHandler(...Array.from(filteredArguments));
- };
-}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1_expected.json
deleted file mode 100644
index 003881d81..000000000
--- a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_1_expected.json
+++ /dev/null
@@ -1 +0,0 @@
-{"tagName": "div", "children": [{"tagName": "Container", "attributes": {"fluid": true, "style": {"height": "calc(100vh - 75px"}}, "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Row", "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Render"]}]}, {"tagName": "Row", "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Col", "attributes": {"style": {"width": "50vw"}}, "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Editor", "attributes": {"height": "80vh", "language": "python", "theme": "vs-dark", "value": "", "options": {"inlineSuggest": true, "fontSize": "16px", "formatOnType": true}}, "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "Col", "attributes": {"style": {"width": "50vw"}}, "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "iframe", "attributes": {"style": {"height": "100%", "width": "100%"}, "src": "/apps/component-app-playground/render", "title": "Result"}}]}]}, {"tagName": "Row", "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "Button", "importSource": {"source": "test_page_1.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": ["Render"]}]}]}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2_expected.js b/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2_expected.js
deleted file mode 100644
index 66d037240..000000000
--- a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2_expected.js
+++ /dev/null
@@ -1,109 +0,0 @@
-
-import {Col, Row, Container} from "https://esm.sh/react-bootstrap/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=Col,Row,Container";
-export {Col, Row, Container};
-loadCSS("https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css");
-import ImageLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Image?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import ImageArcGISRestSource from "https://esm.sh/@planet/maps@11.2.0/source/ImageArcGISRest?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import Map from "https://esm.sh/@planet/maps@11.2.0/Map?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import View from "https://esm.sh/@planet/maps@11.2.0/View?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import XYZSource from "https://esm.sh/@planet/maps@11.2.0/source/XYZ?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import OSMSource from "https://esm.sh/@planet/maps@11.2.0/source/OSM?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import GroupLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Group?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import TileLayer from "https://esm.sh/@planet/maps@11.2.0/layer/Tile?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-import ScaleLineControl from "https://esm.sh/@planet/maps@11.2.0/control/ScaleLine?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0";
-export {ImageLayer, ImageArcGISRestSource, Map, View, XYZSource, OSMSource, GroupLayer, TileLayer, ScaleLineControl};
-loadCSS("https://esm.sh/ol@10.4.0/ol.css");
-import {LayerPanel} from "/static/tethys_apps/js/layer-panel.js/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=LayerPanel";
-export {LayerPanel};
-loadCSS("https://esm.sh/ol-layerswitcher@4.1.2/dist/ol-layerswitcher.css");
-loadCSS("https://esm.sh/ol-side-panel@1.0.6/src/SidePanel.css");
-import {LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line} from "https://esm.sh/recharts/?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=LineChart,CartesianGrid,XAxis,YAxis,Tooltip,Line";
-export {LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line};
-
-function loadCSS(href) {
- var head = document.getElementsByTagName('head')[0];
-
- if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
- // Creating link element
- var style = document.createElement('link');
- style.id = href;
- style.href = href;
- style.type = 'text/css';
- style.rel = 'stylesheet';
- head.append(style);
- }
-}
-
-
-export function bind(node, config) {
- const root = ReactDOM.createRoot(node);
- return {
- create: (component, props, children) =>
- React.createElement(component, wrapEventHandlers(props), ...children),
- render: (element) => root.render(element),
- unmount: () => root.unmount()
- };
-}
-
-
-function wrapEventHandlers(props) {
- const newProps = Object.assign({}, props);
- for (const [key, value] of Object.entries(props)) {
- if (typeof value === "function") {
- newProps[key] = makeJsonSafeEventHandler(value);
- }
- }
- return newProps;
-}
-
-function stringifyToDepth(val, depth, replacer, space) {
- depth = isNaN(+depth) ? 1 : depth;
- function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
- return !val || typeof val != 'object' ? val : (a=Array.isArray(val), JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(k, v, a?depth:depth-1); } }), o||(a?[]:{}));
- }
- return JSON.stringify(_build('', val, depth), null, space);
-}
-
-function stringifyReplacer (key, value) {
- if (key === '') return value;
- try {
- JSON.stringify(value);
- return value;
- } catch (err) {
- return (typeof value === 'object') ? value : undefined;
- }
-}
-
-function makeJsonSafeEventHandler(oldHandler) {
- // Since we can't really know what the event handlers get passed we have to check if
- // they are JSON serializable or not. We can allow normal synthetic events to pass
- // through since the original handler already knows how to serialize those for us.
- return function safeEventHandler() {
-
- var filteredArguments = [];
- Array.from(arguments).forEach(function (arg) {
- let filteredArg = arg;
- if (typeof arg === "object") {
- if (arg.nativeEvent) {
- // this is probably a standard React synthetic event
- filteredArg = arg;
- } else {
- filteredArg = JSON.parse(stringifyToDepth(arg, 3, stringifyReplacer));
- }
-
- if (arg.__proto__) {
- Object.getOwnPropertyNames(arg.__proto__).forEach(function (propName) {
- if (propName == 'constructor') return;
- if (!arg.hasOwnProperty(propName) && arg[propName]) {
- filteredArg[propName] = arg[propName];
- delete filteredArg[propName + '_'];
- }
- });
- }
- }
- // Add non-enumerable properties
- filteredArguments.push(filteredArg);
- });
- oldHandler(...Array.from(filteredArguments));
- };
-}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2_expected.json b/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2_expected.json
deleted file mode 100644
index 61f551b65..000000000
--- a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_2_expected.json
+++ /dev/null
@@ -1 +0,0 @@
-{"tagName": "div", "attributes": {"style": {"width": "100vw", "height": "calc(100vh - 57px)"}}, "children": [{"tagName": "Map", "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "View", "attributes": {"options": {"projection": "EPSG:3857", "center": [-100, 40], "zoom": 3.5}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Basemap", "fold": "close"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "None"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": true, "type": "base", "title": "OpenStreetMap"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "OSMSource", "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "XYZ"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "NatGeo World Map"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "USA Topo Maps"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/USA_Topo_Maps/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Imagery"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Physical Map"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Physical_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Shaded Relief"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Street Map"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Terrain Base"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Terrain_Base/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "World Topo Map"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_all)"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (light_nolabels)"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_all)"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "TileLayer", "attributes": {"options": {"visible": false, "type": "base", "title": "CartoDB (dark_nolabels)"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "XYZSource", "attributes": {"options": {"url": "http://{1-4}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "GroupLayer", "attributes": {"options": {"title": "Overlays", "fold": "open"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "ImageLayer", "attributes": {"options": {"title": "GEOGLOWS Streamflow Service"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "ImageArcGISRestSource", "attributes": {"options": {"url": "https://livefeeds3.arcgis.com/arcgis/rest/services/GEOGLOWS/GlobalWaterModel_Medium/MapServer"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}, {"tagName": "ScaleLineControl", "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "LayerPanel", "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}, {"tagName": "div", "attributes": {"role": "dialog", "aria-modal": "true", "className": "offcanvas offcanvas-end show", "tabIndex": "-1", "style": {"visibility": "visible", "width": "50vw"}}, "children": [{"tagName": "div", "attributes": {"className": "offcanvas-header"}, "children": [{"tagName": "div", "attributes": {"className": "offcanvas-title h5"}, "children": ["Forecast"]}, {"tagName": "button", "attributes": {"type": "button", "className": "btn-close", "aria-label": "true"}, "eventHandlers": {"onClick": null}}]}, {"tagName": "div", "attributes": {"className": "offcanvas-body"}, "children": [{"tagName": "Container", "children": [{"tagName": "Row", "children": [{"tagName": "Col", "children": [{"tagName": "h2", "children": ["Streamflow @ Test 123"]}, {"tagName": "LineChart", "attributes": {"width": 700, "height": 500, "data": [{"x": 1, "y": 100}, {"x": 2, "y": 200}]}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}, "children": [{"tagName": "CartesianGrid", "attributes": {"strokeDasharray": "3 3"}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "XAxis", "attributes": {"label": "Date"}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "YAxis", "attributes": {"label": {"value": "Streamflow", "angle": -90, "position": "insideLeft"}}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "Tooltip", "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}, {"tagName": "Line", "attributes": {"type": "monotone", "dataKey": "y"}, "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}], "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}], "importSource": {"source": "test_page_2.js", "sourceType": "NAME", "fallback": "\u231b", "unmountBeforeUpdate": false}}]}]}]}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3_expected.js b/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3_expected.js
deleted file mode 100644
index 8f3953d6e..000000000
--- a/tests/unit_tests/test_tethys_components/test_resources/test_library/test_page_3_expected.js
+++ /dev/null
@@ -1,91 +0,0 @@
-
-import ReactPlayer from "https://esm.sh/react-player/?deps=react@19.0,react-dom@19.0,react-is@19.0";
-export {ReactPlayer};
-
-function loadCSS(href) {
- var head = document.getElementsByTagName('head')[0];
-
- if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
- // Creating link element
- var style = document.createElement('link');
- style.id = href;
- style.href = href;
- style.type = 'text/css';
- style.rel = 'stylesheet';
- head.append(style);
- }
-}
-
-
-export function bind(node, config) {
- const root = ReactDOM.createRoot(node);
- return {
- create: (component, props, children) =>
- React.createElement(component, wrapEventHandlers(props), ...children),
- render: (element) => root.render(element),
- unmount: () => root.unmount()
- };
-}
-
-
-function wrapEventHandlers(props) {
- const newProps = Object.assign({}, props);
- for (const [key, value] of Object.entries(props)) {
- if (typeof value === "function") {
- newProps[key] = makeJsonSafeEventHandler(value);
- }
- }
- return newProps;
-}
-
-function stringifyToDepth(val, depth, replacer, space) {
- depth = isNaN(+depth) ? 1 : depth;
- function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
- return !val || typeof val != 'object' ? val : (a=Array.isArray(val), JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(k, v, a?depth:depth-1); } }), o||(a?[]:{}));
- }
- return JSON.stringify(_build('', val, depth), null, space);
-}
-
-function stringifyReplacer (key, value) {
- if (key === '') return value;
- try {
- JSON.stringify(value);
- return value;
- } catch (err) {
- return (typeof value === 'object') ? value : undefined;
- }
-}
-
-function makeJsonSafeEventHandler(oldHandler) {
- // Since we can't really know what the event handlers get passed we have to check if
- // they are JSON serializable or not. We can allow normal synthetic events to pass
- // through since the original handler already knows how to serialize those for us.
- return function safeEventHandler() {
-
- var filteredArguments = [];
- Array.from(arguments).forEach(function (arg) {
- let filteredArg = arg;
- if (typeof arg === "object") {
- if (arg.nativeEvent) {
- // this is probably a standard React synthetic event
- filteredArg = arg;
- } else {
- filteredArg = JSON.parse(stringifyToDepth(arg, 3, stringifyReplacer));
- }
-
- if (arg.__proto__) {
- Object.getOwnPropertyNames(arg.__proto__).forEach(function (propName) {
- if (propName == 'constructor') return;
- if (!arg.hasOwnProperty(propName) && arg[propName]) {
- filteredArg[propName] = arg[propName];
- delete filteredArg[propName + '_'];
- }
- });
- }
- }
- // Add non-enumerable properties
- filteredArguments.push(filteredArg);
- });
- oldHandler(...Array.from(filteredArguments));
- };
-}
\ No newline at end of file
diff --git a/tests/unit_tests/test_tethys_components/test_utils.py b/tests/unit_tests/test_tethys_components/test_utils.py
index 095498c80..0a6931054 100644
--- a/tests/unit_tests/test_tethys_components/test_utils.py
+++ b/tests/unit_tests/test_tethys_components/test_utils.py
@@ -1,349 +1,326 @@
import pytest
-from unittest import TestCase, mock
+from unittest import mock
from tethys_components import utils
from pathlib import Path
+from urllib.parse import urlencode, urljoin
THIS_DIR = Path(__file__).parent
TEST_APP_DIR = (
THIS_DIR.parents[1] / "apps" / "tethysapp-test_app" / "tethysapp" / "test_app"
)
+MOCK_APP = mock.MagicMock()
+MOCK_USER = mock.MagicMock()
+
+
+@mock.patch("tethys_components.utils.inspect")
+@pytest.mark.django_db
+def test_infer_app_from_stack_trace_works(mock_inspect):
+ mock_stack_item_1 = mock.MagicMock()
+ mock_stack_item_1.__getitem__().f_code.co_filename = str(TEST_APP_DIR)
+ mock_inspect.stack.return_value = [mock_stack_item_1, mock_stack_item_1]
+ app = utils._infer_app_from_stack_trace()
+ assert app.package == "test_app"
+
+
+@mock.patch("tethys_components.utils.Path")
+def test_infer_app_from_stack_trace_fails_no_app_package(mock_path):
+ mock_path.side_effect = IndexError
+
+ with pytest.raises(ModuleNotFoundError) as cm:
+ utils._infer_app_from_stack_trace()
+ assert "No such module was found" in str(cm.exception)
-class TestComponentUtils(TestCase):
- @classmethod
- def setUpClass(cls):
- cls.user = mock.MagicMock()
- cls.app = mock.MagicMock()
-
- @mock.patch("tethys_components.utils.inspect")
- @pytest.mark.django_db
- def test_infer_app_from_stack_trace_works(self, mock_inspect):
- mock_stack_item_1 = mock.MagicMock()
- mock_stack_item_1.__getitem__().f_code.co_filename = str(TEST_APP_DIR)
- mock_inspect.stack.return_value = [mock_stack_item_1, mock_stack_item_1]
- app = utils._infer_app_from_stack_trace()
- self.assertEqual(app.package, "test_app")
-
- @mock.patch("tethys_components.utils.Path")
- def test_infer_app_from_stack_trace_fails_no_app_package(self, mock_path):
- mock_path.side_effect = IndexError
-
- with self.assertRaises(ModuleNotFoundError) as cm:
- utils._infer_app_from_stack_trace()
- self.assertIn("No such module was found", str(cm.exception))
-
- @mock.patch("tethys_components.utils.inspect")
- def test_infer_app_from_stack_trace_fails_no_app(self, mock_inspect):
- mock_stack_item_1 = mock.MagicMock()
- mock_stack_item_1.__getitem__().f_code.co_filename = str(TEST_APP_DIR).replace(
- "test", "fake"
- )
- mock_inspect.stack.return_value = [mock_stack_item_1, mock_stack_item_1]
- with self.assertRaises(EnvironmentError) as cm:
- utils._infer_app_from_stack_trace()
- self.assertIn("app was not found", str(cm.exception))
-
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_app_workspace_loading(self, mock_iafst):
- # SETUP ARGS/ENV
- mock_iafst.return_value = self.app
- mock_import = mock.patch("builtins.__import__").start()
- mock_import().use_query.return_value.loading = True
+
+@mock.patch("tethys_components.utils.inspect")
+def test_infer_app_from_stack_trace_fails_no_app(mock_inspect):
+ mock_stack_item_1 = mock.MagicMock()
+ mock_stack_item_1.__getitem__().f_code.co_filename = str(TEST_APP_DIR).replace(
+ "test", "fake"
+ )
+ mock_inspect.stack.return_value = [mock_stack_item_1, mock_stack_item_1]
+ with pytest.raises(EnvironmentError) as cm:
+ utils._infer_app_from_stack_trace()
+ assert "app was not found" in str(cm.exception)
+
+
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_app_workspace_loading(mock_iafst):
+ # SETUP ARGS/ENV
+ mock_iafst.return_value = MOCK_APP
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.use_query.return_value.loading = True
# EXECUTE FUNCTION
result = utils.use_workspace()
# EVALUATE RESULT
- self.assertIsInstance(result, utils._PathsQuery)
- self.assertTrue(result.checking_quota)
- mock_import().use_query.assert_called_once_with(
- utils._get_app_workspace, {"app_or_request": self.app}, postprocessor=None
+ assert isinstance(result, utils._PathsQuery)
+ assert result.checking_quota
+ mock_import.return_value.use_query.assert_called_once_with(
+ utils._get_app_workspace, {"app_or_request": MOCK_APP}, postprocessor=None
)
- # CLEANUP
- mock.patch.stopall()
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_user_workspace_error(self, mock_iafst):
- # SETUP ARGS/ENV
- mock_iafst.return_value = self.app
- mock_import = mock.patch("builtins.__import__").start()
- mock_import().use_query.return_value.loading = False
- mock_import().use_query.return_value.error = True
+
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_user_workspace_error(mock_iafst):
+ # SETUP ARGS/ENV
+ mock_iafst.return_value = MOCK_APP
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.use_query.return_value.loading = False
+ mock_import.return_value.use_query.return_value.error = True
# EXECUTE FUNCTION
- result = utils.use_workspace(self.user)
+ result = utils.use_workspace(MOCK_USER)
# EVALUATE RESULT
- self.assertIsInstance(result, utils._PathsQuery)
- self.assertFalse(result.checking_quota)
- self.assertTrue(result.quota_exceeded)
- mock_import().use_query.assert_called_once_with(
+ assert isinstance(result, utils._PathsQuery)
+ assert not result.checking_quota
+ assert result.quota_exceeded
+ mock_import.return_value.use_query.assert_called_once_with(
utils._get_user_workspace,
- {"app_or_request": self.app, "user_or_request": self.user},
+ {"app_or_request": MOCK_APP, "user_or_request": MOCK_USER},
postprocessor=None,
)
- # CLEANUP
- mock.patch.stopall()
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_user_workspace_ready(self, mock_iafst):
- # SETUP ARGS/ENV
- mock_iafst.return_value = self.app
- mock_import = mock.patch("builtins.__import__").start()
- mock_import().use_query.return_value.loading = False
- mock_import().use_query.return_value.error = False
+
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_user_workspace_ready(mock_iafst):
+ # SETUP ARGS/ENV
+ mock_iafst.return_value = MOCK_APP
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.use_query.return_value.loading = False
+ mock_import.return_value.use_query.return_value.error = False
# EXECUTE FUNCTION
- result = utils.use_workspace(self.user)
+ result = utils.use_workspace(MOCK_USER)
# EVALUATE RESULT
- self.assertEqual(result, mock_import().use_query.return_value.data)
- self.assertFalse(result.checking_quota)
- self.assertFalse(result.quota_exceeded)
+ assert result == mock_import.return_value.use_query.return_value.data
+ assert not result.checking_quota
+ assert not result.quota_exceeded
- # CLEANUP
- mock.patch.stopall()
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_app_media_loading(self, mock_iafst):
- # SETUP ARGS/ENV
- mock_iafst.return_value = self.app
- mock_import = mock.patch("builtins.__import__").start()
- mock_import().use_query.return_value.loading = True
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_app_media_loading(mock_iafst):
+ # SETUP ARGS/ENV
+ mock_iafst.return_value = MOCK_APP
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.use_query.return_value.loading = True
# EXECUTE FUNCTION
result = utils.use_media()
# EVALUATE RESULT
- self.assertIsInstance(result, utils._PathsQuery)
- self.assertTrue(result.checking_quota)
- mock_import().use_query.assert_called_once_with(
- utils._get_app_media, {"app_or_request": self.app}, postprocessor=None
+ assert isinstance(result, utils._PathsQuery)
+ assert result.checking_quota
+ mock_import.return_value.use_query.assert_called_once_with(
+ utils._get_app_media, {"app_or_request": MOCK_APP}, postprocessor=None
)
- # CLEANUP
- mock.patch.stopall()
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_user_media_error(self, mock_iafst):
- # SETUP ARGS/ENV
- mock_iafst.return_value = self.app
- mock_import = mock.patch("builtins.__import__").start()
- mock_import().use_query.return_value.loading = False
- mock_import().use_query.return_value.error = True
+
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_user_media_error(mock_iafst):
+ # SETUP ARGS/ENV
+ mock_iafst.return_value = MOCK_APP
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.use_query.return_value.loading = False
+ mock_import.return_value.use_query.return_value.error = True
# EXECUTE FUNCTION
- result = utils.use_media(self.user)
+ result = utils.use_media(MOCK_USER)
# EVALUATE RESULT
- self.assertIsInstance(result, utils._PathsQuery)
- self.assertFalse(result.checking_quota)
- self.assertTrue(result.quota_exceeded)
- mock_import().use_query.assert_called_once_with(
+ assert isinstance(result, utils._PathsQuery)
+ assert not result.checking_quota
+ assert result.quota_exceeded
+ mock_import.return_value.use_query.assert_called_once_with(
utils._get_user_media,
- {"app_or_request": self.app, "user_or_request": self.user},
+ {"app_or_request": MOCK_APP, "user_or_request": MOCK_USER},
postprocessor=None,
)
- # CLEANUP
- mock.patch.stopall()
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_user_media_ready(self, mock_iafst):
- # SETUP ARGS/ENV
- mock_iafst.return_value = self.app
- mock_import = mock.patch("builtins.__import__").start()
- mock_import().use_query.return_value.loading = False
- mock_import().use_query.return_value.error = False
+
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_user_media_ready(mock_iafst):
+ # SETUP ARGS/ENV
+ mock_iafst.return_value = MOCK_APP
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.use_query.return_value.loading = False
+ mock_import.return_value.use_query.return_value.error = False
# EXECUTE FUNCTION
- result = utils.use_media(self.user)
+ result = utils.use_media(MOCK_USER)
# EVALUATE RESULT
- self.assertEqual(result, mock_import().use_query.return_value.data)
- self.assertFalse(result.checking_quota)
- self.assertFalse(result.quota_exceeded)
+ assert result == mock_import.return_value.use_query.return_value.data
+ assert not result.checking_quota
+ assert not result.quota_exceeded
- # CLEANUP
- mock.patch.stopall()
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_resources(self, mock_iafst):
- mock_iafst.return_value = self.app
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_resources(mock_iafst):
+ mock_iafst.return_value = MOCK_APP
- result = utils.use_resources()
+ result = utils.use_resources()
- self.assertEqual(result, self.app.resources_path)
+ assert result == MOCK_APP.resources_path
- @mock.patch("tethys_components.utils._infer_app_from_stack_trace")
- def test_use_public(self, mock_iafst):
- mock_iafst.return_value = self.app
- result = utils.use_public()
+@mock.patch("tethys_components.utils._infer_app_from_stack_trace")
+def test_use_public(mock_iafst):
+ mock_iafst.return_value = MOCK_APP
- self.assertEqual(result, self.app.public_path)
+ result = utils.use_public()
- def test_background_execute_no_delay(self):
- mock_import = mock.patch("builtins.__import__").start()
+ assert result == MOCK_APP.public_path
- def test_func(arg1):
- pass
- utils.background_execute(test_func, ["Hello"])
- mock_import().Thread.assert_called_once_with(
- target=utils._background_execute_wrapper,
- func=test_func,
- args=["Hello"],
- callback=None,
- )
- mock_import().Thread().start.assert_called_once()
- mock.patch.stopall()
+def test_background_execute_invalid_args():
+ with pytest.raises(ValueError):
+ utils.background_execute(lambda: None, "fail")
- def test_background_execute_delay(self):
- mock_import = mock.patch("builtins.__import__").start()
- def test_func(arg1):
- pass
+def test_background_execute_no_delay():
+ def _test_func(arg1):
+ pass
- utils.background_execute(test_func, ["Hello"], delay_seconds=10)
- mock_import().Timer.assert_called_once_with(
- 10, utils._background_execute_wrapper, [test_func, ["Hello"], None]
- )
- mock_import().Timer().start.assert_called_once()
- mock.patch.stopall()
-
- def test_background_execute_repeat(self):
- mock_import = mock.patch("builtins.__import__").start()
-
- def test_func(arg1):
- pass
-
- utils.background_execute(test_func, ["Hello"], repeat_seconds=1)
- mock_import().Thread.assert_has_calls(
- [
- mock.call(
- target=utils._background_execute_wrapper,
- func=test_func,
- args=["Hello"],
- callback=None,
- ),
- mock.call().start(),
- mock.call(
- target=utils._background_execute_wrapper,
- func=test_func,
- args=["Hello"],
- callback=None,
- ),
- mock.call().start(),
- ]
+ with mock.patch("builtins.__import__") as mock_import:
+ utils.background_execute(_test_func, ("Hello",))
+ mock_import.return_value.Thread.assert_called_once_with(
+ target=utils._background_execute_wrapper,
+ args=(_test_func, ("Hello",), None),
)
- self.assertEqual(mock_import().Thread().start.call_count, 2)
- mock_import().Timer.assert_called_once()
- self.assertEqual(mock_import().Timer.call_args.args[0], 1)
- mock_import().Timer().start.assert_called_once()
- mock.patch.stopall()
-
- def test_props_all_cases_combined(self):
- expected = {"foo": "bar", "onClick": "test", "thisProp": "none"}
- value = utils.Props(foo_="bar", on_click="test", this_prop=None)
+ mock_import.return_value.Thread().start.assert_called_once()
- self.assertEqual(value, expected)
- def test_get_layout_component_layout_callable(self):
- def test_layout_func():
- pass
+def test_background_execute_delay():
+ def _test_func(arg1):
+ pass
- self.assertEqual(
- utils._get_layout_component(self.app, test_layout_func), test_layout_func
+ with mock.patch("builtins.__import__") as mock_import:
+ utils.background_execute(_test_func, ("Hello",), delay_seconds=10)
+ mock_import.return_value.Timer.assert_called_once_with(
+ interval=10,
+ function=utils._background_execute_wrapper,
+ args=(_test_func, ("Hello",), None),
)
+ mock_import.return_value.Timer().start.assert_called_once()
- def test_get_layout_component_default_layout_callable(self):
- def test_layout_func():
- pass
- self.app.default_layout = test_layout_func
- self.assertEqual(
- utils._get_layout_component(self.app, "default"), self.app.default_layout
- )
+@mock.patch("tethys_components.utils.RepeatManager")
+def test_background_execute_repeat(mock_repeat_manager):
+ def _test_func(arg1):
+ pass
- @mock.patch("tethys_components.utils.layouts")
- def test_get_layout_component_default_layout_not_callable(self, mock_layouts):
- self.app.default_layout = "TestLayout"
- self.assertEqual(
- utils._get_layout_component(self.app, "default"), mock_layouts.TestLayout
+ with mock.patch("builtins.__import__") as mock_import:
+ utils.background_execute(_test_func, ("Hello",), repeat_seconds=1)
+ mock_import.assert_not_called()
+ mock_repeat_manager.assert_called_once_with(
+ repeat_seconds=1,
+ target=utils._background_execute_wrapper,
+ args=(_test_func, ("Hello",), None),
)
- @mock.patch("tethys_components.utils.layouts")
- def test_get_layout_component_not_default_not_callable(self, mock_layouts):
- self.assertEqual(
- utils._get_layout_component(self.app, "TestLayout"), mock_layouts.TestLayout
- )
- def test_AttrDict_all_the_stops(self):
- test_dict = {
- "camelProp": [
- {
- "list": "of",
- "props": ["that", {"are": "all"}, "very"],
- "differEnt": [{"types": "and"}, {"nesting": "orders"}],
- },
- ],
- "clear": "is reserved",
- "update": 100,
- "oneMore": {"howbout": "this"},
- }
-
- d = utils.DotNotationDict(test_dict)
-
- self.assertTrue(hasattr(d, "camel_prop"))
- self.assertTrue(isinstance(d.camel_prop, list))
- self.assertEqual(len(d.camel_prop), 1)
- self.assertTrue(isinstance(d.camel_prop[0], utils.DotNotationDict))
- self.assertTrue(hasattr(d.camel_prop[0], "list"))
- self.assertEqual(d.camel_prop[0].list, "of")
- self.assertTrue(hasattr(d.camel_prop[0], "props"))
- self.assertTrue(isinstance(d.camel_prop[0].props, list))
- self.assertEqual(len(d.camel_prop[0].props), 3)
- self.assertEqual(d.camel_prop[0].props[0], "that")
- self.assertTrue(isinstance(d.camel_prop[0].props[1], utils.DotNotationDict))
- self.assertTrue(hasattr(d.camel_prop[0].props[1], "are"))
- self.assertEqual(d.camel_prop[0].props[1].are, "all")
- self.assertEqual(d.camel_prop[0].props[2], "very")
- self.assertTrue(hasattr(d.camel_prop[0], "differ_ent"))
- self.assertTrue(isinstance(d.camel_prop[0].differ_ent, list))
- self.assertEqual(len(d.camel_prop[0].differ_ent), 2)
- self.assertTrue(
- isinstance(d.camel_prop[0].differ_ent[0], utils.DotNotationDict)
- )
- self.assertTrue(
- isinstance(d.camel_prop[0].differ_ent[1], utils.DotNotationDict)
- )
- self.assertTrue(hasattr(d.camel_prop[0].differ_ent[0], "types"))
- self.assertEqual(d.camel_prop[0].differ_ent[0].types, "and")
- self.assertTrue(hasattr(d.camel_prop[0].differ_ent[1], "nesting"))
- self.assertEqual(d.camel_prop[0].differ_ent[1].nesting, "orders")
- self.assertTrue(hasattr(d, "clear_"))
- self.assertEqual(d.clear_, "is reserved")
- self.assertTrue(hasattr(d, "update_"))
- self.assertEqual(d.update_, 100)
- self.assertTrue(hasattr(d, "one_more"))
- self.assertTrue(isinstance(d.one_more, utils.DotNotationDict))
- self.assertTrue(hasattr(d.one_more, "howbout"))
- self.assertEqual(d.one_more.howbout, "this")
- with self.assertRaises(AttributeError):
- d.not_there
-
- def test_args_to_attrdicts_wrapper(self):
- @utils.args_to_dot_notation_dicts
- def test_func(arg1, arg2, arg3):
- self.assertTrue(isinstance(arg1, utils.DotNotationDict))
- self.assertTrue(isinstance(arg2, utils.DotNotationDict))
- self.assertTrue(isinstance(arg3, str))
-
- test_func(
- {"this": "is", "a": "test"}, {"how": "about", "another": "one"}, "done"
- )
-
- def test_fetch_json_as_attrdict(self):
- mock_import = mock.patch("builtins.__import__").start()
+def test_props_all_cases_combined():
+ expected = {"foo": "bar", "onClick": "test", "thisProp": "none"}
+ value = utils.Props(foo_="bar", on_click="test", this_prop=None)
+
+ assert value == expected
+
+
+def test_get_layout_component_layout_callable():
+ def _test_layout_func():
+ pass
+
+ assert utils._get_layout_component(MOCK_APP, _test_layout_func) == _test_layout_func
+
+
+def test_get_layout_component_default_layout_callable():
+ def _test_layout_func():
+ pass
+
+ MOCK_APP.default_layout = _test_layout_func
+ assert utils._get_layout_component(MOCK_APP, "default") == MOCK_APP.default_layout
+
+
+@mock.patch("tethys_components.utils.layouts")
+def test_get_layout_component_default_layout_not_callable(mock_layouts):
+ MOCK_APP.default_layout = "TestLayout"
+ assert utils._get_layout_component(MOCK_APP, "default") == mock_layouts.TestLayout
+
+
+@mock.patch("tethys_components.utils.layouts")
+def test_get_layout_component_not_default_not_callable(mock_layouts):
+ assert (
+ utils._get_layout_component(MOCK_APP, "TestLayout") == mock_layouts.TestLayout
+ )
+
+
+def test_AttrDict_all_the_stops():
+ test_dict = {
+ "camelProp": [
+ {
+ "list": "of",
+ "props": ["that", {"are": "all"}, "very"],
+ "differEnt": [{"types": "and"}, {"nesting": "orders"}],
+ },
+ ],
+ "clear": "is reserved",
+ "update": 100,
+ "oneMore": {"howbout": "this"},
+ }
+
+ d = utils.DotNotationDict(test_dict)
+
+ assert hasattr(d, "camel_prop")
+ assert isinstance(d.camel_prop, list)
+ assert len(d.camel_prop) == 1
+ assert isinstance(d.camel_prop[0], utils.DotNotationDict)
+ assert hasattr(d.camel_prop[0], "list")
+ assert d.camel_prop[0].list == "of"
+ assert hasattr(d.camel_prop[0], "props")
+ assert isinstance(d.camel_prop[0].props, list)
+ assert len(d.camel_prop[0].props) == 3
+ assert d.camel_prop[0].props[0] == "that"
+ assert isinstance(d.camel_prop[0].props[1], utils.DotNotationDict)
+ assert hasattr(d.camel_prop[0].props[1], "are")
+ assert d.camel_prop[0].props[1].are == "all"
+ assert d.camel_prop[0].props[2] == "very"
+ assert hasattr(d.camel_prop[0], "differ_ent")
+ assert isinstance(d.camel_prop[0].differ_ent, list)
+ assert len(d.camel_prop[0].differ_ent) == 2
+ assert isinstance(d.camel_prop[0].differ_ent[0], utils.DotNotationDict)
+ assert isinstance(d.camel_prop[0].differ_ent[1], utils.DotNotationDict)
+ assert hasattr(d.camel_prop[0].differ_ent[0], "types")
+ assert d.camel_prop[0].differ_ent[0].types == "and"
+ assert hasattr(d.camel_prop[0].differ_ent[1], "nesting")
+ assert d.camel_prop[0].differ_ent[1].nesting == "orders"
+ assert hasattr(d, "clear_")
+ assert d.clear_ == "is reserved"
+ assert hasattr(d, "update_")
+ assert d.update_ == 100
+ assert hasattr(d, "one_more")
+ assert isinstance(d.one_more, utils.DotNotationDict)
+ assert hasattr(d.one_more, "howbout")
+ assert d.one_more.howbout == "this"
+ with pytest.raises(AttributeError):
+ d.not_there
+
+
+def test_args_to_attrdicts_wrapper():
+ @utils.args_to_dot_notation_dicts
+ def _test_func(arg1, arg2, arg3):
+ assert isinstance(arg1, utils.DotNotationDict)
+ assert isinstance(arg2, utils.DotNotationDict)
+ assert isinstance(arg3, str)
+
+ _test_func({"this": "is", "a": "test"}, {"how": "about", "another": "one"}, "done")
+
+
+def test_fetch_json_as_attrdict():
+ with mock.patch("builtins.__import__") as mock_import:
test_dict = {"this": "is", "a": "test"}
mock_import.return_value.get.return_value.json.return_value = test_dict
test_url = "test-url"
@@ -351,14 +328,13 @@ def test_fetch_json_as_attrdict(self):
data = utils.fetch_json(test_url)
mock_import.return_value.get.assert_called_once_with(test_url)
- self.assertTrue(isinstance(data, utils.DotNotationDict))
- self.assertEqual(data.this, "is")
- self.assertEqual(data.a, "test")
+ assert isinstance(data, utils.DotNotationDict)
+ assert data.this == "is"
+ assert data.a == "test"
- mock.patch.stopall()
- def test_fetch_json_not_attrdict(self):
- mock_import = mock.patch("builtins.__import__").start()
+def test_fetch_json_not_attrdict():
+ with mock.patch("builtins.__import__") as mock_import:
test_dict = {"this": "is", "a": "test"}
mock_import.return_value.get.return_value.json.return_value = test_dict
test_url = "test-url"
@@ -366,38 +342,332 @@ def test_fetch_json_not_attrdict(self):
data = utils.fetch_json(test_url, as_attr_dict=False)
mock_import.return_value.get.assert_called_once_with(test_url)
- self.assertDictEqual(data, test_dict)
+ assert data == test_dict
+
- mock.patch.stopall()
+def test_fetch():
+ with mock.patch("builtins.__import__") as mock_import:
+ test_content = "this is a test"
+ mock_import.return_value.get.return_value.text = test_content
+ test_url = "test-url"
+
+ data = utils.fetch(test_url)
- def test_transform_coordinate(self):
- mock_import = mock.patch("builtins.__import__").start()
- coordinate = [0, 0]
- src_proj = "EPSG:3857"
- target_proj = "EPSG:4326"
+ mock_import.return_value.get.assert_called_once_with(test_url)
+ assert data == test_content
+
+def test_transform_coordinate():
+ coordinate = [0, 0]
+ src_proj = "EPSG:3857"
+ target_proj = "EPSG:4326"
+
+ with mock.patch("builtins.__import__") as mock_import:
result = utils.transform_coordinate(coordinate, src_proj, target_proj)
- self.assertEqual(
- mock_import.return_value.Transformer.from_crs.return_value.transform.return_value,
- result,
- )
- mock_import.return_value.CRS.assert_has_calls(
- [mock.call(src_proj), mock.call(target_proj)]
- )
+ assert (
+ mock_import.return_value.Transformer.from_crs.return_value.transform.return_value
+ == result
+ )
+ mock_import.return_value.CRS.assert_has_calls(
+ [mock.call(src_proj), mock.call(target_proj)]
+ )
+
- mock.patch.stopall()
+def test_transform_coordinate_custom_projections():
+ coordinate = [0, 0]
+ src_proj = {"definition": "test src proj"}
+ target_proj = {"definition": "test src proj"}
+
+ with mock.patch("builtins.__import__") as mock_import:
+ result = utils.transform_coordinate(coordinate, src_proj, target_proj)
+
+ assert (
+ mock_import.return_value.Transformer.from_crs.return_value.transform.return_value
+ == result
+ )
+ mock_import.return_value.CRS.assert_has_calls(
+ [mock.call(src_proj["definition"]), mock.call(target_proj["definition"])]
+ )
+
+
+def test_transform_coordinate_invalid_src_proj():
+ coordinate = [0, 0]
+ src_proj = 1234
+ target_proj = {"definition": "test src proj"}
+
+ with mock.patch("builtins.__import__"), pytest.raises(ValueError):
+ utils.transform_coordinate(coordinate, src_proj, target_proj)
- def test_get_db_object(self):
- app = mock.MagicMock(db_object="expected")
- val = utils._get_db_object(app)
- self.assertEqual(val, "expected")
- def test_background_execute_wrapper(self):
- test_func = mock.MagicMock()
- test_func.return_value = "Test"
- callback = mock.MagicMock()
+def test_transform_coordinate_invalid_target_proj():
+ coordinate = [0, 0]
+ src_proj = {"definition": "test src proj"}
+ target_proj = 1234
+
+ with mock.patch("builtins.__import__"), pytest.raises(ValueError):
+ utils.transform_coordinate(coordinate, src_proj, target_proj)
+
+
+def test_get_db_object():
+ app = mock.MagicMock(db_object="expected")
+ val = utils._get_db_object(app)
+ assert val == "expected"
+
+
+def test_background_execute_wrapper():
+ test_func = mock.MagicMock()
+ test_func.return_value = "Test"
+ callback = mock.MagicMock()
+
+ utils._background_execute_wrapper(test_func, ("Hello",), callback)
+ test_func.assert_called_once_with("Hello")
+ callback.assert_called_once_with("Test")
+
+
+def test_repeat_manager_start_and_cancel():
+ # Patch the Thread and Timer implementations on the class
+ rm = utils.RepeatManager(repeat_seconds=2, target=lambda: None, args=(1,))
+
+ with (
+ mock.patch.object(utils.RepeatManager, "Thread") as mock_thread_cls,
+ mock.patch.object(utils.RepeatManager, "Timer") as mock_timer_cls,
+ ):
+
+ # Make start/cancel available on instances
+ mock_thread = mock.MagicMock()
+ mock_timer = mock.MagicMock()
+ mock_thread_cls.return_value = mock_thread
+ mock_timer_cls.return_value = mock_timer
+
+ # Start the repeat manager
+ rm.start()
+
+ # Thread should be created and started for the first invocation
+ mock_thread_cls.assert_called()
+ mock_thread.start.assert_called_once()
+
+ # Timer should be created with the correct interval and function
+ mock_timer_cls.assert_called()
+ called_args, called_kwargs = mock_timer_cls.call_args
+ assert called_kwargs.get("interval") == 2
+ assert callable(called_kwargs.get("function"))
+
+ # is_alive should be True after start
+ assert rm.is_alive()
+
+ # Cancel should stop the repeating timer
+ rm.cancel()
+ mock_timer.cancel.assert_called_once()
+ assert not rm.is_alive()
+
+
+def test_repeat_manager_repeat_schedules_timer_again():
+ # Verify that _repeat_function schedules another timer when running
+ rm = utils.RepeatManager(repeat_seconds=3, target=lambda: None, args=(1,))
+
+ with (
+ mock.patch.object(utils.RepeatManager, "Thread") as mock_thread_cls,
+ mock.patch.object(utils.RepeatManager, "Timer") as mock_timer_cls,
+ ):
+
+ mock_thread = mock.MagicMock()
+ mock_timer = mock.MagicMock()
+ mock_thread_cls.return_value = mock_thread
+ mock_timer_cls.return_value = mock_timer
+
+ # Directly call internal repeat function to simulate running state
+ rm._running = False
+ rm._repeat_function()
+ mock_thread.start.assert_not_called()
+ rm._running = True
+ rm._repeat_function()
+
+ # Should have started a Thread and scheduled a Timer
+ mock_thread.start.assert_called_once()
+ mock_timer.start.assert_called_once()
+ # The created timer instance should be stored on the manager
+ assert id(rm._timer) == id(mock_timer)
+
+
+def test_get_legend_url_invalid_tag():
+ vdom = {"tagName": "Div", "attributes": {}}
+ with pytest.raises(ValueError):
+ utils._get_legend_url_(vdom)
+
+
+def test_get_legend_url_single_layer_list_returns_none():
+ # When LAYERS is a single-element list the function currently treats
+ # it as not a single-layer and will print and return None.
+ vdom = {
+ "tagName": "ImageWMSSource",
+ "attributes": {
+ "options": {
+ "params": {"LAYERS": ["only_layer"]},
+ "url": "http://example.com/wms",
+ }
+ },
+ }
+
+ with (
+ mock.patch("builtins.print") as mock_print,
+ mock.patch("builtins.__import__"),
+ ):
+ result = utils._get_legend_url_(vdom)
+ assert result is None
+ mock_print.assert_called_with("NOT SINGLE LAYER")
+
+
+def test_get_legend_url_basic_with_layer_and_no_resolution():
+ vdom = {
+ "tagName": "TileWMSSource",
+ "attributes": {
+ "options": {
+ "params": {"LAYER": "layer1"},
+ "url": "http://example.com/wms",
+ }
+ },
+ }
+
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.urljoin = urljoin
+ mock_import.return_value.urlencode = urlencode
+ url = utils._get_legend_url_(vdom)
+ assert isinstance(url, str)
+ assert "GetLegendGraphic" in url
+ assert "LAYER=layer1" in url
+
+
+def test_get_legend_url_basic_with_single_layer_in_layers():
+ vdom = {
+ "tagName": "TileWMSSource",
+ "attributes": {
+ "options": {
+ "params": {"LAYERS": "layer1"},
+ "url": "http://example.com/wms",
+ }
+ },
+ }
+
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.urljoin = urljoin
+ mock_import.return_value.urlencode = urlencode
+ url = utils._get_legend_url_(vdom)
+ assert isinstance(url, str)
+ assert "GetLegendGraphic" in url
+ assert "LAYER=layer1" in url
+
+
+def test_get_legend_url_with_resolution_and_scale():
+ vdom = {
+ "tagName": "ImageWMSSource",
+ "attributes": {
+ "options": {
+ "params": {"LAYER": "layerX", "projection": "EPSG:3857"},
+ "url": "http://example.com/wms",
+ }
+ },
+ }
+
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.urljoin = urljoin
+ mock_import.return_value.urlencode = urlencode
+ mock_crs = mock_import.return_value.CRS
+ mock_axis = mock.MagicMock()
+ mock_axis.unit_conversion_factor = 2
+ mock_crs.return_value.axis_info = [mock_axis]
+
+ # Call with resolution so SCALE is computed
+ result = utils._get_legend_url_(vdom, resolution=100)
+
+ assert isinstance(result, str)
+ assert "GetLegendGraphic" in result
+ assert "SCALE=" in result
+
+
+def test_get_feature_info_url_invalid_tag():
+ vdom = {"tagName": "Div", "attributes": {}}
+ with pytest.raises(ValueError):
+ utils._get_feature_info_url_(vdom, [0, 0], 1, "EPSG:3857", "EPSG:3857")
+
+
+def test_get_feature_info_url_not_implemented_for_diff_projections():
+ vdom = {
+ "tagName": "TileWMSSource",
+ "attributes": {
+ "options": {
+ "params": {"LAYERS": "layer1"},
+ "url": "http://example.com/wms",
+ }
+ },
+ }
+
+ with mock.patch("builtins.__import__"):
+ with pytest.raises(NotImplementedError):
+ # map_proj != layer_proj triggers NotImplementedError
+ utils._get_feature_info_url_(vdom, [0, 0], 1, "EPSG:3857", "EPSG:4326")
+
+
+def test_get_feature_info_url_success():
+ vdom = {
+ "tagName": "ImageWMSSource",
+ "attributes": {
+ "options": {
+ "params": {"LAYERS": "layer1"},
+ "url": "http://example.com/wms",
+ }
+ },
+ }
+
+ # Patch pyproj.CRS to provide predictable axis_info and directions
+ with mock.patch("builtins.__import__") as mock_import:
+ mock_import.return_value.urljoin = urljoin
+ mock_import.return_value.urlencode = urlencode
+ mock_crs = mock_import.return_value.CRS
+ mock_axis1 = mock.MagicMock()
+ mock_axis1.direction = "north"
+ mock_axis2 = mock.MagicMock()
+ mock_axis2.direction = "east"
+ mock_crs.return_value.axis_info = [mock_axis1, mock_axis2]
+
+ feature_url = utils._get_feature_info_url_(
+ vdom,
+ map_coordinate=[0, 0],
+ map_resolution=1,
+ map_proj="EPSG:3857",
+ layer_proj="EPSG:3857",
+ )
- utils._background_execute_wrapper(test_func, ["Hello"], callback)
- test_func.assert_called_once_with("Hello")
- callback.assert_called_once_with("Test")
+ assert isinstance(feature_url, str)
+ assert "GetFeatureInfo" in feature_url
+ assert "I=" in feature_url
+ assert "J=" in feature_url
+
+
+def test_find_by_tag_various_structures():
+ # Nested dict/list structure with two matching tags
+ tree = {
+ "tagName": "root",
+ "children": [
+ {"tagName": "target", "children": []},
+ {
+ "tagName": "branch",
+ "children": [
+ {"tagName": "target", "children": []},
+ "some text",
+ ],
+ },
+ ],
+ }
+
+ found = utils.find_by_tag(tree, "target")
+ assert len(found) == 2
+
+ # Top-level list input
+ found_list = utils.find_by_tag(
+ [tree, {"tagName": "target", "children": []}], "target"
+ )
+ assert len(found_list) == 3
+
+ # Non-element input returns empty list
+ assert utils.find_by_tag("not an element", "target") == []
diff --git a/tethys_apps/static/tethys_apps/js/layer-panel.js b/tethys_apps/static/tethys_apps/js/layer-panel.js
index 611b05bbd..4f0d8745d 100644
--- a/tethys_apps/static/tethys_apps/js/layer-panel.js
+++ b/tethys_apps/static/tethys_apps/js/layer-panel.js
@@ -1,7 +1,7 @@
import {LayersHalf} from "https://esm.sh/react-bootstrap-icons@1.11.4?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=LayersHalf";
export {LayersHalf};
-import {SidePanel} from 'https://esm.sh/ol-side-panel@1.0.6?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0&exports=SidePanel';
-import LayerSwitcher from "https://esm.sh/ol-layerswitcher@4.1.2?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.4.0&bundle=false";
+import {SidePanel} from 'https://esm.sh/ol-side-panel@1.0.6?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0&exports=SidePanel';
+import LayerSwitcher from "https://esm.sh/ol-layerswitcher@4.1.2?deps=react@19.0,react-dom@19.0,react-is@19.0,ol@10.7.0&bundle=false";
class LayerPanelClass extends SidePanel {
constructor() {
diff --git a/tethys_apps/static/tethys_apps/js/ol-mods/Overlay.js b/tethys_apps/static/tethys_apps/js/ol-mods/Overlay.js
new file mode 100644
index 000000000..66136d19a
--- /dev/null
+++ b/tethys_apps/static/tethys_apps/js/ol-mods/Overlay.js
@@ -0,0 +1,39 @@
+import OLOverlay from 'https://esm.sh/ol@10.7.0/Overlay.js';
+
+const OVERLAYS_DOM_NODE = document.createElement('div');
+const OVERLAYS_ROOT = ReactDOM.createRoot(OVERLAYS_DOM_NODE);
+const OVERLAYS = {};
+document.body.appendChild(OVERLAYS_DOM_NODE);
+
+function renderNode(type, attributes, children) {
+ if (Array.isArray(children) && children.length > 0) {
+ children = children.map((child) => typeof child === "string" ? child : renderNode(child.tagName, child.attributes, child.children))
+ }
+ return React.createElement(type, attributes, children)
+}
+
+export default function Overlay(...props) {
+ props = props[0];
+
+ if (props.element) {
+ if (props.element instanceof HTMLElement) {
+ "pass";
+ } else {
+ let Element;
+ if (typeof props.element === 'string') {
+ Element = document.getElementById(props.element);
+ } else if (typeof props.element === "object" && props.element.tagName) {
+ Element = document.getElementById(props.element.attributes.id);
+ if (!Element) {
+ props.element.attributes.hidden = true;
+ }
+ OVERLAYS[props.element.attributes.id] = props.element;
+ OVERLAYS_ROOT.render(
+ renderNode("div", {}, Object.values(OVERLAYS))
+ );
+ }
+ props.element = Element;
+ }
+ }
+ return React.createElement('overlay', {cls: OLOverlay, ...props});
+}
\ No newline at end of file
diff --git a/tethys_apps/static/tethys_apps/js/ol-mods/View.js b/tethys_apps/static/tethys_apps/js/ol-mods/View.js
new file mode 100644
index 000000000..07fd3b4e9
--- /dev/null
+++ b/tethys_apps/static/tethys_apps/js/ol-mods/View.js
@@ -0,0 +1,26 @@
+import _View from 'https://esm.sh/ol@10.7.0/View';
+import Projection from 'https://esm.sh/ol@10.7.0/proj/Projection';
+import proj4 from 'https://esm.sh/proj4?deps=ol@10.7.0';
+import {register} from 'https://esm.sh/ol@10.7.0/proj/proj4';
+
+export default function View (...props) {
+ let projection;
+ props = props[0];
+ if (!props.options) props.options = {};
+
+ if (props.options.projection) {
+ projection = props.options.projection;
+ if (typeof projection === "string" || projection instanceof Projection) {
+ "pass";
+ } else {
+ if (projection.definition) {
+ proj4.defs(projection.code, projection.definition);
+ register(proj4);
+ delete projection.definition;
+ }
+ projection = new Projection(projection);
+ }
+ props.options.projection = projection;
+ }
+ return React.createElement('view', {cls: _View, ...props});
+}
\ No newline at end of file
diff --git a/tethys_apps/static/tethys_apps/js/ol-mods/source/Image.js b/tethys_apps/static/tethys_apps/js/ol-mods/source/Image.js
new file mode 100644
index 000000000..73b039dcf
--- /dev/null
+++ b/tethys_apps/static/tethys_apps/js/ol-mods/source/Image.js
@@ -0,0 +1,25 @@
+import _ImageSource from 'https://esm.sh/ol@10.7.0/source/Image';
+import {load} from 'https://esm.sh/ol@10.7.0/Image';
+import {createLoader} from 'https://esm.sh/ol@10.7.0/source/wms';
+
+export default function ImageSource (...props) {
+ let loader;
+ props = props[0];
+ if (!props.options) props.options = {};
+
+ if (props.loader || props.options.loader) {
+ loader = props.loader || props.options.loader;
+ if (typeof loader === "object") {
+ if (loader.load) {
+ loader.load = load
+ }
+ loader = createLoader(loader);
+ }
+ if (props.loader) {
+ props.loader = loader;
+ } else {
+ props.options.loader = loader;
+ }
+ }
+ return React.createElement('source', {cls: _ImageSource, ...props});
+}
\ No newline at end of file
diff --git a/tethys_apps/static/tethys_apps/js/ol-mods/source/TileWMS.js b/tethys_apps/static/tethys_apps/js/ol-mods/source/TileWMS.js
new file mode 100644
index 000000000..4038efc5e
--- /dev/null
+++ b/tethys_apps/static/tethys_apps/js/ol-mods/source/TileWMS.js
@@ -0,0 +1,17 @@
+import _TileWMSSource from 'https://esm.sh/ol@10.7.0/source/TileWMS';
+import TileGrid from 'https://esm.sh/ol@10.7.0/tilegrid/TileGrid.js';
+
+export default function TileWMSSource (...props) {
+ let tileGrid;
+ props = props[0];
+ if (!props.options) props.options = {};
+
+ if (props.options.tileGrid) {
+ tileGrid = props.options.tileGrid;
+ if (!(tileGrid instanceof TileGrid)) {
+ tileGrid = new TileGrid(tileGrid);
+ }
+ props.options.tileGrid = tileGrid;
+ }
+ return React.createElement('source', {cls: _TileWMSSource, ...props});
+}
\ No newline at end of file
diff --git a/tethys_apps/static/tethys_apps/js/ol-mods/source/Vector.js b/tethys_apps/static/tethys_apps/js/ol-mods/source/Vector.js
new file mode 100644
index 000000000..95bd4a54e
--- /dev/null
+++ b/tethys_apps/static/tethys_apps/js/ol-mods/source/Vector.js
@@ -0,0 +1,39 @@
+import _VectorSource from 'https://esm.sh/ol@10.7.0/source/Vector';
+import * as FormatLib from 'https://esm.sh/ol@10.7.0/format';
+import Feature from 'https://esm.sh/ol@10.7.0/Feature';
+
+export default function VectorSource (...props) {
+ let format, features;
+ props = props[0];
+ if (!props.options) props.options = {};
+
+ if (props.format || props.options.format) {
+ format = props.format || props.options.format;
+ if (typeof format === "string") {
+ format = new FormatLib[format]();
+ }
+ if (props.format) {
+ props.format = format;
+ } else {
+ props.options.format = format;
+ }
+ }
+
+ if (props.features || props.options.features) {
+ if (!format) {
+ throw Error("Format must be specified when features are provided");
+ }
+ features = props.features || props.options.features;
+ if (Array.isArray(features) && features.length > 0 && features[0] instanceof Feature) {
+ 'pass';
+ } else {
+ features = format.readFeatures(features);
+ }
+ if (props.features) {
+ props.features = features;
+ } else {
+ props.options.features = features;
+ }
+ }
+ return React.createElement('source', {cls: _VectorSource, ...props});
+}
\ No newline at end of file
diff --git a/tethys_components/custom.py b/tethys_components/custom.py
index 572b53faa..4380bed0b 100644
--- a/tethys_components/custom.py
+++ b/tethys_components/custom.py
@@ -5,10 +5,13 @@
RESOURCE_DIR = THIS_DIR / "resources"
-def Display(lib, children=None):
+def Display(lib, **kwargs):
"""A full screen container for nesting content within."""
- return lib.bs.Container(fluid=True, style=lib.Style(height="100%"))(
- *(children or [])
+ style = lib.Style(height="100%")
+ if "style" in kwargs:
+ style |= kwargs["style"]
+ return lib.bs.Container(fluid=True, style=style)(
+ *kwargs.get("children", []),
)
@@ -19,19 +22,11 @@ def LayerPanel(lib):
def PageLoader(lib, content):
hide_loading, set_hide_loading = lib.hooks.use_state(True)
hide_content, set_hide_content = lib.hooks.use_state(True)
-
lib.hooks.use_effect(
- # Delay the content load so it doesn't flash at all
+ # This None if ... else None is a weird pattern used to ensure that nothing is returned by this
+ # lambda, since if anything besided None is returned, it is assumed to be a cleanup function
lambda: (
- None
- if all(
- [
- set_hide_loading(False),
- lib.utils.background_execute(set_hide_content, [False], 0.5),
- lib.utils.background_execute(set_hide_loading, [True], 2),
- ]
- )
- else None
+ None if any([set_hide_loading(False), set_hide_content(False)]) else None
),
dependencies=[],
)
@@ -49,7 +44,31 @@ def PageLoader(lib, content):
style=lib.Style(
display=None if hide_content else "unset", height="100%", width="100%"
),
- )(content),
+ )(
+ content,
+ (
+ lib.html.div(style=lib.Style(display="none"))(
+ lib.html.div(
+ id_="trigger-loaded", onClick=lambda _: set_hide_loading(True)
+ ),
+ lib.html.script(
+ """
+ window.onload = function () {
+ window.setTimeout(function () {
+ try {
+ document.getElementById('trigger-loaded').click();
+ } catch (e) {
+ 'pass';
+ }
+ }, 1000)
+ }
+ """
+ ),
+ )
+ if not hide_loading
+ else None
+ ),
+ ),
)
@@ -106,17 +125,21 @@ def BaseMapSuite(lib, default="OpenStreetMap"):
"OpenStreetMap": lib.ol.source.OSM(),
# "Bing": lib.ol.source.BingMaps(),
"XYZ": lib.ol.source.XYZ(
- url="https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png"
+ options=lib.Props(url="https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png")
),
**{
" ".join(esri_basemap.split("_")): lib.ol.source.XYZ(
- url=f"https://server.arcgisonline.com/ArcGIS/rest/services/{esri_basemap}/MapServer/tile/{{z}}/{{y}}/{{x}}"
+ options=lib.Props(
+ url=f"https://server.arcgisonline.com/ArcGIS/rest/services/{esri_basemap}/MapServer/tile/{{z}}/{{y}}/{{x}}"
+ )
)
for esri_basemap in ESRI_BASEMAP_NAMES
},
**{
f"CartoDB ({style}_{label})": lib.ol.source.XYZ(
- url=f"http://{{1-4}}.basemaps.cartocdn.com/{style}_{label}/{{z}}/{{x}}/{{y}}.png"
+ options=lib.Props(
+ url=f"http://{{1-4}}.basemaps.cartocdn.com/{style}_{label}/{{z}}/{{x}}/{{y}}.png"
+ )
)
for style, label in [
(style, label)
@@ -125,18 +148,26 @@ def BaseMapSuite(lib, default="OpenStreetMap"):
]
},
}
- return lib.ol.layer.Group(title="Basemap", fold="close")(
+ return lib.ol.layer.Group(options=lib.Props(title="Basemap", fold="close"))(
*[
- lib.ol.layer.Tile(visible=default == title, type="base", title=title)(
- source
- )
+ lib.ol.layer.Tile(
+ options=lib.Props(
+ visible=str(default) == str(title), type="base", title=str(title)
+ )
+ )(source)
for title, source in SUPPORTED_BASEMAPS.items()
]
)
def Map(
- lib, projection="EPSG:3857", center=None, zoom=3.5, on_click=None, children=None
+ lib,
+ default_basemap="OpenStreetMap",
+ projection="EPSG:3857",
+ center=None,
+ zoom=1,
+ children=None,
+ **kwargs,
):
"""A Map for displaying geospatial data. Fixed to the EPSG:3857 projection.
@@ -146,16 +177,36 @@ def Map(
on_click (callable): A function that should be called when the map is clicked. Defaults to None.
children (list[]): A list of layers to be rendered. These can also be passed in as nested components (i.e. Map()(layer1, layer2, layer3)). Defaults to [].
"""
-
- center = (
- center or [-100, 40]
+ if isinstance(projection, dict):
+ if not center and not projection.get("definition"):
+ raise ValueError(
+ "Either provide a center point or a projection definition from which a center point can be calculated."
+ )
+ center = center or (
+ [-100, 40]
if projection == "EPSG:3857"
else lib.utils.transform_coordinate([-100, 40], "EPSG:3857", projection)
)
- return lib.ol.Map(**({"onClick": on_click} if on_click else {}))(
- lib.ol.View(projection=projection, center=center, zoom=zoom),
- lib.tethys.BaseMapSuite(),
- lib.ol.layer.Group(title="Overlays", fold="open")(*children or []),
+ view_opts = lib.Props(projection=projection)
+ actual_children = []
+ overlays = []
+ if children:
+ for child in children:
+ if not child:
+ continue
+ if dict(child)["tagName"] == "Overlay":
+ overlays.append(child)
+ else:
+ actual_children.append(child)
+ if isinstance(projection, dict) and projection.get("extent"):
+ view_opts |= lib.Props(extent=projection["extent"])
+ return lib.ol.Map(**lib.Props(**kwargs))(
+ lib.ol.View(options=view_opts, center=center, zoom=zoom),
+ lib.tethys.BaseMapSuite(default=default_basemap),
+ *overlays if overlays else [],
+ lib.ol.layer.Group(options=lib.Props(title="Overlays", fold="open"))(
+ *actual_children or []
+ ),
lib.ol.control.ScaleLine(),
lib.tethys.LayerPanel(),
)
@@ -218,6 +269,7 @@ def Panel(
lib,
show=True,
set_show=None,
+ handle_close=None,
anchor="right",
extent="500px",
title="Panel",
@@ -248,7 +300,7 @@ def Panel(
anchor = {"right": "end", "left": "start"}[anchor]
style["width"] = extent
- def handle_close(_):
+ def _handle_close(_):
set_show(False) if set_show is not None else _set_show(False)
return lib.html.div(
@@ -264,7 +316,7 @@ def handle_close(_):
type="button",
class_name="btn-close",
**{"aria-label": "true"},
- onClick=handle_close,
+ onClick=handle_close if handle_close else _handle_close,
),
),
lib.html.div(class_name="offcanvas-body")(*children or []),
@@ -312,7 +364,10 @@ def HeaderWithNavBar(lib, app, user, nav_links=None):
redirect, set_redirect = lib.hooks.use_state(False)
lib.hooks.use_effect(
- lambda: lib.utils.background_execute(set_margin_top, [0], 0.5), []
+ lambda: (
+ None if lib.utils.background_execute(set_margin_top, [0], 0.5) else None
+ ),
+ [],
)
def handle_exit(*_, **__): # pragma: no cover
@@ -346,9 +401,13 @@ def handle_exit(*_, **__): # pragma: no cover
),
)(
lib.bs.Container(as_="header", fluid=True, class_name="px-4")(
- lib.bs.NavbarToggle(
- aria_controls="offcanvasNavbar",
- class_name="styled-header-button",
+ (
+ lib.bs.NavbarToggle(
+ aria_controls="offcanvasNavbar",
+ class_name="styled-header-button",
+ )
+ if len(nav_links) > 1
+ else lib.html.span()
),
lib.bs.NavbarBrand(
href=f"/apps/{app.root_url}/",
diff --git a/tethys_components/library.py b/tethys_components/library.py
index a67c240ba..02e23dc4b 100644
--- a/tethys_components/library.py
+++ b/tethys_components/library.py
@@ -23,6 +23,21 @@
class _CallableVdom(dict):
+ def as_dict(self):
+ return dict(self)
+
+ def __getattribute__(self, name):
+ if (
+ not name.startswith("__")
+ and hasattr(utils, f"_{name}_")
+ and callable(getattr(utils, f"_{name}_"))
+ ):
+ func = partial(getattr(utils, f"_{name}_"), self)
+ setattr(self, name, func)
+ return func
+ else:
+ return super().__getattribute__(name)
+
def __call__(self, *args):
self["children"] = list(args)
return self
@@ -73,13 +88,8 @@ def __call__(self, *args, **kwargs):
for k, v in kwargs.items():
if callable(v):
kwargs[k] = utils.args_to_dot_notation_dicts(v)
- # Custom ReactPy
- if self.component.startswith("ol") and any(
- x in self.component for x in ["ol.source", "ol.layer", "ol.View"]
- ):
- args = [{"options": utils.Props(**kwargs)}]
- else:
- args = [utils.Props(**kwargs)]
+
+ args = [utils.Props(**kwargs)]
kwargs = {}
vdom = self.vdom_func(*args, **kwargs)
return _CallableVdom(vdom)
@@ -155,7 +165,6 @@ def __init__(
self._components_by_path = {}
def add_component(self, module_path, component):
-
added = False
if module_path not in self._components_by_path:
self._components_by_path[module_path] = []
@@ -185,6 +194,7 @@ def compose_javascript_statements(self):
for i, (module_path, components) in enumerate(
self.get_components_by_path().items()
):
+ module_path = f"{module_path}.js" if self.treat_as_path else module_path
if i == 0:
exports_statement += "export {"
non_default_components = [
@@ -301,16 +311,30 @@ class ComponentLibrary:
name="plotly-chart.js",
host="/static/tethys_apps/js",
),
+ "olmod": Package(
+ name="ol-mods",
+ host="/static/tethys_apps/js",
+ default_export="*",
+ treat_as_path=True,
+ ),
"ol": Package(
name="@planet/maps@11.2.0",
default_export="*",
treat_as_path=True,
- dependencies=["ol@10.4.0"],
- styles=["https://esm.sh/ol@10.4.0/ol.css"],
+ dependencies=["ol@10.7.0"],
+ styles=["https://esm.sh/ol@10.7.0/ol.css"],
),
}
)
+ OVERRIDES = {
+ "ol.source.Vector": "olmod.source.Vector",
+ "ol.source.Image": "olmod.source.Image",
+ "ol.source.TileWMS": "olmod.source.TileWMS",
+ "ol.View": "olmod.View",
+ "ol.Overlay": "olmod.Overlay",
+ }
+
def __init__(self, name):
self.name = name
@@ -376,7 +400,9 @@ def render_js_template(self) -> str:
return content
- def register(self, package, accessor, styles=None, default_export=None):
+ def register(
+ self, package, accessor, styles=None, default_export=None, treat_as_path=False
+ ):
"""
Registers a new package to be used by the ComponentLibrary
@@ -427,7 +453,10 @@ def test_react_grid_layout(lib):
return lib.rgl.GridLayout(...)
"""
new_package = Package(
- name=package, styles=styles, default_export=default_export
+ name=package,
+ styles=styles,
+ default_export=default_export,
+ treat_as_path=treat_as_path,
)
self.CURATED_PACKAGES.check_package(accessor, new_package)
if hasattr(self, accessor):
@@ -477,17 +506,25 @@ def load_dependencies_from_source_code(self, function_or_source_code):
source_code = utils.remove_comments_and_docstrings(source_code)
register_matches = re.findall(r"""lib\.register\([^\)]+\)""", source_code)
- for register_match in register_matches:
- capture_match = re.match(
- r"""lib\.register\((?:package=)?['"]([^'"]+)['"], ?(?:accessor=)?['"]([^'"]+)['"],? ?(?:(?:styles=)?(\[[^\]]+\])?,? ?)(?:(?:default_export=)?['"]([^'"]+)['"])?""",
- "".join(register_match.split()),
- )
- args = []
- for m in capture_match.groups():
- if m and m.startswith("[") and m.endswith("]") and len(m) > 2:
- m = eval(m)
- args.append(m)
- self.register(*args)
+ if register_matches:
+ import ast
+
+ ast_obj = ast.parse(source_code)
+ for node in ast.iter_child_nodes(ast_obj.body[0]):
+ if isinstance(node, ast.Expr):
+ if isinstance(node.value, ast.Call):
+ if isinstance(node.value.func, ast.Attribute):
+ if node.value.func.attr == "register":
+ register_args = []
+ register_kwargs = {}
+ for register_arg in node.value.args:
+ register_args.append(ast.literal_eval(register_arg))
+ for register_kwarg in node.value.keywords:
+ register_kwargs[register_kwarg.arg] = (
+ ast.literal_eval(register_kwarg.value)
+ )
+ self.register(*register_args, **register_kwargs)
+
matches = re.findall(r"\blib\.([^\(]+)\(", source_code)
for match in matches:
try:
@@ -548,9 +585,21 @@ def __init__(
def __getattr__(self, attr):
component = f"{self.component}.{attr}" if self.component else attr
- new_instance = DynamicPackageManager(
- library=self.library, package=self.package, component=component
- )
+ override_key = f"{self.package.accessor}.{component}"
+ if override_key in self.library.OVERRIDES:
+ override_accessor, override_component = self.library.OVERRIDES[
+ override_key
+ ].split(".", 1)
+ new_instance_base = getattr(
+ self.library, override_accessor
+ ) # Get or create the new instance of the override package if it does not exist
+ new_instance = getattr(
+ new_instance_base, override_component
+ ) # Create the new instance of the override component (a recursive call to this same __getattr__ method)
+ else:
+ new_instance = DynamicPackageManager(
+ library=self.library, package=self.package, component=component
+ )
setattr(self, attr, new_instance)
return new_instance
@@ -558,7 +607,7 @@ def __call__(self, *args, **kwargs):
component_parts = self.component.split(".")
_component = self.component
- if self.package.accessor == "ol":
+ if self.package.accessor in ["ol", "olmod"]:
_component += "." + component_parts[-1]
if len(component_parts) > 1:
_component += component_parts[-2].capitalize()
diff --git a/tethys_components/resources/reactjs_module_wrapper_template.js b/tethys_components/resources/reactjs_module_wrapper_template.js
index 4d2d64df8..32d62dd0f 100644
--- a/tethys_components/resources/reactjs_module_wrapper_template.js
+++ b/tethys_components/resources/reactjs_module_wrapper_template.js
@@ -56,22 +56,70 @@ function wrapEventHandlers(props) {
return newProps;
}
-function stringifyToDepth(val, depth, replacer, space) {
- depth = isNaN(+depth) ? 1 : depth;
- function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
- return !val || typeof val != 'object' ? val : (a=Array.isArray(val), JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(k, v, a?depth:depth-1); } }), o||(a?[]:{}));
- }
- return JSON.stringify(_build('', val, depth), null, space);
+/**
+ * Converts an HTML element and its children into a structured JavaScript object.
+ * @param {HTMLElement} element The HTML element to convert.
+ * @return {object} The structured object.
+ */
+function htmlToJsonObject(element) {
+ if (!element) return null;
+
+ const obj = {
+ tagName: element.tagName.toLowerCase(),
+ attributes: {}
+ };
+
+ // Get attributes
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attr = element.attributes[i];
+ obj.attributes[attr.name] = attr.value;
+ }
+
+ return obj;
}
-function stringifyReplacer (key, value) {
- if (key === '') return value;
- try {
- JSON.stringify(value);
- return value;
- } catch (err) {
- return (typeof value === 'object') ? value : undefined;
+function jsonSanitizeObject(obj, maxDepth, refs, depth) {
+ if (!maxDepth) {
+ maxDepth = 4;
+ }
+ if (!depth) {
+ depth = 0;
+ }
+ if (!refs) {
+ refs = [];
+ }
+ if (typeof obj === 'string' || typeof obj === 'number' || obj == null || typeof obj === 'boolean') {
+ return obj;
+ }
+ if (typeof obj === 'function') {
+ return undefined;
}
+ if (obj.constructor === Window) {
+ return undefined;
+ }
+ if (refs.includes(obj)) {
+ return undefined;
+ }
+ refs.push(obj);
+ delete obj.nativeEvent;
+ let newObj = Array.isArray(obj) ? [] : {};
+ if (depth > maxDepth) {
+ newObj = "BEYOND MAX DEPTH";
+ } else {
+ for (const [key, value] of Object.entries(obj)) {
+ if (refs.includes(value)) continue;
+ newObj[key] = jsonSanitizeObject(value, maxDepth, refs, depth+1);
+ }
+ if (obj.__proto__) {
+ Object.getOwnPropertyNames(obj.__proto__).forEach(function (propName) {
+ newObj[propName] = jsonSanitizeObject(obj[propName], maxDepth, refs, depth+1);
+ });
+ }
+ if (obj instanceof Element) {
+ newObj = {...newObj, ...htmlToJsonObject(obj)}
+ }
+ }
+ return newObj;
}
function makeJsonSafeEventHandler(oldHandler) {
@@ -79,31 +127,6 @@ function makeJsonSafeEventHandler(oldHandler) {
// they are JSON serializable or not. We can allow normal synthetic events to pass
// through since the original handler already knows how to serialize those for us.
return function safeEventHandler() {
-
- var filteredArguments = [];
- Array.from(arguments).forEach(function (arg) {
- let filteredArg = arg;
- if (typeof arg === "object") {
- if (arg.nativeEvent) {
- // this is probably a standard React synthetic event
- filteredArg = arg;
- } else {
- filteredArg = JSON.parse(stringifyToDepth(arg, 3, stringifyReplacer));
- }
-
- if (arg.__proto__) {
- Object.getOwnPropertyNames(arg.__proto__).forEach(function (propName) {
- if (propName == 'constructor') return;
- if (!arg.hasOwnProperty(propName) && arg[propName]) {
- filteredArg[propName] = arg[propName];
- delete filteredArg[propName + '_'];
- }
- });
- }
- }
- // Add non-enumerable properties
- filteredArguments.push(filteredArg);
- });
- oldHandler(...Array.from(filteredArguments));
+ oldHandler(...Array.from(arguments).map((x) => jsonSanitizeObject(x, 4)));
};
}
\ No newline at end of file
diff --git a/tethys_components/utils.py b/tethys_components/utils.py
index 3ee797da5..03c077c27 100644
--- a/tethys_components/utils.py
+++ b/tethys_components/utils.py
@@ -1,5 +1,6 @@
import inspect
import io
+import math
import tokenize
from tethys_components import layouts
from typing import Any
@@ -51,6 +52,13 @@ def wrapped(*data):
return wrapped
+def fetch(url: str) -> str:
+ """Fetches data from url and returns result as string"""
+ from requests import get
+
+ return get(url).text
+
+
def fetch_json(url: str, as_attr_dict: bool = True) -> dict | DotNotationDict:
"""Fetches data expected to be JSON from url and returns result as AttrDict"""
from requests import get
@@ -205,6 +213,42 @@ def _background_execute_wrapper(func, args, callback=None):
callback(result)
+class RepeatManager:
+ from threading import Timer, Thread
+
+ def __init__(self, repeat_seconds, target, args=None):
+ self._running = False
+ self.repeat_seconds = repeat_seconds
+ self.target = target
+ self.args = args or ()
+ self._timer = None
+
+ def _repeat_function(self):
+ if not self._running:
+ return
+ self.Thread(
+ target=self.target,
+ args=self.args,
+ ).start()
+ self._timer = self.Timer(
+ interval=self.repeat_seconds,
+ function=self._repeat_function,
+ )
+ self._timer.start()
+
+ def start(self):
+ self._running = True
+ self._repeat_function()
+ return self
+
+ def cancel(self):
+ self._running = False
+ self._timer.cancel()
+
+ def is_alive(self):
+ return self._running
+
+
def background_execute(
func, args=None, delay_seconds=None, repeat_seconds=None, callback=None
):
@@ -220,47 +264,57 @@ def background_execute(
Returns: None
"""
+ args = args or ()
+ if not isinstance(args, (list, tuple)):
+ raise ValueError("args must be a list or tuple")
if delay_seconds:
from threading import Timer
t = Timer(
- delay_seconds,
- _background_execute_wrapper,
- [func, args if args else [], callback],
+ interval=delay_seconds,
+ function=_background_execute_wrapper,
+ args=(func, args, callback),
+ )
+ elif repeat_seconds:
+ t = RepeatManager(
+ repeat_seconds=repeat_seconds,
+ target=_background_execute_wrapper,
+ args=(func, args, callback),
)
else:
from threading import Thread
t = Thread(
target=_background_execute_wrapper,
- func=func,
- args=args if args else [],
- callback=callback,
+ args=(func, args, callback),
)
t.start()
-
- if repeat_seconds:
- from threading import Timer
-
- def repeat_function():
- Thread(
- target=_background_execute_wrapper,
- func=func,
- args=args if args else [],
- callback=callback,
- ).start()
- Timer(repeat_seconds, repeat_function).start()
-
- repeat_function()
+ return t
def transform_coordinate(coordinate, src_proj, target_proj):
from pyproj import Transformer, CRS
- source_crs = CRS(src_proj)
- target_crs = CRS(target_proj)
+ if isinstance(src_proj, dict):
+ source_crs = CRS(src_proj["definition"])
+ elif isinstance(src_proj, str):
+ source_crs = CRS(src_proj)
+ else:
+ raise ValueError(
+ "src_proj must be a string or dictionary with a definition key"
+ )
+
+ if isinstance(target_proj, dict):
+ target_crs = CRS(target_proj["definition"])
+ elif isinstance(target_proj, str):
+ target_crs = CRS(target_proj)
+ else:
+ raise ValueError(
+ "target_proj must be a string or dictionary with a definition key"
+ )
+
transformer = Transformer.from_crs(source_crs, target_crs)
return transformer.transform(coordinate[0], coordinate[1])
@@ -356,3 +410,180 @@ def remove_comments_and_docstrings(source):
def _get_db_object(app):
return app.db_object
+
+
+def _get_legend_url_(vdom_element, resolution=None, params=None):
+ if vdom_element["tagName"] not in ["ImageWMSSource", "TileWMSSource"]:
+ raise ValueError(
+ "The get_legend_url method can only be called on ImageWMSSource or TileWMSSource components"
+ )
+
+ from urllib.parse import urlencode, urljoin
+ from pyproj import CRS
+
+ if not params:
+ params = {}
+
+ source_params = vdom_element["attributes"]["options"]["params"]
+ base_url = vdom_element["attributes"]["options"]["url"]
+ query_params = {
+ "SERVICE": "WMS",
+ "VERSION": "1.0.0",
+ "REQUEST": "GetLegendGraphic",
+ "FORMAT": "image/png",
+ **source_params,
+ }
+
+ if "LAYER" not in query_params:
+ layers = source_params["LAYERS"]
+ is_single_layer = not isinstance(layers, list) or len(layers) != 1
+ if not is_single_layer:
+ print("NOT SINGLE LAYER")
+ return None
+ query_params["LAYER"] = layers
+
+ if resolution:
+ mpu = (
+ CRS(
+ source_params["projection"]
+ if "projection" in source_params
+ else "EPSG:3857"
+ )
+ .axis_info[0]
+ .unit_conversion_factor
+ )
+ pixelSize = 0.00028
+ query_params["SCALE"] = (resolution * mpu) / pixelSize
+
+ query_string = urlencode(query_params)
+ legend_url = urljoin(base_url, f"?{query_string}")
+ return legend_url
+
+
+def _get_feature_info_url_(
+ vdom_element, map_coordinate, map_resolution, map_proj, layer_proj, params=None
+):
+ if vdom_element["tagName"] not in ["ImageWMSSource", "TileWMSSource"]:
+ raise ValueError(
+ "The get_feature_info_url method can only be called on ImageWMSSource or TileWMSSource components"
+ )
+
+ from urllib.parse import urlencode, urljoin
+ from pyproj import CRS
+
+ if not params:
+ params = {}
+
+ GETFEATUREINFO_IMAGE_SIZE = [101, 101]
+ DECIMALS = 4
+
+ if map_proj != layer_proj:
+ # TODO: Implement transformation of map coordinates to layer coordinates
+ raise NotImplementedError(
+ "get_feature_info_url has not yet been implemented for layers with different projections than the map"
+ )
+
+ extent = _get_for_view_and_size(
+ map_coordinate,
+ map_resolution,
+ 0,
+ GETFEATUREINFO_IMAGE_SIZE,
+ )
+ x = round(math.floor((map_coordinate[0] - extent[0]) / map_resolution), DECIMALS)
+ y = round(math.floor((extent[3] - map_coordinate[1]) / map_resolution), DECIMALS)
+
+ axisOrientation = "".join([a.direction[0] for a in CRS(map_proj).axis_info])
+ bbox = (
+ [extent[1], extent[0], extent[3], extent[2]]
+ if axisOrientation == "ne"
+ else extent
+ )
+
+ source_params = vdom_element["attributes"]["options"]["params"]
+ base_url = vdom_element["attributes"]["options"]["url"]
+ query_params = {
+ "SERVICE": "WMS",
+ "VERSION": "1.3.0",
+ "REQUEST": "GetFeatureInfo",
+ "LAYERS": source_params["LAYERS"],
+ "STYLES": "",
+ "CRS": map_proj, # Map's projection
+ "BBOX": ",".join(str(x) for x in bbox), # In map's projection
+ "WIDTH": GETFEATUREINFO_IMAGE_SIZE[0],
+ "HEIGHT": GETFEATUREINFO_IMAGE_SIZE[1],
+ "QUERY_LAYERS": source_params["LAYERS"],
+ "INFO_FORMAT": "application/json",
+ "I": x, # X ordinate of query point on map, in pixels. 0 is left side.
+ "J": y, # Y ordinate of query point on map, in pixels. 0 is top.
+ **source_params,
+ **params,
+ }
+
+ query_string = urlencode(query_params)
+ feature_info_url = urljoin(base_url, f"?{query_string}")
+ return feature_info_url
+
+
+def _get_for_view_and_size(center, resolution, rotation, size):
+ [x0, y0, x1, y1, x2, y2, x3, y3, _, _] = _get_rotated_viewport(
+ center,
+ resolution,
+ rotation,
+ size,
+ )
+ return [
+ min(x0, x1, x2, x3),
+ min(y0, y1, y2, y3),
+ max(x0, x1, x2, x3),
+ max(y0, y1, y2, y3),
+ ]
+
+
+def _get_rotated_viewport(center, resolution, rotation, size):
+ dx = (resolution * size[0]) / 2
+ dy = (resolution * size[1]) / 2
+ cosRotation = math.cos(rotation)
+ sinRotation = math.sin(rotation)
+ xCos = dx * cosRotation
+ xSin = dx * sinRotation
+ yCos = dy * cosRotation
+ ySin = dy * sinRotation
+ x = center[0]
+ y = center[1]
+
+ return [
+ x - xCos + ySin,
+ y - xSin - yCos,
+ x - xCos - ySin,
+ y - xSin + yCos,
+ x + xCos - ySin,
+ y + xSin + yCos,
+ x + xCos + ySin,
+ y + xSin - yCos,
+ x - xCos + ySin,
+ y - xSin - yCos,
+ ]
+
+
+def find_by_tag(element, tag_name: str):
+ """Recursively finds all elements with a specific tag name."""
+ if isinstance(element, dict):
+ found_elements = []
+ if element.get("tagName") == tag_name:
+ found_elements.append(element)
+
+ # Recursively search children
+ children = element.get("children")
+ if children:
+ found_elements.extend(find_by_tag(children, tag_name))
+
+ return found_elements
+
+ elif isinstance(element, list):
+ found_elements = []
+ for child in element:
+ found_elements.extend(find_by_tag(child, tag_name))
+ return found_elements
+ else:
+ # Ignore non-element types (like strings or numbers)
+ return []
diff --git a/tethys_sdk/components/utils.py b/tethys_sdk/components/utils.py
index 21002eef3..2c714c184 100644
--- a/tethys_sdk/components/utils.py
+++ b/tethys_sdk/components/utils.py
@@ -1,2 +1,6 @@
from reactpy import component, event # noqa: F401
-from tethys_components.utils import Props, background_execute # noqa: F401
+from tethys_components.utils import ( # noqa: F401
+ Props,
+ background_execute,
+ transform_coordinate,
+)