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..24e0a30e1 --- /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 } + +export 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.markerConstraints) + 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.markerConstraints) + if (!isInsideConstraints) { + e.target.setLatLng(this.oldCoords) + } else { + this.updateMarker(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..5a6092826 --- /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' + +const polygonStyle = { + color: '#0076ae', + weight: 2, + opacity: 1, + fillOpacity: 0.2 +} + +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.setMinZoom(map.current.getZoom()) + // used in AddMarkerControl to specify where markers can be placed + map.current.markerConstraints = polygon + } + + return ( + + {polygon && } + + {children} + + ) +}) + +export default Map 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..aed15cd3c --- /dev/null +++ b/adhocracy4/maps_react/static/a4maps_react/MaplibreGlLayer.js @@ -0,0 +1,38 @@ +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({ + attribution: props.attribution, + 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/__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 new file mode 100644 index 000000000..b971118ad --- /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 point 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..c8a4f84b6 --- /dev/null +++ b/adhocracy4/maps_react/templatetags/react_maps_tags.py @@ -0,0 +1,20 @@ +import json + +from django import template +from django.utils.html import format_html + +from adhocracy4.maps_react.utils import get_map_settings + +register = template.Library() + + +@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 new file mode 100644 index 000000000..0c9e508b8 --- /dev/null +++ b/adhocracy4/maps_react/utils.py @@ -0,0 +1,27 @@ +from django.conf import settings + + +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 != ""} 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..01e236825 --- /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 an utility in python to easily get all relevant map settings diff --git a/docs/react_maps.md b/docs/react_maps.md new file mode 100644 index 000000000..58e402b5a --- /dev/null +++ b/docs/react_maps.md @@ -0,0 +1,237 @@ +# 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. + +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`, `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 + + + + + + + +``` + + +## 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/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/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/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 = () => {} +} 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 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",