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/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 = () => {} +}