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, +)