From 95cb2284c54eb0fea2c194998fb11904a7a43c6e Mon Sep 17 00:00:00 2001 From: Philipp Veller Date: Thu, 7 Dec 2023 13:11:35 +0100 Subject: [PATCH 1/4] react_maps: add app and all necessary react components --- adhocracy4/maps_react/__init__.py | 0 adhocracy4/maps_react/apps.py | 6 + adhocracy4/maps_react/migrations/__init__.py | 0 .../static/a4maps_react/AddMarkerControl.js | 85 ++++++ .../static/a4maps_react/GeoJsonMarker.js | 36 +++ .../maps_react/static/a4maps_react/Map.jsx | 43 +++ .../static/a4maps_react/MapPopup.jsx | 17 ++ .../static/a4maps_react/MaplibreGlLayer.js | 37 +++ .../static/a4maps_react/MarkerClusterLayer.js | 19 ++ .../static/a4maps_react/ZoomControl.js | 48 ++++ .../a4maps_react/map_choose_point_widget.html | 4 + .../maps_react/templatetags/__init__.py | 0 .../templatetags/react_maps_tags.py | 11 + adhocracy4/maps_react/utils.py | 73 +++++ adhocracy4/maps_react/widgets.py | 33 +++ changelog/7704.md | 11 + docs/react_maps.md | 250 ++++++++++++++++++ index.js | 1 + package.json | 3 + tests/maps_react/__init__.py | 0 tests/maps_react/conftest.py | 5 + tests/maps_react/test_maps_react_utils.py | 58 ++++ 22 files changed, 740 insertions(+) create mode 100644 adhocracy4/maps_react/__init__.py create mode 100644 adhocracy4/maps_react/apps.py create mode 100644 adhocracy4/maps_react/migrations/__init__.py create mode 100644 adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js create mode 100644 adhocracy4/maps_react/static/a4maps_react/GeoJsonMarker.js create mode 100644 adhocracy4/maps_react/static/a4maps_react/Map.jsx create mode 100644 adhocracy4/maps_react/static/a4maps_react/MapPopup.jsx create mode 100644 adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js create mode 100644 adhocracy4/maps_react/static/a4maps_react/MarkerClusterLayer.js create mode 100644 adhocracy4/maps_react/static/a4maps_react/ZoomControl.js create mode 100644 adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html create mode 100644 adhocracy4/maps_react/templatetags/__init__.py create mode 100644 adhocracy4/maps_react/templatetags/react_maps_tags.py create mode 100644 adhocracy4/maps_react/utils.py create mode 100644 adhocracy4/maps_react/widgets.py create mode 100644 changelog/7704.md create mode 100644 docs/react_maps.md create mode 100644 tests/maps_react/__init__.py create mode 100644 tests/maps_react/conftest.py create mode 100644 tests/maps_react/test_maps_react_utils.py diff --git a/adhocracy4/maps_react/__init__.py b/adhocracy4/maps_react/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/adhocracy4/maps_react/apps.py b/adhocracy4/maps_react/apps.py new file mode 100644 index 000000000..edf47f027 --- /dev/null +++ b/adhocracy4/maps_react/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class Config(AppConfig): + name = "adhocracy4.maps_react" + label = "a4maps_react" diff --git a/adhocracy4/maps_react/migrations/__init__.py b/adhocracy4/maps_react/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js b/adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js new file mode 100644 index 000000000..498ca51fe --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js @@ -0,0 +1,85 @@ +import L from 'leaflet' +import { createControlComponent } from '@react-leaflet/core' +import { point, inside } from '@turf/turf' +import { makeIcon } from './GeoJsonMarker' + +export function checkPointInsidePolygon (marker, polygons) { + const pointGeoJSON = point([marker.lng, marker.lat]) + let isInPolygon = false + + polygons.eachLayer((layer) => { + const polygonGeoJSON = layer.toGeoJSON() + if (inside(pointGeoJSON, polygonGeoJSON)) { + isInPolygon = true + } + }) + + return isInPolygon +} + +const markerProps = { icon: makeIcon(), draggable: true } + +class AddMarkerControlClass extends L.Control { + constructor ({ input, point }) { + super() + this.marker = null + this.oldCoords = null + this.map = null + this.input = input + + if (point) { + const pointObj = JSON.parse(point) + const latlng = pointObj.geometry.coordinates.reverse() + this.marker = L.marker(latlng, markerProps) + this.oldCoords = latlng + } + } + + updateMarker (latlng) { + const isInsideConstraints = checkPointInsidePolygon(latlng, this.map.constraints) + if (isInsideConstraints) { + this.oldCoords = latlng + if (this.marker) { + this.marker.setLatLng(latlng) + } else { + this.marker = L.marker(latlng, markerProps).addTo(this.map) + this.marker.on('dragend', this.onDragend.bind(this)) + } + this.input.value = JSON.stringify(this.marker.toGeoJSON()) + } + } + + onDragend (e) { + const targetPosition = e.target.getLatLng() + const isInsideConstraints = checkPointInsidePolygon(targetPosition, this.map.constraints) + if (!isInsideConstraints) { + e.target.setLatLng(this.oldCoords) + } else { + this.oldCoords = targetPosition + } + } + + addTo (map) { + this.map = map + this.boundClickHandler = (e) => this.updateMarker(e.latlng) + map.on('click', this.boundClickHandler) + + if (this.marker) { + this.marker.addTo(this.map) + this.marker.on('dragend', this.onDragend.bind(this)) + } + } + + onRemove (map) { + map.off('click', this.boundClickHandler) + if (this.marker) { + this.marker.off('dragend', this.onDragend) + this.marker.remove() + this.marker = null + } + } +} +const createControl = (props) => new AddMarkerControlClass(props) + +const AddMarkerControl = createControlComponent(createControl) +export default AddMarkerControl diff --git a/adhocracy4/maps_react/static/a4maps_react/GeoJsonMarker.js b/adhocracy4/maps_react/static/a4maps_react/GeoJsonMarker.js new file mode 100644 index 000000000..f560a537b --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/GeoJsonMarker.js @@ -0,0 +1,36 @@ +import L from 'leaflet' +import { + createElementObject, + createLayerComponent, extendContext +} from '@react-leaflet/core' + +export const makeIcon = (iconUrl) => + L.icon({ + iconUrl: iconUrl || '/static/images/map_pin_default.svg', + shadowUrl: '/static/images/map_shadow_01.svg', + iconSize: [30, 36], + iconAnchor: [15, 36], + shadowSize: [40, 54], + shadowAnchor: [20, 54], + popupAnchor: [0, -10] + }) + +/** + * Creates a Leaflet marker from a GeoJSON. This is needed to + * be able to add any Tooltip or Popup to the Markers using JSX. + */ +const createGeoJsonMarker = ({ feature, ...props }, context) => { + const coords = [...feature.geometry.coordinates].reverse() + const propsWithIcon = { icon: makeIcon(feature.properties.category_icon), ...props } + const instance = L.marker(coords, propsWithIcon) + + return createElementObject(instance, extendContext(context, { overlayContainer: instance })) +} + +const updateGeoJsonMarker = (instance, { feature, ...props }, prevProps) => { + const coords = [...feature.geometry.coordinates].reverse() + instance.setLatLng(coords) +} + +const GeoJsonMarker = createLayerComponent(createGeoJsonMarker, updateGeoJsonMarker) +export default GeoJsonMarker diff --git a/adhocracy4/maps_react/static/a4maps_react/Map.jsx b/adhocracy4/maps_react/static/a4maps_react/Map.jsx new file mode 100644 index 000000000..067cea639 --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/Map.jsx @@ -0,0 +1,43 @@ +import React, { useRef, useImperativeHandle } from 'react' +import { MapContainer, GeoJSON } from 'react-leaflet' +import MaplibreGlLayer from './MaplibreGlLayer' +import ZoomControl from './ZoomControl' + +const polygonStyle = { + color: '#0076ae', + weight: 2, + opacity: 1, + fillOpacity: 0.2 +} + +export const Map = React.forwardRef(function Map ( + { attribution, baseUrl, polygon, omtToken, children, ...rest }, ref +) { + const map = useRef() + // forwarding our map ref + useImperativeHandle(ref, () => map.current) + const refCallback = (polygon) => { + if (!map.current || !polygon) { + return + } + map.current.fitBounds(polygon.getBounds()) + map.current.options.minZoom = map.current.getZoom() + map.current.constraints = polygon + } + + return ( + + {polygon && } + + + {children} + + ) +}) diff --git a/adhocracy4/maps_react/static/a4maps_react/MapPopup.jsx b/adhocracy4/maps_react/static/a4maps_react/MapPopup.jsx new file mode 100644 index 000000000..ec783f5de --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/MapPopup.jsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Popup } from 'react-leaflet' + +export const MapPopup = ({ feature, className, children, ...rest }) => { + const _className = 'maps-popups ' + (className ?? '') + return ( + +
+
+ {children} +
+ + ) +} diff --git a/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js b/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js new file mode 100644 index 000000000..d5cdb9a18 --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js @@ -0,0 +1,37 @@ +import L from 'leaflet' +import '@maplibre/maplibre-gl-leaflet' +import { + createElementObject, + createTileLayerComponent, updateGridLayer +} from '@react-leaflet/core' + +const createMaplibreGlLayer = (props, context) => { + const instance = L.maplibreGL({ + style: props.baseUrl, + transformRequest: function (url, resourceType) { + if (resourceType === 'Tile' && url.indexOf('https://') === 0) { + return { + url: url + '?token=' + props.omtToken + } + } + } + }) + + return createElementObject(instance, context) +} + +const updateMaplibreGlLayer = (instance, props, prevProps) => { + updateGridLayer(instance, props, prevProps) + + const { baseUrl, attribution } = props + if (baseUrl != null && baseUrl !== prevProps.baseUrl) { + instance.getMaplibreMap().setStyle(baseUrl) + } + + if (attribution != null && attribution !== prevProps.attribution) { + instance.options.attribution = attribution + } +} + +const MaplibreGlLayer = createTileLayerComponent(createMaplibreGlLayer, updateMaplibreGlLayer) +export default MaplibreGlLayer diff --git a/adhocracy4/maps_react/static/a4maps_react/MarkerClusterLayer.js b/adhocracy4/maps_react/static/a4maps_react/MarkerClusterLayer.js new file mode 100644 index 000000000..3d67cb973 --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/MarkerClusterLayer.js @@ -0,0 +1,19 @@ +import L from 'leaflet' +import 'leaflet.markercluster' +import { + createElementObject, + createLayerComponent, extendContext, updateGridLayer +} from '@react-leaflet/core' + +const createMarkerClusterLayer = (props, context) => { + const instance = L.markerClusterGroup({ showCoverageOnHover: false }) + + return createElementObject(instance, extendContext(context, { layerContainer: instance })) +} + +const updateMarkerClusterLayer = (instance, props, prevProps) => { + updateGridLayer(instance, props, prevProps) +} + +const MarkerClusterLayer = createLayerComponent(createMarkerClusterLayer, updateMarkerClusterLayer) +export default MarkerClusterLayer diff --git a/adhocracy4/maps_react/static/a4maps_react/ZoomControl.js b/adhocracy4/maps_react/static/a4maps_react/ZoomControl.js new file mode 100644 index 000000000..cb308dd71 --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/ZoomControl.js @@ -0,0 +1,48 @@ +import { createControlComponent } from '@react-leaflet/core' +import L from 'leaflet' + +// Create a Leaflet Control +const createLeafletElement = (props) => { + const zoomControl = L.control.zoom(props) + + const updateDisabled = () => { + const map = zoomControl._map + if (!map) { + return + } + + const className = 'leaflet-disabled' + const zoomInBtn = zoomControl._zoomInButton + const zoomOutBtn = zoomControl._zoomOutButton + + // disable button when at min/max zoom + if (map._zoom === map.getMinZoom()) { + L.DomUtil.addClass(zoomOutBtn, className) + } else { + L.DomUtil.removeClass(zoomOutBtn, className) + } + + if (map._zoom === map.getMaxZoom() || (map._zoomSnap && Math.abs(map.getZoom() - map.getMaxZoom()) < map._zoomSnap)) { + L.DomUtil.addClass(zoomInBtn, className) + } else { + L.DomUtil.removeClass(zoomInBtn, className) + } + } + + zoomControl.onAdd = (map) => { + const container = L.Control.Zoom.prototype.onAdd.call(zoomControl, map) + map.on('zoom', updateDisabled) + updateDisabled() + return container + } + + zoomControl.onRemove = (map) => { + map.off('zoom', updateDisabled) + L.Control.Zoom.prototype.onRemove.call(zoomControl, map) + } + + return zoomControl +} + +const ZoomControl = createControlComponent(createLeafletElement) +export default ZoomControl diff --git a/adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html b/adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html new file mode 100644 index 000000000..ae9176399 --- /dev/null +++ b/adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html @@ -0,0 +1,4 @@ +{% load react_maps_tags %} + +{% react_choose_point polygon=polygon point=point name=name %} + diff --git a/adhocracy4/maps_react/templatetags/__init__.py b/adhocracy4/maps_react/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/adhocracy4/maps_react/templatetags/react_maps_tags.py b/adhocracy4/maps_react/templatetags/react_maps_tags.py new file mode 100644 index 000000000..4f5648cc2 --- /dev/null +++ b/adhocracy4/maps_react/templatetags/react_maps_tags.py @@ -0,0 +1,11 @@ +from django import template + +from adhocracy4.maps_react.utils import react_tag_factory + +register = template.Library() + +register.simple_tag( + react_tag_factory("choose-point"), + False, + "react_choose_point", +) diff --git a/adhocracy4/maps_react/utils.py b/adhocracy4/maps_react/utils.py new file mode 100644 index 000000000..07aa9a124 --- /dev/null +++ b/adhocracy4/maps_react/utils.py @@ -0,0 +1,73 @@ +import json + +from django.conf import settings +from django.urls import reverse +from django.utils.html import format_html + + +def get_map_settings(**kwargs): + map_settings = { + "mapboxToken": "", + "omtToken": "", + "attribution": "", + "useVectorMap": 0, + "baseUrl": settings.A4_MAP_BASEURL, + **kwargs, + } + + if hasattr(settings, "A4_MAP_ATTRIBUTION"): + map_settings["attribution"] = settings.A4_MAP_ATTRIBUTION + + if hasattr(settings, "A4_USE_VECTORMAP") and settings.A4_USE_VECTORMAP: + map_settings["useVectorMap"] = 1 + + if hasattr(settings, "A4_MAPBOX_TOKEN"): + map_settings["mapboxToken"] = settings.A4_MAPBOX_TOKEN + + if hasattr(settings, "A4_OPENMAPTILES_TOKEN"): + map_settings["omtToken"] = settings.A4_OPENMAPTILES_TOKEN + + # Filter out the keys that have a value of "" + return {key: val for key, val in map_settings.items() if val != ""} + + +def react_tag_factory(tag_name, api_url_name=None): + """ + :param tag_name: The name of the template tag. + :param api_url_name: The name of the API URL (optional). + :return: A formatted HTML string containing the React tag with required props. + + This method creates a function that generates a React tag with the given name and + attributes. It takes the following parameters: + + If the `api_url_name` parameter is provided, the `module` parameter must also be + provided. Otherwise, a `ValueError` is raised. + + The function generated by this method takes a variable number of keyword arguments, + which are used to populate the attributes of the React tag. If the `module` + parameter is provided and the keyword argument "polygon" is not included, the + function automatically adds the "polygon" attribute using the `polygon` setting + from the `module` object. + """ + + def func(**kwargs): + module = kwargs.pop("module", None) + if module and "polygon" not in kwargs: + kwargs["polygon"] = module.settings_instance.polygon + + attributes = {"map": get_map_settings(**kwargs)} + if api_url_name: + if not module: + raise ValueError("Module must be provided if api_url_name is provided") + attributes["apiUrl"] = reverse( + api_url_name, kwargs={"module_pk": module.pk} + ) + + return format_html( + f'
', + attributes=json.dumps(attributes), + ) + + # set the correct name on the function + func.__name__ = tag_name + return func diff --git a/adhocracy4/maps_react/widgets.py b/adhocracy4/maps_react/widgets.py new file mode 100644 index 000000000..4330c49c7 --- /dev/null +++ b/adhocracy4/maps_react/widgets.py @@ -0,0 +1,33 @@ +from django.contrib.staticfiles import finders +from django.core.exceptions import ImproperlyConfigured +from django.forms.widgets import Widget +from django.template import loader + + +class MapChoosePointWidget(Widget): + def __init__(self, polygon, attrs=None): + self.polygon = polygon + super().__init__(attrs) + + class Media: + js = ("a4maps_choose_point.js",) + + css = {"all": ["a4maps_choose_point.css"]} + + def render(self, name, value, attrs, renderer=None): + if not finders.find("a4maps_choose_point.js"): + raise ImproperlyConfigured( + "Configure your frontend build tool to generate a4maps_choose_point.js." + ) + + context = { + "name": name, + "polygon": self.polygon, + } + + if value != "null" and value: + context["point"] = value + + return loader.render_to_string( + "a4maps_react/map_choose_point_widget.html", context + ) diff --git a/changelog/7704.md b/changelog/7704.md new file mode 100644 index 000000000..be6e6e49e --- /dev/null +++ b/changelog/7704.md @@ -0,0 +1,11 @@ +### Added + +- react components for generating a leaflet map: + - A basic map component that renders a polygon, tilelayer and zoom controls + - MaplibreGL Tilelayer to implement `@maplibre/maplibre-gl-leaflet` + - MarkerClusterLayer to implement `leaflet.markercluster` + - MapPopup to provide basic html wrapping + - AddMarkerControl to allow users to set a marker on a map within a + constraining polygon + - GeoJsonMarker to fetch the coords from GeoJson and render a jsx Marker +- added a template_tag utility to reduce repetition when rendering react maps diff --git a/docs/react_maps.md b/docs/react_maps.md new file mode 100644 index 000000000..e19f64ffe --- /dev/null +++ b/docs/react_maps.md @@ -0,0 +1,250 @@ +# Map Components Documentation + +This document provides an annotated reference to the various custom map +components used in the project. These components leverage the react-leaflet +library, which provides React-friendly abstractions for the popular Leaflet. +js, an open-source library used to create interactive maps. These components +get further customization and extension to cater to the specific +requirements of the project. +They include a variety of features including custom map controls, geometric +layers, markers, pop-up windows, and more. In the following sections, you will +find a brief overview, key functions, and methods for each of these components +which are designed to offer a unique interactivity and to display the data in a +visually appealing and user-friendly manner. + +## Components List + +* **Map**: + Main map displaying component, using polygon and map layers defined + in other components. Also adds zoom functionality. +* **MaplibreGlLayer**: + Map layer component which uses maplibre-gl-leaflet to + render maps using vector tiles from an external source. +* **AddMarkerControl**: + Leaflet Control used for adding a draggable marker to the map + on click events within certain constraints. +* **GeoJsonMarker**: + Represents a GeoJSON marker with a custom icon on the + map, allowing the developer to use JSX for its content. +* **MapPopup**: + A custom styled Popup component wrapping react-leaflet Popup + which appears on the map. Might include an image and text. +* **MarkerClusterLayer**: + Layer component which groups nearby markers into + clusters for easier visualization and understanding. +* **ZoomControl**: + A react-leaflet control component for zooming in and out, + with buttons disabling at the max/min zoom level. + +For more detailed understanding, dive deeper into individual components and +refer to official documentation +of [react-leaflet](https://react-leaflet.js.org/) +and [Leaflet.js](https://leafletjs.com/reference.html). + +## Making non-react leaflet modules into React components + +We are using `@react-leaflet/core` to wrap basic Leaflet elements into a react +wrapper. This allows us to use JSX syntax instead of having to do a complicated +mixture of JSX and JS. +You can read more about this method and +examples [here](https://react-leaflet.js.org/docs/core-architecture/). +Examples of those within a4 will be the `AddMarkerControl`, `MaplibreGlLayer`, +`MarkerClusterLayer`, `ZoomControl`, `GeoJsonMarker`. + +## Map + +This is a Map component which uses the React Leaflet library to create maps. The +map can include polygons and tiles, and supports zoom control. It uses +`react-leaflet`, `MaplibreGlLayer` for tiles rendering, and the GeoJSON feature +to +parse geographical features. + +### Props + +* **attribution**: This prop is passed to MaplibreGlLayer. It allows you to + specify an attribution displayed. +* **baseUrl**: This prop is passed to MaplibreGlLayer. It determines the vector + tiles URL. +* **omtToken**: This prop is passed to MaplibreGlLayer. It's added to the URL + for tiles in case you need to pass a token. +* **polygon**: This prop is used to generate a polygon on the map. It will be in + the center of the map. +* **children**: Add any other Components you will need in here. Markers, Popups, + GeoJSON, [etc](https://react-leaflet.js.org/docs/api-components/). +* ...**rest**: This is a placeholder for all other props, which are passed + directly to the + underlying `react-leaflet` [MapContainer component](https://react-leaflet.js.org/docs/api-map/#mapcontainer). + This means, you can also specify leaflet options in here. + +### Accessing Leaflet Instance + +If you need to access the leaflet instance directly, you can use a reference to +it like so: + +```jsx +const map = useRef(); + +useEffect(() => { + if (map.current) { + // do stuff with map.current + map.current.panTo([50, 20]) + } +}, [map]); + + +``` + +### Building a Map + +To build a map you will currently need to pass a baseUrl for it to have access +to a vector tile server. After that you'll either need to specify a `center` +prop to prevent the map from focusing somewhere in the ocean, or pass a polygon +which automatically gets centered. Now you have a map. +If you need to add objects +like [Markers](https://react-leaflet.js.org/docs/api-components/#marker) +you can easily do that: + +```jsx +import { Marker } from 'react-leaflet/Marker'; + + + + + +``` + +In case you want to build a Marker from GeoJSON you can use the GeoJsonMarker. + +## MaplibreGlLayer + +This is a `MaplibreGlLayer` component which uses `leaflet` +and `maplibre-gl-leaflet` to render a map layer that can display tiles from an +external services. + +### Props + +* **baseUrl**: This is the base URL which will be used to fetch the tiles. +* **attribution**: This should be a string that contains the attribution for the + tile layer. +* **omtToken**: This is an optional token needed to access the tile service at + the baseUrl. + +## AddMarkerControl + +This is a Leaflet Control used for adding a draggable marker to the map on click +events. It prevents the marker from being dragged out of a set constraint area, +which is checked using the `checkPointInsidePolygon` helper function. + +### Props + +* **input**: The input that will receive the GeoJSON position of the newly added + marker. This has to be an HTMLElement. +* **point**: Optional default marker in GeoJSON + +### Defining the limiting polygon + +Currently, the area that can have a marker added to it is defined by the polygon +you pass to the `Map` component: + +```jsx + + {/* <- this polygon is used as limiting area for the AddMarkerControl */} + + +``` + +## GeoJsonMarker + +The `GeoJsonMarker` component is a custom marker that represents a GeoJSON +marker with a custom icon on the map. + +### Props: + +* **feature**: A single GeoJSON feature that the coords will be extracted from + to generate a Marker +* **...rest**: [Any other option](https://leafletjs.com/reference.html#marker) + that you could pass to L.marker + +### makeIcon + +You can find this utility function in `GeoJsonMarker.js`. It creates and returns +a new Leaflet Icon instance with the provided iconUrl, or default icon if no URL +is provided. + +## MapPopup + +This component wraps the react-leaflet Popup component and adds custom styling +and behavior. It provides the same css classes as previous jQuery Maps. + +### Props + +* **feature**: A single GeoJSON feature, if it has an image + in `feature.properties.image` it displays that. +* **className**: Any additional classes you want to pass to the outmost `div`. +* **children**: The content you want to render inside. It will be + within `.maps-popups-popup-text-content`. +* **...rest**: [Any other option](https://react-leaflet.js.org/docs/api-components/#popup) + that you could pass to `Popup` + +## MarkerClusterLayer + +The `MarkerClusterLayer` is a layer component that groups close markers together +into clusters. It uses the `leaflet.markercluster` plugin for Leaflet. + +### Usage + +```jsx + + + + + + + +``` + +## ZoomControl + +`ZoomControl` is a custom react-leaflet control for zooming in and out on a map. +Specifically, this custom ZoomControl disables the zoom in/out buttons at the +min/max zoom level. + +### Props + +* **...rest**: It takes all the props that you could also pass + to [L.control.zoom](https://leafletjs.com/reference.html#control-zoom). + +## Examples for Maps + +### Map with one or more markers. + +If there are more than one marker, it also adds the `ClusterLayer`. + +```jsx +const MapWithMarkers = ({ points, withoutPopup, ...props }) => { + const markers = points.map((feature) => ( + + {!withoutPopup && } + + )) + return ( + + {points.length > 1 ? + {markers} : markers} + + ) +} +``` + +### Map for allowing users to set a point + +This sets the value after each marker update in `valueInputEl` and if there is +a `point` prop present, this will be the initial value. + +```jsx + + + +``` diff --git a/index.js b/index.js index 3edad3729..434236f78 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ export * as commentsAsync from './adhocracy4/comments_async/static/comments_asyn export { default as config } from './adhocracy4/static/config' export { default as follows } from './adhocracy4/follows/static/follows/react_follows' export * as maps from './adhocracy4/maps/static/a4maps/a4maps_common' +export * as mapsReact from './adhocracy4/maps_react/static/a4maps_react/Map' export { default as ratings } from './adhocracy4/ratings/static/ratings/react_ratings' export { default as reports } from './adhocracy4/reports/static/reports/react_reports' export { default as selectDropdown } from './adhocracy4/static/select_dropdown' diff --git a/package.json b/package.json index 54e21dad5..ff4ded580 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ ], "dependencies": { "@popperjs/core": "2.11.8", + "@turf/turf": "^6.5.0", "file-saver": "2.0.5", "js-cookie": "3.0.5", "maplibregl-mapbox-request-transformer": "0.0.2" @@ -24,6 +25,7 @@ "@babel/preset-env": "7.24.0", "@babel/preset-react": "7.23.3", "@maplibre/maplibre-gl-leaflet": "0.0.20", + "@react-leaflet/core": "^2.1.0", "@testing-library/jest-dom": "6.4.2", "@testing-library/react": "14.2.1", "eslint": "8.57.0", @@ -49,6 +51,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-flip-move": "3.0.5", + "react-leaflet": "^4.2.1", "react-markdown": "9.0.1", "react-slick": "0.30.2", "shpjs": "4.0.4" diff --git a/tests/maps_react/__init__.py b/tests/maps_react/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/maps_react/conftest.py b/tests/maps_react/conftest.py new file mode 100644 index 000000000..d94fbb495 --- /dev/null +++ b/tests/maps_react/conftest.py @@ -0,0 +1,5 @@ +from pytest_factoryboy import register + +from adhocracy4.test.factories import maps as map_factories + +register(map_factories.AreaSettingsFactory) diff --git a/tests/maps_react/test_maps_react_utils.py b/tests/maps_react/test_maps_react_utils.py new file mode 100644 index 000000000..e32b0c28a --- /dev/null +++ b/tests/maps_react/test_maps_react_utils.py @@ -0,0 +1,58 @@ +import json +import re +from html import unescape + +import pytest +from django.conf import settings +from django.test.utils import override_settings +from django.utils.html import escape + +from adhocracy4.maps_react.utils import get_map_settings +from adhocracy4.test import helpers + + +@override_settings( + A4_MAP_ATTRIBUTION="attribution", + A4_MAP_BASEURL="https://{s}.tile.openstreetmap.org/", +) +def test_get_map_settings_with_settings(): + result = get_map_settings(test_setting="value", empty="") + assert result["attribution"] == "attribution" + assert result["test_setting"] == "value" + assert result["baseUrl"] == "https://{s}.tile.openstreetmap.org/" + assert "empty" not in result + + +@override_settings(A4_MAP_BASEURL="https://{s}.tile.openstreetmap.org/") +@override_settings(A4_MAP_ATTRIBUTION='attribution') +@pytest.mark.django_db +def test_map_display_point(area_settings): + point = {"test": [1, 2]} + + template = "{% load maps_tags %}{% map_display_point point polygon %}" + context = {"point": point, "polygon": area_settings.polygon} + + expected = ( + r"^
$" + ).format( + baseurl=escape(settings.A4_MAP_BASEURL), + attribution=escape(settings.A4_MAP_ATTRIBUTION), + ) + + match = re.match(expected, helpers.render_template(template, context)) + assert match + _point = match.group("point") + assert json.loads(unescape(_point)) == point + _polygon = match.group("polygon") + assert json.loads(unescape(_polygon)) == area_settings.polygon From bd18f4f5bbed8dfc4dd9c9b8f319bbc9649d5bf2 Mon Sep 17 00:00:00 2001 From: Philipp Veller Date: Wed, 13 Dec 2023 13:56:22 +0100 Subject: [PATCH 2/4] react_maps: write js tests for react leaflet map --- .../static/a4maps_react/AddMarkerControl.js | 8 +-- .../maps_react/static/a4maps_react/Map.jsx | 12 ++-- .../static/a4maps_react/ZoomControl.js | 48 -------------- .../__tests__/AddMarkerControl.jest.js | 60 +++++++++++++++++ .../a4maps_react/__tests__/Map.jest.jsx | 66 +++++++++++++++++++ .../a4maps_react/map_choose_point_widget.html | 2 +- .../templatetags/react_maps_tags.py | 21 ++++-- adhocracy4/maps_react/utils.py | 46 ------------- changelog/7704.md | 2 +- docs/react_maps.md | 15 +---- jest.config.js | 8 ++- setupTests.js | 3 + 12 files changed, 164 insertions(+), 127 deletions(-) delete mode 100644 adhocracy4/maps_react/static/a4maps_react/ZoomControl.js create mode 100644 adhocracy4/maps_react/static/a4maps_react/__tests__/AddMarkerControl.jest.js create mode 100644 adhocracy4/maps_react/static/a4maps_react/__tests__/Map.jest.jsx create mode 100644 setupTests.js diff --git a/adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js b/adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js index 498ca51fe..24e0a30e1 100644 --- a/adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js +++ b/adhocracy4/maps_react/static/a4maps_react/AddMarkerControl.js @@ -19,7 +19,7 @@ export function checkPointInsidePolygon (marker, polygons) { const markerProps = { icon: makeIcon(), draggable: true } -class AddMarkerControlClass extends L.Control { +export class AddMarkerControlClass extends L.Control { constructor ({ input, point }) { super() this.marker = null @@ -36,7 +36,7 @@ class AddMarkerControlClass extends L.Control { } updateMarker (latlng) { - const isInsideConstraints = checkPointInsidePolygon(latlng, this.map.constraints) + const isInsideConstraints = checkPointInsidePolygon(latlng, this.map.markerConstraints) if (isInsideConstraints) { this.oldCoords = latlng if (this.marker) { @@ -51,11 +51,11 @@ class AddMarkerControlClass extends L.Control { onDragend (e) { const targetPosition = e.target.getLatLng() - const isInsideConstraints = checkPointInsidePolygon(targetPosition, this.map.constraints) + const isInsideConstraints = checkPointInsidePolygon(targetPosition, this.map.markerConstraints) if (!isInsideConstraints) { e.target.setLatLng(this.oldCoords) } else { - this.oldCoords = targetPosition + this.updateMarker(targetPosition) } } diff --git a/adhocracy4/maps_react/static/a4maps_react/Map.jsx b/adhocracy4/maps_react/static/a4maps_react/Map.jsx index 067cea639..5a6092826 100644 --- a/adhocracy4/maps_react/static/a4maps_react/Map.jsx +++ b/adhocracy4/maps_react/static/a4maps_react/Map.jsx @@ -1,7 +1,6 @@ import React, { useRef, useImperativeHandle } from 'react' import { MapContainer, GeoJSON } from 'react-leaflet' import MaplibreGlLayer from './MaplibreGlLayer' -import ZoomControl from './ZoomControl' const polygonStyle = { color: '#0076ae', @@ -10,7 +9,7 @@ const polygonStyle = { fillOpacity: 0.2 } -export const Map = React.forwardRef(function Map ( +const Map = React.forwardRef(function Map ( { attribution, baseUrl, polygon, omtToken, children, ...rest }, ref ) { const map = useRef() @@ -21,8 +20,9 @@ export const Map = React.forwardRef(function Map ( return } map.current.fitBounds(polygon.getBounds()) - map.current.options.minZoom = map.current.getZoom() - map.current.constraints = polygon + map.current.setMinZoom(map.current.getZoom()) + // used in AddMarkerControl to specify where markers can be placed + map.current.markerConstraints = polygon } return ( @@ -30,14 +30,14 @@ export const Map = React.forwardRef(function Map ( style={{ minHeight: 300 }} zoom={13} maxZoom={18} - zoomControl={false} {...rest} ref={map} > {polygon && } - {children} ) }) + +export default Map diff --git a/adhocracy4/maps_react/static/a4maps_react/ZoomControl.js b/adhocracy4/maps_react/static/a4maps_react/ZoomControl.js deleted file mode 100644 index cb308dd71..000000000 --- a/adhocracy4/maps_react/static/a4maps_react/ZoomControl.js +++ /dev/null @@ -1,48 +0,0 @@ -import { createControlComponent } from '@react-leaflet/core' -import L from 'leaflet' - -// Create a Leaflet Control -const createLeafletElement = (props) => { - const zoomControl = L.control.zoom(props) - - const updateDisabled = () => { - const map = zoomControl._map - if (!map) { - return - } - - const className = 'leaflet-disabled' - const zoomInBtn = zoomControl._zoomInButton - const zoomOutBtn = zoomControl._zoomOutButton - - // disable button when at min/max zoom - if (map._zoom === map.getMinZoom()) { - L.DomUtil.addClass(zoomOutBtn, className) - } else { - L.DomUtil.removeClass(zoomOutBtn, className) - } - - if (map._zoom === map.getMaxZoom() || (map._zoomSnap && Math.abs(map.getZoom() - map.getMaxZoom()) < map._zoomSnap)) { - L.DomUtil.addClass(zoomInBtn, className) - } else { - L.DomUtil.removeClass(zoomInBtn, className) - } - } - - zoomControl.onAdd = (map) => { - const container = L.Control.Zoom.prototype.onAdd.call(zoomControl, map) - map.on('zoom', updateDisabled) - updateDisabled() - return container - } - - zoomControl.onRemove = (map) => { - map.off('zoom', updateDisabled) - L.Control.Zoom.prototype.onRemove.call(zoomControl, map) - } - - return zoomControl -} - -const ZoomControl = createControlComponent(createLeafletElement) -export default ZoomControl diff --git a/adhocracy4/maps_react/static/a4maps_react/__tests__/AddMarkerControl.jest.js b/adhocracy4/maps_react/static/a4maps_react/__tests__/AddMarkerControl.jest.js new file mode 100644 index 000000000..1a3400263 --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/__tests__/AddMarkerControl.jest.js @@ -0,0 +1,60 @@ +import L from 'leaflet' +import { AddMarkerControlClass } from '../AddMarkerControl' +import { polygonData } from './Map.jest' +import { jest } from '@jest/globals' + +describe('AddMarkerControlClass', () => { + const polygonGeoJSON = L.geoJSON(polygonData) + const map = { on: jest.fn(), off: jest.fn(), addLayer: jest.fn(), markerConstraints: polygonGeoJSON } + const point = JSON.stringify({ + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [5, 5] + } + }) + + it('sets a marker', () => { + const input = document.createElement('input') + const instance = new AddMarkerControlClass({ input }) + instance.map = map + + const latlng = { lat: 10, lng: 5 } + + expect(instance.marker).toBe(null) + instance.updateMarker(latlng) + expect(instance.marker).toBeDefined() + expect(input.value).toEqual(expect.stringContaining('5,10')) + instance.updateMarker({ lat: 2, lng: 5 }) + expect(input.value).toEqual(expect.stringContaining('5,2')) + }) + + it('does not set a marker when outside', () => { + const input = document.createElement('input') + const instance = new AddMarkerControlClass({ input }) + instance.map = map + const latlng = { lat: 15, lng: 15 } + expect(instance.marker).toBe(null) + instance.updateMarker(latlng) + expect(instance.marker).toBe(null) + expect(input.value).toEqual('') + }) + + it('updates on drag', () => { + const input = document.createElement('input') + const instance = new AddMarkerControlClass({ input, point }) + instance.map = map + expect(instance.oldCoords).toStrictEqual([5, 5]) + const newCoords = { lat: 10, lng: 10 } + + const e = { target: { getLatLng: () => newCoords, setLatLng: jest.fn() } } + instance.onDragend(e) + expect(instance.oldCoords).toStrictEqual(newCoords) + + const e2 = { target: { getLatLng: () => ({ lat: 15, lng: 15 }), setLatLng: jest.fn() } } + instance.onDragend(e2) + expect(e2.target.setLatLng).toHaveBeenCalledWith(newCoords) + expect(instance.oldCoords).toStrictEqual(newCoords) + }) +}) diff --git a/adhocracy4/maps_react/static/a4maps_react/__tests__/Map.jest.jsx b/adhocracy4/maps_react/static/a4maps_react/__tests__/Map.jest.jsx new file mode 100644 index 000000000..278f07b1d --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/__tests__/Map.jest.jsx @@ -0,0 +1,66 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import Map from '../Map' + +export const polygonData = { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10] + ] + ] + } +} + +jest.mock('react-leaflet', () => { + const ActualReactLeaflet = jest.requireActual('react-leaflet') + const React = require('react') + + const MapContainer = React.forwardRef((props, ref) => ( +
+ +
+ )) + MapContainer.displayName = 'MapContainer' + + const GeoJSON = React.forwardRef((props, ref) => ( +
+ )) + GeoJSON.displayName = 'GeoJSON' + + return { + __esModule: true, + ...ActualReactLeaflet, + GeoJSON, + MapContainer + } +}) + +describe('Map component tests', () => { + test('component renders', () => { + render() + const mapNode = screen.getByTestId('map') + + expect(mapNode).toBeTruthy() + }) + + test('renders map with GeoJSON when polygon prop is provided', () => { + render() + const geoJsonNode = screen.getByTestId('geojson') + + expect(geoJsonNode).toBeTruthy() + }) + + test('does not render GeoJSON when polygon prop is not provided', () => { + render() + const geoJsonNode = screen.queryByTestId('geojson') + + expect(geoJsonNode).toBeFalsy() + }) +}) diff --git a/adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html b/adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html index ae9176399..b971118ad 100644 --- a/adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html +++ b/adhocracy4/maps_react/templates/a4maps_react/map_choose_point_widget.html @@ -1,4 +1,4 @@ {% load react_maps_tags %} -{% react_choose_point polygon=polygon point=point name=name %} +{% react_choose_point polygon point name %} diff --git a/adhocracy4/maps_react/templatetags/react_maps_tags.py b/adhocracy4/maps_react/templatetags/react_maps_tags.py index 4f5648cc2..c8a4f84b6 100644 --- a/adhocracy4/maps_react/templatetags/react_maps_tags.py +++ b/adhocracy4/maps_react/templatetags/react_maps_tags.py @@ -1,11 +1,20 @@ +import json + from django import template +from django.utils.html import format_html -from adhocracy4.maps_react.utils import react_tag_factory +from adhocracy4.maps_react.utils import get_map_settings register = template.Library() -register.simple_tag( - react_tag_factory("choose-point"), - False, - "react_choose_point", -) + +@register.simple_tag() +def react_choose_point(polygon, point, name): + attributes = { + "map": get_map_settings(polygon=polygon, point=point, name=name), + } + + return format_html( + '
', + attributes=json.dumps(attributes), + ) diff --git a/adhocracy4/maps_react/utils.py b/adhocracy4/maps_react/utils.py index 07aa9a124..0c9e508b8 100644 --- a/adhocracy4/maps_react/utils.py +++ b/adhocracy4/maps_react/utils.py @@ -1,8 +1,4 @@ -import json - from django.conf import settings -from django.urls import reverse -from django.utils.html import format_html def get_map_settings(**kwargs): @@ -29,45 +25,3 @@ def get_map_settings(**kwargs): # Filter out the keys that have a value of "" return {key: val for key, val in map_settings.items() if val != ""} - - -def react_tag_factory(tag_name, api_url_name=None): - """ - :param tag_name: The name of the template tag. - :param api_url_name: The name of the API URL (optional). - :return: A formatted HTML string containing the React tag with required props. - - This method creates a function that generates a React tag with the given name and - attributes. It takes the following parameters: - - If the `api_url_name` parameter is provided, the `module` parameter must also be - provided. Otherwise, a `ValueError` is raised. - - The function generated by this method takes a variable number of keyword arguments, - which are used to populate the attributes of the React tag. If the `module` - parameter is provided and the keyword argument "polygon" is not included, the - function automatically adds the "polygon" attribute using the `polygon` setting - from the `module` object. - """ - - def func(**kwargs): - module = kwargs.pop("module", None) - if module and "polygon" not in kwargs: - kwargs["polygon"] = module.settings_instance.polygon - - attributes = {"map": get_map_settings(**kwargs)} - if api_url_name: - if not module: - raise ValueError("Module must be provided if api_url_name is provided") - attributes["apiUrl"] = reverse( - api_url_name, kwargs={"module_pk": module.pk} - ) - - return format_html( - f'
', - attributes=json.dumps(attributes), - ) - - # set the correct name on the function - func.__name__ = tag_name - return func diff --git a/changelog/7704.md b/changelog/7704.md index be6e6e49e..01e236825 100644 --- a/changelog/7704.md +++ b/changelog/7704.md @@ -8,4 +8,4 @@ - AddMarkerControl to allow users to set a marker on a map within a constraining polygon - GeoJsonMarker to fetch the coords from GeoJson and render a jsx Marker -- added a template_tag utility to reduce repetition when rendering react maps +- added an utility in python to easily get all relevant map settings diff --git a/docs/react_maps.md b/docs/react_maps.md index e19f64ffe..58e402b5a 100644 --- a/docs/react_maps.md +++ b/docs/react_maps.md @@ -32,9 +32,6 @@ visually appealing and user-friendly manner. * **MarkerClusterLayer**: Layer component which groups nearby markers into clusters for easier visualization and understanding. -* **ZoomControl**: - A react-leaflet control component for zooming in and out, - with buttons disabling at the max/min zoom level. For more detailed understanding, dive deeper into individual components and refer to official documentation @@ -49,7 +46,7 @@ mixture of JSX and JS. You can read more about this method and examples [here](https://react-leaflet.js.org/docs/core-architecture/). Examples of those within a4 will be the `AddMarkerControl`, `MaplibreGlLayer`, -`MarkerClusterLayer`, `ZoomControl`, `GeoJsonMarker`. +`MarkerClusterLayer`, `GeoJsonMarker`. ## Map @@ -203,16 +200,6 @@ into clusters. It uses the `leaflet.markercluster` plugin for Leaflet.
``` -## ZoomControl - -`ZoomControl` is a custom react-leaflet control for zooming in and out on a map. -Specifically, this custom ZoomControl disables the zoom in/out buttons at the -min/max zoom level. - -### Props - -* **...rest**: It takes all the props that you could also pass - to [L.control.zoom](https://leafletjs.com/reference.html#control-zoom). ## Examples for Maps diff --git a/jest.config.js b/jest.config.js index ca88e87bd..aa923a902 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,7 +29,13 @@ const config = { ], transform: { '^.+\\.[t|j]sx?$': 'babel-jest' - } + }, + transformIgnorePatterns: [ + 'node_modules/(?!(@?react-leaflet)/)' + ], + setupFiles: [ + '/setupTests.js' + ] } module.exports = config diff --git a/setupTests.js b/setupTests.js new file mode 100644 index 000000000..47fc61ff2 --- /dev/null +++ b/setupTests.js @@ -0,0 +1,3 @@ +if (typeof window.URL.createObjectURL === 'undefined') { + window.URL.createObjectURL = () => {} +} From c127c9080ec60c21de8b4b08b99904690a7525e4 Mon Sep 17 00:00:00 2001 From: Philipp Veller Date: Wed, 14 Feb 2024 09:19:36 +0100 Subject: [PATCH 3/4] react_maps: write python tests for react leaflet map --- tests/maps_react/test_templatetags.py | 60 +++++++++++++++++++++++++++ tests/maps_react/test_widget.py | 48 +++++++++++++++++++++ tests/project/settings.py | 1 + 3 files changed, 109 insertions(+) create mode 100644 tests/maps_react/test_templatetags.py create mode 100644 tests/maps_react/test_widget.py diff --git a/tests/maps_react/test_templatetags.py b/tests/maps_react/test_templatetags.py new file mode 100644 index 000000000..870ea1861 --- /dev/null +++ b/tests/maps_react/test_templatetags.py @@ -0,0 +1,60 @@ +import json + +import pytest +from django.test import override_settings +from django.utils.html import format_html + +from adhocracy4.test.helpers import render_template + + +@override_settings(A4_MAP_BASEURL="https://{s}.tile.openstreetmap.org/") +@override_settings(A4_MAP_ATTRIBUTION='attribution') +@pytest.mark.django_db +def test_react_maps_without_point(rf, area_settings): + request = rf.get("/") + attrs = { + "map": { + "attribution": 'attribution', + "useVectorMap": 0, + "baseUrl": "https://{s}.tile.openstreetmap.org/", + "polygon": area_settings.polygon, + "name": "test", + } + } + expected = format_html( + '
', + attributes=json.dumps(attrs), + ) + + context = {"request": request, "polygon": area_settings.polygon} + template = '{% load react_maps_tags %}{% react_choose_point polygon=polygon point=point name="test" %}' + result = render_template(template, context) + assert result == expected + + +@override_settings(A4_MAP_BASEURL="https://{s}.tile.openstreetmap.org/") +@override_settings(A4_MAP_ATTRIBUTION='attribution') +@pytest.mark.django_db +def test_react_maps_with_point(rf, area_settings): + request = rf.get("/") + point = {"test": [1, 2]} + + attrs = { + "map": { + "attribution": 'attribution', + "useVectorMap": 0, + "baseUrl": "https://{s}.tile.openstreetmap.org/", + "polygon": area_settings.polygon, + "point": point, + "name": "test", + } + } + expected = format_html( + '
', + attributes=json.dumps(attrs), + ) + + context = {"request": request, "polygon": area_settings.polygon, "point": point} + template = '{% load react_maps_tags %}{% react_choose_point polygon=polygon point=point name="test" %}' + result = render_template(template, context) + assert result == expected diff --git a/tests/maps_react/test_widget.py b/tests/maps_react/test_widget.py new file mode 100644 index 000000000..ce90963b6 --- /dev/null +++ b/tests/maps_react/test_widget.py @@ -0,0 +1,48 @@ +import json +from unittest.mock import patch + +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.test import override_settings +from django.utils.html import format_html + +from adhocracy4.maps_react import widgets + + +@override_settings(A4_MAP_BASEURL="https://{s}.tile.openstreetmap.org/") +@override_settings(A4_MAP_ATTRIBUTION='attribution') +@pytest.mark.django_db +def test_choose_point_widget_throws(area_settings): + widget = widgets.MapChoosePointWidget(area_settings.polygon) + with pytest.raises(ImproperlyConfigured): + widget.render("test_filter", "test_val1", attrs={"id": "test_id"}) + + +@override_settings(A4_MAP_BASEURL="https://{s}.tile.openstreetmap.org/") +@override_settings(A4_MAP_ATTRIBUTION='attribution') +@pytest.mark.django_db +def test_choose_point_widget(area_settings): + widget = widgets.MapChoosePointWidget(area_settings.polygon) + with patch("django.contrib.staticfiles.finders.find") as mock_find: + mock_find.return_value = "path/to/a4maps_choose_point.js" + html = widget.render("test_filter", "test_val1", attrs={"id": "test_id"}) + attrs = { + "map": { + "attribution": 'attribution', + "useVectorMap": 0, + "baseUrl": "https://{s}.tile.openstreetmap.org/", + "polygon": area_settings.polygon, + "point": "test_val1", + "name": "test_filter", + } + } + expected = format_html( + """ + +
+ +""", + attributes=json.dumps(attrs), + ) + assert widget.polygon == area_settings.polygon + assert html == expected diff --git a/tests/project/settings.py b/tests/project/settings.py index 50835698f..83de04dc5 100644 --- a/tests/project/settings.py +++ b/tests/project/settings.py @@ -35,6 +35,7 @@ "adhocracy4.comments", "adhocracy4.comments_async", "adhocracy4.maps", + "adhocracy4.maps_react", "adhocracy4.actions", "adhocracy4.follows", "adhocracy4.filters", From 3f8ddd83fc6f2b682d57bedf745b73bff82ed672 Mon Sep 17 00:00:00 2001 From: Julian Dehm Date: Mon, 11 Mar 2024 16:15:28 +0100 Subject: [PATCH 4/4] maps_react: add missing attribution to MaplibreGlLayer --- adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js b/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js index d5cdb9a18..aed15cd3c 100644 --- a/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js +++ b/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js @@ -7,6 +7,7 @@ import { const createMaplibreGlLayer = (props, context) => { const instance = L.maplibreGL({ + attribution: props.attribution, style: props.baseUrl, transformRequest: function (url, resourceType) { if (resourceType === 'Tile' && url.indexOf('https://') === 0) {