From 7ed90e19be3b538a6c80e77402cbc8a26e34b927 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Fri, 19 Aug 2022 13:26:29 -0500 Subject: [PATCH] feat: Draw Landscape Boundary (Complete Feature) (#475) * chore: Base branch * feat: Reused landscape creation boundary options in update boundary (#474) Co-authored-by: Paul Schreiber * feat: Added draw landscape option (#480) Co-authored-by: Paul Schreiber --- .github/workflows/build.yml | 4 +- .github/workflows/check-commits.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 +- .github/workflows/localization.yml | 4 +- src/common/components/ExternalLink.js | 26 + src/gis/components/Map.css | 14 + src/gis/components/Map.js | 448 +++++++++++++----- .../components/GroupMembershipCard.js | 8 +- .../components/LandscapeBoundariesUpdate.js | 56 +-- .../LandscapeBoundariesUpdate.test.js | 58 ++- .../components/LandscapeForm/BoundaryStep.css | 28 ++ .../components/LandscapeForm/BoundaryStep.js | 294 +++++++++--- .../components/LandscapeForm/map-icons.svg | 156 ++++++ ...aries.js => LandscapeGeoJsonBoundaries.js} | 20 +- src/landscape/components/LandscapeList.js | 5 +- .../components/LandscapeList.test.js | 2 +- src/landscape/components/LandscapeListMap.css | 8 +- src/landscape/components/LandscapeListMap.js | 2 +- src/landscape/components/LandscapeMap.js | 22 +- src/landscape/components/LandscapeNew.js | 4 + .../LandscapeNewFormDrawPolygon.test.js | 134 ++++++ .../LandscapeNewFormGeoJson.test.js | 12 +- .../components/LandscapeNewFormPin.test.js | 30 +- src/landscape/components/LandscapeView.js | 9 +- src/landscape/landscapeService.js | 50 +- src/landscape/landscapeUtils.js | 24 +- src/landscape/landscapeUtils.test.js | 9 +- src/localization/locales/en-US.json | 93 +++- src/localization/locales/es-ES.json | 91 +++- src/monitoring/analytics.js | 1 - src/sharedData/components/SharedDataCard.js | 6 +- 31 files changed, 1272 insertions(+), 352 deletions(-) create mode 100644 src/common/components/ExternalLink.js create mode 100644 src/landscape/components/LandscapeForm/BoundaryStep.css create mode 100644 src/landscape/components/LandscapeForm/map-icons.svg rename src/landscape/components/{LandscapeBoundaries.js => LandscapeGeoJsonBoundaries.js} (87%) create mode 100644 src/landscape/components/LandscapeNewFormDrawPolygon.test.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f15d6749d..ffb6b2405 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,11 +4,11 @@ on: push: branches: - main - - feature/explore-landscapes + - feature/draw-landscape-boundary pull_request: branches: - main - - feature/explore-landscapes + - feature/draw-landscape-boundary jobs: lint: diff --git a/.github/workflows/check-commits.yml b/.github/workflows/check-commits.yml index 6752ef9fd..09f81591d 100644 --- a/.github/workflows/check-commits.yml +++ b/.github/workflows/check-commits.yml @@ -4,7 +4,7 @@ on: pull_request: branches: - main - - feature/explore-landscapes + - feature/draw-landscape-boundary types: - opened - edited diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f0d33c241..702821eb4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ main, feature/explore-landscapes ] + branches: [ main, feature/draw-landscape-boundary ] pull_request: # The branches below must be a subset of the branches above - branches: [ main, feature/explore-landscapes ] + branches: [ main, feature/draw-landscape-boundary ] schedule: - cron: '34 6 * * 4' diff --git a/.github/workflows/localization.yml b/.github/workflows/localization.yml index e4b38d4de..d6adc80ed 100644 --- a/.github/workflows/localization.yml +++ b/.github/workflows/localization.yml @@ -4,11 +4,11 @@ on: push: branches: - main - - feature/explore-landscapes + - feature/draw-landscape-boundary pull_request: branches: - main - - feature/explore-landscapes + - feature/draw-landscape-boundary jobs: missing-keys: diff --git a/src/common/components/ExternalLink.js b/src/common/components/ExternalLink.js new file mode 100644 index 000000000..9d982d239 --- /dev/null +++ b/src/common/components/ExternalLink.js @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Link } from '@mui/material'; + +import { useAnalytics } from 'monitoring/analytics'; + +// Link for external resources. It handles opening it on a new +// tab and tracking the analytics event. +// This is neede because of this plausible issue: +// https://github.com/plausible/plausible-tracker/issues/12 +const ExternalLink = ({ href, children }) => { + const { trackEvent } = useAnalytics(); + const onClick = event => { + window.open(href, '_blank', 'noopener,noreferrer'); + trackEvent('Outbound Link: Click', { props: { url: href } }); + event.preventDefault(); + }; + + return ( + + {children} + + ); +}; + +export default ExternalLink; diff --git a/src/gis/components/Map.css b/src/gis/components/Map.css index 85dcc9a90..237b8350a 100644 --- a/src/gis/components/Map.css +++ b/src/gis/components/Map.css @@ -33,3 +33,17 @@ width: 65%; } } + +.leaflet-marker-icon.leaflet-interactive { + background: url("data:image/svg+xml;utf8,") + no-repeat; + border: none; +} + +.leaflet-draw-actions a { + color: #fff; +} + +.leaflet-disabled { + opacity: 0.6; +} diff --git a/src/gis/components/Map.js b/src/gis/components/Map.js index f9bd27794..02067a59b 100644 --- a/src/gis/components/Map.js +++ b/src/gis/components/Map.js @@ -1,18 +1,16 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import L from 'leaflet'; import { GeoSearchControl, OpenStreetMapProvider } from 'leaflet-geosearch'; import _ from 'lodash/fp'; import { useTranslation } from 'react-i18next'; -import { - GeoJSON, - MapContainer, - Marker, - TileLayer, - ZoomControl, - useMap, -} from 'react-leaflet'; -import { v4 as uuidv4 } from 'uuid'; +import { MapContainer, ZoomControl, useMap } from 'react-leaflet'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -32,17 +30,102 @@ L.Icon.Default.mergeOptions({ shadowUrl: require('leaflet/dist/images/marker-shadow.png'), }); -const LeafletDraw = props => { +const LAYER_OSM = L.tileLayer( + 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', + { + attribution: + 'Data © OpenStreetMap contributors Tiles © HOT', + } +); +const LAYER_ESRI = L.tileLayer( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + { + attribution: + 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community', + } +); + +const MapContext = React.createContext(); + +function geojsonToLayer(geojson, layer, options = {}) { + layer.clearLayers(); + L.geoJson(geojson, options).eachLayer(l => l.addTo(layer)); +} + +function layerToGeoJSON(layers) { + const features = layers + .filter(layer => layer.toGeoJSON) + .map(layer => layer.toGeoJSON()); + return { + type: 'FeatureCollection', + features, + }; +} + +function boundsToGeoJsonBbox(bounds) { + const southWest = bounds.getSouthWest(); + const northEast = bounds.getNorthEast(); + return [southWest.lng, southWest.lat, northEast.lng, northEast.lat]; +} + +const LeafletDraw = () => { + const { t } = useTranslation(); const map = useMap(); - const { setPinLocation } = props; const isSmall = useMediaQuery(theme.breakpoints.down('xs')); + const [isEditing, setIsEditing] = useState(false); + const { onLayersUpdate, drawOptions, featureGroup, onBoundsUpdate } = + useContext(MapContext); + const { onLayerChange, onEditStart, onEditStop } = drawOptions; + + // Localized strings + L.drawLocal = _.defaultsDeep( + L.drawLocal, + t('gis.map_draw', { returnObjects: true }) + ); + + const polygonEnabled = useMemo( + () => !!drawOptions.polygon, + [drawOptions.polygon] + ); + const markerEnabled = useMemo( + () => !!drawOptions.marker, + [drawOptions.marker] + ); + const editEnabled = useMemo(() => polygonEnabled, [polygonEnabled]); + useEffect(() => { + if (!featureGroup) { + return; + } const options = { position: isSmall ? 'topright' : 'topleft', + ...(editEnabled + ? { + edit: { + featureGroup, + poly: { + allowIntersection: false, + icon: new L.DivIcon({ + iconSize: new L.Point(14, 14), + className: 'leaflet-editing-icon', + }), + }, + }, + } + : {}), draw: { + marker: markerEnabled, + polygon: polygonEnabled + ? { + allowIntersection: false, + showArea: true, + icon: new L.DivIcon({ + iconSize: new L.Point(14, 14), + }), + } + : false, polyline: false, - polygon: false, circle: false, rectangle: false, circlemarker: false, @@ -50,23 +133,93 @@ const LeafletDraw = props => { }; const drawControl = new L.Control.Draw(options); map.addControl(drawControl); + return () => map.removeControl(drawControl); + }, [editEnabled, isSmall, map, featureGroup, markerEnabled, polygonEnabled]); - map.on(L.Draw.Event.CREATED, event => { + useEffect(() => { + const onDrawCreatedListener = event => { const { layerType } = event; if (layerType === 'marker') { - const location = event.layer.getLatLng(); - setPinLocation({ lat: location.lat, lng: location.lng }); + onLayersUpdate(() => ({ + layers: [event.layer], + })); } - }); - - return () => map.removeControl(drawControl); - }, [map, setPinLocation, isSmall]); + if (layerType === 'polygon') { + onLayersUpdate(featureGroup => { + const newLayers = L.featureGroup([ + ...featureGroup.getLayers(), + event.layer, + ]); + return { + layers: newLayers.getLayers(), + bbox: boundsToGeoJsonBbox(newLayers.getBounds()), + }; + }); + } + onLayerChange?.(); + }; + const onDrawDeletedListener = () => { + onLayersUpdate(featureGroup => ({ + layers: featureGroup.getLayers(), + ...(featureGroup.getBounds().isValid() + ? { + bbox: boundsToGeoJsonBbox(featureGroup.getBounds()), + } + : {}), + })); + }; + const onEditStartListener = () => { + setIsEditing(true); + onEditStart?.(); + }; + const onEditStopListener = () => { + setIsEditing(false); + onEditStop?.(); + }; + const onEditedListener = () => { + onLayersUpdate(featureGroup => ({ + layers: featureGroup.getLayers(), + bbox: boundsToGeoJsonBbox(featureGroup.getBounds()), + })); + onLayerChange?.(); + }; + const onMoveListener = () => { + if (isEditing || !map.getBounds().isValid()) { + return; + } + const bbox = boundsToGeoJsonBbox(map.getBounds()); + onBoundsUpdate(bbox); + }; + map.on(L.Draw.Event.CREATED, onDrawCreatedListener); + map.on(L.Draw.Event.DELETED, onDrawDeletedListener); + map.on(L.Draw.Event.EDITSTART, onEditStartListener); + map.on(L.Draw.Event.EDITSTOP, onEditStopListener); + map.on(L.Draw.Event.EDITED, onEditedListener); + map.on('moveend', onMoveListener); + + return () => { + map.off(L.Draw.Event.CREATED, onDrawCreatedListener); + map.off(L.Draw.Event.DELETED, onDrawDeletedListener); + map.off(L.Draw.Event.EDITSTART, onEditStartListener); + map.off(L.Draw.Event.EDITSTOP, onEditStopListener); + map.off(L.Draw.Event.EDITED, onEditedListener); + map.off('moveend', onMoveListener); + }; + }, [ + map, + onEditStart, + onEditStop, + onLayerChange, + onLayersUpdate, + onBoundsUpdate, + isEditing, + ]); return null; }; -const LeafletSearch = props => { +const LeafletSearch = () => { const map = useMap(); - const { setBoundingBox, setPinLocation } = props; + const { onLayersUpdate } = useContext(MapContext); const { t } = useTranslation(); useEffect(() => { @@ -77,7 +230,7 @@ const LeafletSearch = props => { style: 'bar', showMarker: false, autoClose: true, - searchLabel: t('common.map_search_placeholder'), + searchLabel: t('gis.map_search_placeholder'), }); const currentOnAdd = searchControl.onAdd; searchControl.onAdd = param => { @@ -95,153 +248,198 @@ const LeafletSearch = props => { const getPinData = event => { if (event?.location?.lat) { - setPinLocation({ - lat: event.location.lat, - lng: event.location.lng, - }); + onLayersUpdate(() => ({ + layers: [L.marker([event.location.lat, event.location.lng])], + })); } if (event?.location?.x) { - setPinLocation({ - lat: event.location.y, - lng: event.location.x, - }); + onLayersUpdate(() => ({ + layers: [L.marker([event.location.y, event.location.x])], + })); } }; map.on('geosearch/showlocation', getPinData); return () => map.removeControl(searchControl); - }, [map, setBoundingBox, setPinLocation, t]); + }, [map, onLayersUpdate, t]); return null; }; -const MapPolygon = props => { - const { bounds, geojson } = props; - const map = useMap(); +const MapGeoJson = () => { + const [newGeoJson, setNewGeoJson] = useState(); + const { featureGroup, geojson, onGeoJsonChange, geoJsonFilter } = + useContext(MapContext); useEffect(() => { - if (bounds) { - map.fitBounds(bounds); + if (!featureGroup) { + return; } - }, [map, bounds]); - // Added unique key on every rerender to force GeoJSON update - return ( - - ); -}; - -const Location = props => { - const map = useMap(); - const markerRef = useRef(null); - const { onPinLocationChange, enableSearch, enableDraw, center } = props; - const [pinLocation, setPinLocation] = useState( - center ? { lat: center[0], lng: center[1] } : null - ); - const [boundingBox, setBoundingBox] = useState(); - - const markerEventHandlers = useMemo( - () => ({ - dragend: () => { - const marker = markerRef.current; - if (marker != null) { - setPinLocation(marker.getLatLng()); - } + geojsonToLayer(geojson, featureGroup, { + style: { color: theme.palette.map.polygon }, + pointToLayer: (feature, latlng) => { + const marker = L.marker(latlng, { draggable: true }); + marker.on('dragend', event => { + setNewGeoJson(layerToGeoJSON(featureGroup.getLayers())); + }); + return marker; }, - }), - [] - ); + ...(geoJsonFilter ? { filter: geoJsonFilter } : {}), + }); + }, [featureGroup, geojson, onGeoJsonChange, geoJsonFilter]); useEffect(() => { - if (pinLocation && boundingBox) { - onPinLocationChange({ - pinLocation, - boundingBox, - }); + if (!newGeoJson) { + return; } - }, [boundingBox, pinLocation, onPinLocationChange]); + onGeoJsonChange(newGeoJson); + }, [newGeoJson, onGeoJsonChange]); - useEffect(() => { - const getZoomData = () => { - const southWest = map.getBounds().getSouthWest(); - const northEast = map.getBounds().getNorthEast(); - const bbox = [southWest.lng, southWest.lat, northEast.lng, northEast.lat]; - if (bbox) { - setBoundingBox(bbox); - } - }; - getZoomData(); - map.on('zoomend', getZoomData); - }, [map]); - return ( - <> - {enableSearch && ( - - )} + return null; +}; - {enableDraw && ( - - )} +const Location = props => { + const { enableSearch, enableDraw } = props; - {pinLocation && ( - - )} + return ( + <> + {enableSearch && } + {enableDraw && } ); }; const Map = props => { - const [map, setMap] = useState(); + const map = useMap(); + const { t } = useTranslation(); + const [featureGroup, setFeatureGroup] = useState(); const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + const { bounds, geojson, onGeoJsonChange, geoJsonFilter, drawOptions } = + props; useEffect(() => { - if (map?.target && props.center) { - map.target.flyTo(props.center, 3); + if (props.center) { + map.flyTo(props.center, 3); } }, [props.center, map]); + useEffect(() => { + if (bounds) { + map.fitBounds(bounds); + } + }, [map, bounds]); + + useEffect(() => { + // Feature Group + const featureGroup = L.featureGroup().addTo(map); + setFeatureGroup(featureGroup); + + // Default layer + map.addLayer(LAYER_OSM); + + // Layers control + const layersControl = L.control + .layers({ + [t('gis.map_layer_streets')]: LAYER_OSM, + [t('gis.map_layer_satellite')]: LAYER_ESRI, + }) + .addTo(map); + return () => { + map.removeControl(layersControl); + featureGroup.remove(); + }; + }, [map, t]); + + const onBoundsUpdate = useCallback( + bbox => { + if (!onGeoJsonChange) { + return; + } + onGeoJsonChange(current => { + if (!current) { + return null; + } + if (_.isEqual(current.bbox, bbox)) { + return current; + } + return { + ...current, + bbox, + }; + }); + }, + [onGeoJsonChange] + ); + + const getDefaultBbox = useCallback( + () => boundsToGeoJsonBbox(map.getBounds()), + [map] + ); + + const onLayersUpdate = useCallback( + getUpdate => { + if (!featureGroup) { + return; + } + const { layers, bbox = getDefaultBbox() } = getUpdate(featureGroup); + if (_.isEmpty(layers)) { + onGeoJsonChange(null); + return; + } + + const newGeojson = layerToGeoJSON(layers); + onGeoJsonChange({ + ...newGeojson, + bbox, + }); + }, + [getDefaultBbox, onGeoJsonChange, featureGroup] + ); + return ( - - - + + + {props.enableSearch && ( )} {props.children} + + ); +}; + +const MapWrapper = props => { + return ( + + ); }; -export default Map; +export default MapWrapper; diff --git a/src/group/membership/components/GroupMembershipCard.js b/src/group/membership/components/GroupMembershipCard.js index 0f2459838..b8261b66e 100644 --- a/src/group/membership/components/GroupMembershipCard.js +++ b/src/group/membership/components/GroupMembershipCard.js @@ -17,6 +17,7 @@ import { Typography, } from '@mui/material'; +import ExternalLink from 'common/components/ExternalLink'; import Restricted from 'permissions/components/Restricted'; import AccountAvatar from 'account/components/AccountAvatar'; @@ -82,12 +83,9 @@ const Content = props => { prefix - + link - + diff --git a/src/landscape/components/LandscapeBoundariesUpdate.js b/src/landscape/components/LandscapeBoundariesUpdate.js index ef54e40ee..6ac00919d 100644 --- a/src/landscape/components/LandscapeBoundariesUpdate.js +++ b/src/landscape/components/LandscapeBoundariesUpdate.js @@ -1,20 +1,21 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import _ from 'lodash/fp'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useParams } from 'react-router-dom'; -import { Button, Grid, Paper } from '@mui/material'; - import { useDocumentTitle } from 'common/document'; import PageContainer from 'layout/PageContainer'; -import PageHeader from 'layout/PageHeader'; import PageLoader from 'layout/PageLoader'; -import { fetchLandscapeForm, saveLandscape } from 'landscape/landscapeSlice'; +import { + fetchLandscapeForm, + saveLandscape, + setFormNewValues, +} from 'landscape/landscapeSlice'; -import LandscapeBoundaries from './LandscapeBoundaries'; +import BoundaryStep from './LandscapeForm/BoundaryStep'; const LandscapeBoundariesUpdate = () => { const dispatch = useDispatch(); @@ -24,10 +25,6 @@ const LandscapeBoundariesUpdate = () => { const { fetching, landscape, success } = useSelector( state => state.landscape.form ); - const [areaPolygon, setAreaPolygon] = useState(); - const onFileSelected = areaPolygon => { - setAreaPolygon(areaPolygon); - }; useDocumentTitle( t('landscape.boundaries_document_title', { @@ -44,52 +41,33 @@ const LandscapeBoundariesUpdate = () => { if (success) { navigate(`/landscapes/${slug}`); } + return () => dispatch(setFormNewValues()); }, [success, slug, navigate, dispatch]); - if (fetching) { + if (fetching || !landscape) { return ; } - const onSave = () => { + const onSave = updatedLandscape => { dispatch( saveLandscape({ id: landscape.id, - areaPolygon, + areaPolygon: updatedLandscape.areaPolygon, }) ); }; return ( - navigate(`/landscapes/${slug}`)} /> - - - - - - - ); }; diff --git a/src/landscape/components/LandscapeBoundariesUpdate.test.js b/src/landscape/components/LandscapeBoundariesUpdate.test.js index faae63231..a0d0e67cb 100644 --- a/src/landscape/components/LandscapeBoundariesUpdate.test.js +++ b/src/landscape/components/LandscapeBoundariesUpdate.test.js @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from 'tests/utils'; +import { act, fireEvent, render, screen } from 'tests/utils'; import React from 'react'; @@ -53,6 +53,14 @@ const testGeoJsonParsing = (file, errorMessage) => async () => { expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); + await act(async () => + fireEvent.click( + screen.getByRole('button', { + name: 'Upload a GeoJSON file', + }) + ) + ); + const dropzone = screen.getByRole('button', { name: 'Select File Accepted file formats: *.json, *.geojson Maximum file size: 1 MB', }); @@ -174,6 +182,14 @@ test('LandscapeBoundaries: Select file', async () => { expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); + await act(async () => + fireEvent.click( + screen.getByRole('button', { + name: 'Upload a GeoJSON file', + }) + ) + ); + const dropzone = screen.getByRole('button', { name: 'Select File Accepted file formats: *.json, *.geojson Maximum file size: 1 MB', }); @@ -199,7 +215,7 @@ test('LandscapeBoundaries: Select file', async () => { }) ).toBeInTheDocument(); }); -test('LandscapeBoundaries: Show cancel', async () => { +test('LandscapeBoundaries: Show back', async () => { const navigate = jest.fn(); useNavigate.mockReturnValue(navigate); terrasoApi.requestGraphQL.mockReturnValue( @@ -219,9 +235,22 @@ test('LandscapeBoundaries: Show cancel', async () => { ); await setup(); - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - expect(cancelButton).toBeInTheDocument(); - await act(async () => fireEvent.click(cancelButton)); + await act(async () => + fireEvent.click( + screen.getByRole('button', { + name: 'Upload a GeoJSON file', + }) + ) + ); + + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + ); + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + ); expect(navigate.mock.calls[0]).toEqual(['/landscapes/slug-1']); }); test('LandscapeBoundaries: Save', async () => { @@ -257,6 +286,14 @@ test('LandscapeBoundaries: Save', async () => { expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); + await act(async () => + fireEvent.click( + screen.getByRole('button', { + name: 'Upload a GeoJSON file', + }) + ) + ); + const dropzone = screen.getByRole('button', { name: 'Select File Accepted file formats: *.json, *.geojson Maximum file size: 1 MB', }); @@ -276,13 +313,12 @@ test('LandscapeBoundaries: Save', async () => { }, }; fireEvent.drop(dropzone, data); - await waitFor(() => - expect( - screen.getByRole('button', { name: 'Save Changes' }) - ).not.toHaveAttribute('disabled') - ); + await screen.findByRole('button', { + name: 'Select File Accepted file formats: *.json, *.geojson Maximum file size: 1 MB test.json 804 B', + }); + const saveButton = screen.getByRole('button', { - name: 'Save Changes', + name: 'Update Map', }); expect(saveButton).toBeInTheDocument(); expect(saveButton).not.toHaveAttribute('disabled'); diff --git a/src/landscape/components/LandscapeForm/BoundaryStep.css b/src/landscape/components/LandscapeForm/BoundaryStep.css new file mode 100644 index 000000000..3a6f8719c --- /dev/null +++ b/src/landscape/components/LandscapeForm/BoundaryStep.css @@ -0,0 +1,28 @@ +.landascape-boundary-step-map-icon { + width: 20px; + height: 20px; + background-size: 300px 30px; + display: inline-block; + background-color: #fff; + border: 2px solid rgba(0, 0, 0, 0.2); + border-radius: 2px; + vertical-align: sub; +} + +.landascape-boundary-step-draw-icon { + background-image: linear-gradient(transparent, transparent), + url('./map-icons.svg'); +} +.landascape-boundary-step-draw-edit-icon { + background-position: -155px -5px; +} +.landascape-boundary-step-draw-polygon-icon { + background-position: -334px -5px; +} + +.landascape-boundary-step-draw-basemap-icon { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=); + background-size: 18px; + background-repeat: no-repeat; + background-position: 1px; +} diff --git a/src/landscape/components/LandscapeForm/BoundaryStep.js b/src/landscape/components/LandscapeForm/BoundaryStep.js index adcf118c5..fc3c781a7 100644 --- a/src/landscape/components/LandscapeForm/BoundaryStep.js +++ b/src/landscape/components/LandscapeForm/BoundaryStep.js @@ -1,33 +1,55 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import * as turf from '@turf/helpers'; +import _ from 'lodash/fp'; import { Trans, useTranslation } from 'react-i18next'; import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt'; +import CloseIcon from '@mui/icons-material/Close'; +import MapIcon from '@mui/icons-material/Map'; import PinDropIcon from '@mui/icons-material/PinDrop'; import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { Button, Link, Paper, Stack, Typography } from '@mui/material'; +import { + Alert, + Button, + Dialog, + DialogContent, + IconButton, + Paper, + Stack, + Typography, +} from '@mui/material'; +import ExternalLink from 'common/components/ExternalLink'; import { countryNameForCode, scrollToNavBar } from 'common/utils'; import PageHeader from 'layout/PageHeader'; import { getPlaceInfoByName } from 'gis/gisService'; -import LandscapeBoundaries from 'landscape/components/LandscapeBoundaries'; +import LandscapeGeoJsonBoundaries from 'landscape/components/LandscapeGeoJsonBoundaries'; import LandscapeMap from 'landscape/components/LandscapeMap'; import { useIsMounted } from 'custom-hooks'; +import './BoundaryStep.css'; + const OPTION_GEOJSON = 'geo-json'; +const OPTION_MAP_DRAW_POLYGON = 'map-draw-polygon'; const OPTION_MAP_PIN = 'map-pin'; const OPTION_SELECT_OPTIONS = 'options'; +const POLYGON_FILTER = feature => _.get('geometry.type', feature) === 'Polygon'; +const POINT_FILTER = feature => _.get('geometry.type', feature) === 'Point'; + const GeoJson = props => { const { t } = useTranslation(); - const { mapCenter, landscape, setOption, save } = props; - const [areaPolygon, setAreaPolygon] = useState(); - const onFileSelected = areaPolygon => { - setAreaPolygon(areaPolygon); - }; + const { + mapCenter, + landscape, + setOption, + save, + saveLabel, + areaPolygon, + setAreaPolygon, + } = props; const onSave = () => { save({ @@ -38,15 +60,12 @@ const GeoJson = props => { return ( <> - + - @@ -57,31 +76,37 @@ const GeoJson = props => { {t('landscape.form_boundary_options_back')} ); }; -const MapPin = props => { +const MapDrawPolygon = props => { const { t } = useTranslation(); - const { landscape, mapCenter, setOption, save } = props; - const [areaPolygon, setAreaPolygon] = useState(); + const { + landscape, + isNew, + boundingBox, + setOption, + save, + saveLabel, + areaPolygon, + setAreaPolygon, + } = props; + const [editHelp, setEditHelp] = useState(false); + const [open, setOpen] = useState(false); - const onPinLocationChange = useCallback( - ({ pinLocation: { lat, lng }, boundingBox }) => { - if (!lat || !lng || !boundingBox) { - return; - } - setAreaPolygon({ - type: 'FeatureCollection', - bbox: boundingBox, - features: [turf.point([lng, lat])], - }); - }, - [setAreaPolygon] - ); + const onPolygonChange = useCallback(() => { + setOpen(true); + }, [setOpen]); + const onEditStart = useCallback(() => { + setEditHelp(true); + }, [setEditHelp]); + const onEditStop = useCallback(() => { + setEditHelp(false); + }, [setEditHelp]); const onSave = () => { save({ @@ -90,19 +115,154 @@ const MapPin = props => { }); }; + const drawOptions = useMemo( + () => ({ + polygon: true, + zoomToFeatures: true, + onEditStart, + onEditStop, + onLayerChange: onPolygonChange, + }), + [onEditStart, onEditStop, onPolygonChange] + ); + return ( <> + setOpen(false)} + sx={{ + '& .MuiBackdrop-root': { + backgroundColor: 'transparent', + }, + '& .MuiPaper-root': { + backgroundColor: '#055989', + }, + '& .MuiDialogContent-root': { + color: '#ffffff', + }, + }} + > + setOpen(false)} + sx={{ + position: 'absolute', + right: 8, + top: 8, + color: 'white', + }} + > + + + + + {{ saveLabel }} + + + + + + + + first + + third + + + + + {editHelp && ( + + + {{ saveLabel }} + + + )} + + {t('landscape.form_boundary_draw_polygon_help')} + + + + + + + + ); +}; + +const MapPin = props => { + const { t } = useTranslation(); + const { + landscape, + boundingBox, + setOption, + save, + saveLabel, + areaPolygon, + setAreaPolygon, + } = props; + + const onSave = () => { + save({ + ...landscape, + areaPolygon, + }); + }; + + return ( + <> + {t('landscape.form_boundary_pin_description')} @@ -113,7 +273,7 @@ const MapPin = props => { {t('landscape.form_boundary_options_back')} @@ -122,7 +282,7 @@ const MapPin = props => { const BoundaryOptions = props => { const { t } = useTranslation(); - const { landscape, setOption, setActiveStepIndex, save } = props; + const { landscape, setOption, save, onCancel, title } = props; const onOptionClick = option => () => { option.onClick(); @@ -135,6 +295,11 @@ const BoundaryOptions = props => { label: 'landscape.form_boundary_options_geojson', onClick: () => setOption(OPTION_GEOJSON), }, + { + Icon: MapIcon, + label: 'landscape.form_boundary_options_draw_polygon', + onClick: () => setOption(OPTION_MAP_DRAW_POLYGON), + }, { Icon: PinDropIcon, label: 'landscape.form_boundary_options_pin', @@ -149,16 +314,15 @@ const BoundaryOptions = props => { return ( <> - + - Prefix - - link - - . + + First + second + + link + + {options.map((option, index) => ( @@ -180,12 +344,11 @@ const BoundaryOptions = props => { ))} - + {onCancel && ( + + )} ); }; @@ -194,6 +357,8 @@ const getOptionComponent = option => { switch (option) { case OPTION_GEOJSON: return GeoJson; + case OPTION_MAP_DRAW_POLYGON: + return MapDrawPolygon; case OPTION_MAP_PIN: return MapPin; default: @@ -203,27 +368,38 @@ const getOptionComponent = option => { const BoundaryStep = props => { const [option, setOption] = useState(OPTION_SELECT_OPTIONS); - const [mapCenter, setMapCenter] = useState(); + const [boundingBox, setBoundingBox] = useState(); const isMounted = useIsMounted(); const OptionComponent = getOptionComponent(option); const { landscape } = props; + const [areaPolygon, setAreaPolygon] = useState(landscape.areaPolygon); - // Whenever the location (country) changes, fetch the lat/lng for the - // country and center the map on that country. useEffect(() => { if (landscape.location) { const currentCountry = countryNameForCode(landscape.location); + if (!currentCountry) { + return; + } + + // Whenever the location (country) changes, fetch the lat/lng for the + // country and center the map on that country. getPlaceInfoByName(currentCountry.name).then(data => { if (isMounted.current) { - setMapCenter([parseFloat(data.lat), parseFloat(data.lon)]); + setBoundingBox(data.boundingbox); } }); } - }, [landscape.location, isMounted]); + }, [landscape, isMounted]); return ( - + ); }; export default BoundaryStep; diff --git a/src/landscape/components/LandscapeForm/map-icons.svg b/src/landscape/components/LandscapeForm/map-icons.svg new file mode 100644 index 000000000..3c00f3031 --- /dev/null +++ b/src/landscape/components/LandscapeForm/map-icons.svg @@ -0,0 +1,156 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/landscape/components/LandscapeBoundaries.js b/src/landscape/components/LandscapeGeoJsonBoundaries.js similarity index 87% rename from src/landscape/components/LandscapeBoundaries.js rename to src/landscape/components/LandscapeGeoJsonBoundaries.js index 053c5341f..970948e7c 100644 --- a/src/landscape/components/LandscapeBoundaries.js +++ b/src/landscape/components/LandscapeGeoJsonBoundaries.js @@ -3,9 +3,8 @@ import React, { useCallback, useState } from 'react'; import _ from 'lodash/fp'; import { Trans, useTranslation } from 'react-i18next'; -import { Link } from '@mui/material'; - import BaseDropZone from 'common/components/DropZone'; +import ExternalLink from 'common/components/ExternalLink'; import InlineHelp from 'common/components/InlineHelp'; import { sendToRollbar } from 'monitoring/logger'; @@ -82,13 +81,17 @@ const DropZone = props => { ); }; -const LandscapeBoundaries = props => { +const LandscapeGeoJsonBoundaries = props => { const { t } = useTranslation(); const { areaPolygon, mapCenter, onFileSelected } = props; return ( <> - + { details: ( Prefix - + link - + . ), @@ -113,4 +113,4 @@ const LandscapeBoundaries = props => { ); }; -export default LandscapeBoundaries; +export default LandscapeGeoJsonBoundaries; diff --git a/src/landscape/components/LandscapeList.js b/src/landscape/components/LandscapeList.js index f30e73638..c3cc6a1d4 100644 --- a/src/landscape/components/LandscapeList.js +++ b/src/landscape/components/LandscapeList.js @@ -7,6 +7,7 @@ import { Link as RouterLink, useSearchParams } from 'react-router-dom'; import { Button, Link, Stack, Typography } from '@mui/material'; +import ExternalLink from 'common/components/ExternalLink'; import TableResponsive from 'common/components/TableResponsive'; import { useDocumentTitle } from 'common/document'; import { countryNameForCode } from 'common/utils'; @@ -163,9 +164,9 @@ const LandscapeList = () => { add link or - + help - + . diff --git a/src/landscape/components/LandscapeList.test.js b/src/landscape/components/LandscapeList.test.js index 1e58644b4..7e6898b5d 100644 --- a/src/landscape/components/LandscapeList.test.js +++ b/src/landscape/components/LandscapeList.test.js @@ -121,7 +121,7 @@ const baseListTest = async () => { expect(mapRegion).toBeInTheDocument(); const markers = within(mapRegion).getAllByRole('button'); - expect(markers.length).toBe(17); // 15 + zoom buttons + expect(markers.length).toBe(18); // 15 + zoom buttons await act(async () => fireEvent.click(markers[0])); diff --git a/src/landscape/components/LandscapeListMap.css b/src/landscape/components/LandscapeListMap.css index fadb1dbe3..a1e9da507 100644 --- a/src/landscape/components/LandscapeListMap.css +++ b/src/landscape/components/LandscapeListMap.css @@ -11,15 +11,15 @@ color: #fff; } -.landscape-list-map-cluster-icon { +.landscape-list-map-cluster-icon.leaflet-marker-icon.leaflet-interactive { background-clip: padding-box; - background-color: #98532999; + background: #98532999; border-radius: 20px; } -.landscape-list-map-marker-icon { +.landscape-list-map-marker-icon.leaflet-marker-icon.leaflet-interactive { background-clip: padding-box; - background-color: #985329; + background: #985329; border-radius: 30px; } diff --git a/src/landscape/components/LandscapeListMap.js b/src/landscape/components/LandscapeListMap.js index 5abfc7251..5c9a95669 100644 --- a/src/landscape/components/LandscapeListMap.js +++ b/src/landscape/components/LandscapeListMap.js @@ -42,7 +42,7 @@ const LandscapesClusters = () => { useEffect(() => { if (!_.isEmpty(landscapesWithPosition)) { const bounds = clusterRef.current?.getBounds?.(); - if (bounds) { + if (bounds && bounds.isValid()) { map.fitBounds(bounds); } } diff --git a/src/landscape/components/LandscapeMap.js b/src/landscape/components/LandscapeMap.js index ff6914f80..139919cfd 100644 --- a/src/landscape/components/LandscapeMap.js +++ b/src/landscape/components/LandscapeMap.js @@ -1,6 +1,4 @@ -import React from 'react'; - -import _ from 'lodash/fp'; +import React, { useMemo } from 'react'; import { Box } from '@mui/material'; @@ -11,26 +9,34 @@ import { } from 'landscape/landscapeUtils'; const LandscapeMap = ({ - landscape, + areaPolygon, + boundingBox, label, onPinLocationChange, enableSearch, enableDraw, mapCenter, + onGeoJsonChange, + geoJsonFilter, + drawOptions, }) => { - const bounds = getLandscapeBoundingBox(landscape); - const areaPolygon = _.get('areaPolygon', landscape); + const bounds = useMemo( + () => getLandscapeBoundingBox({ areaPolygon, boundingBox }), + [areaPolygon, boundingBox] + ); const geojson = isValidGeoJson(areaPolygon) ? areaPolygon : null; - const defaultProps = areaPolygon ? {} : { center: mapCenter }; return ( { label: t('landscape.form_step_boundaries_options_label'), render: ({ setActiveStepIndex }) => ( setActiveStepIndex(current => current - 1)} save={onSave} + saveLabel={t('landscape.form_add_label')} /> ), }, diff --git a/src/landscape/components/LandscapeNewFormDrawPolygon.test.js b/src/landscape/components/LandscapeNewFormDrawPolygon.test.js new file mode 100644 index 000000000..0b321b0c0 --- /dev/null +++ b/src/landscape/components/LandscapeNewFormDrawPolygon.test.js @@ -0,0 +1,134 @@ +import { fireEvent, render, screen, waitFor, within } from 'tests/utils'; + +import React from 'react'; + +import L from 'leaflet'; +import { act } from 'react-dom/test-utils'; +import * as reactLeaflet from 'react-leaflet'; +import { useParams } from 'react-router-dom'; + +import LandscapeNew from 'landscape/components/LandscapeNew'; +import * as terrasoApi from 'terrasoBackend/api'; + +jest.mock('terrasoBackend/api'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); + +const setup = async () => { + await render(); + const name = screen.getByRole('textbox', { + name: 'Name (required)', + }); + const description = screen.getByRole('textbox', { + name: 'Description (required)', + }); + const website = screen.getByRole('textbox', { name: 'Website' }); + const location = screen.getByRole('button', { name: 'Country or region' }); + + const changeLocation = async newLocation => { + await act(async () => fireEvent.mouseDown(location)); + const listbox = within(screen.getByRole('listbox')); + await act(async () => + fireEvent.click(listbox.getByRole('option', { name: newLocation })) + ); + }; + + return { + inputs: { + name, + description, + website, + location, + changeLocation, + }, + }; +}; + +beforeEach(() => { + useParams.mockReturnValue({}); +}); + +test('LandscapeNew: Save form draw polygon boundary', async () => { + terrasoApi.requestGraphQL.mockResolvedValueOnce({ + addLandscape: { + landscape: { + id: '1', + name: 'Landscape Name', + description: 'Landscape Description', + website: 'www.landscape.org', + location: 'EC', + }, + }, + }); + + const spy = jest.spyOn(reactLeaflet, 'useMap'); + + const { inputs } = await setup(); + + fireEvent.change(inputs.name, { target: { value: 'New name' } }); + fireEvent.change(inputs.description, { + target: { value: 'New description' }, + }); + fireEvent.change(inputs.website, { + target: { value: 'https://www.other.org' }, + }); + await inputs.changeLocation('Argentina'); + + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + ); + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Skip this step for now' }) + ).toBeInTheDocument(); + }); + await act(async () => + fireEvent.click( + screen.getByRole('button', { + name: 'Draw the landscape’s boundary on a map', + }) + ) + ); + + expect(spy).toHaveBeenCalled(); + const map = spy.mock.results[spy.mock.results.length - 1].value; + await act(async () => + map.fireEvent(L.Draw.Event.CREATED, { + layerType: 'polygon', + layer: new L.polygon([ + [37, -109.05], + [41, -109.03], + [41, -102.05], + [37, -102.04], + ]), + }) + ); + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'close' })) + ); + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Add Landscape' }) + ).toBeInTheDocument(); + }); + + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Add Landscape' })) + ); + + expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); + const saveCall = terrasoApi.requestGraphQL.mock.calls[0]; + expect(saveCall[1]).toStrictEqual({ + input: { + description: 'New description', + name: 'New name', + website: 'https://www.other.org', + location: 'AR', + areaPolygon: + '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[-109.05,37],[-109.03,41],[-102.05,41],[-102.04,37],[-109.05,37]]]}}],"bbox":[-105.46875000000001,38.82259097617713,-105.46875000000001,38.82259097617713]}', + }, + }); +}); diff --git a/src/landscape/components/LandscapeNewFormGeoJson.test.js b/src/landscape/components/LandscapeNewFormGeoJson.test.js index 69a922538..40201f39a 100644 --- a/src/landscape/components/LandscapeNewFormGeoJson.test.js +++ b/src/landscape/components/LandscapeNewFormGeoJson.test.js @@ -112,11 +112,13 @@ test('LandscapeNew: Save from GeoJSON', async () => { }; await act(async () => fireEvent.drop(dropzone, data)); - expect( - await screen.findByRole('button', { - name: 'Select File Accepted file formats: *.json, *.geojson Maximum file size: 1 MB test.json 804 B', - }) - ).toBeInTheDocument(); + await waitFor(async () => { + expect( + await screen.findByRole('button', { + name: 'Select File Accepted file formats: *.json, *.geojson Maximum file size: 1 MB test.json 804 B', + }) + ).toBeInTheDocument(); + }); await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Add Landscape' })) diff --git a/src/landscape/components/LandscapeNewFormPin.test.js b/src/landscape/components/LandscapeNewFormPin.test.js index a5d82fa30..9c8bf27bd 100644 --- a/src/landscape/components/LandscapeNewFormPin.test.js +++ b/src/landscape/components/LandscapeNewFormPin.test.js @@ -2,8 +2,9 @@ import { fireEvent, render, screen, waitFor, within } from 'tests/utils'; import React from 'react'; +import L from 'leaflet'; import { act } from 'react-dom/test-utils'; -import { useMap } from 'react-leaflet'; +import * as reactLeaflet from 'react-leaflet'; import { useParams } from 'react-router-dom'; import LandscapeNew from 'landscape/components/LandscapeNew'; @@ -16,11 +17,6 @@ jest.mock('react-router-dom', () => ({ useParams: jest.fn(), })); -jest.mock('react-leaflet', () => ({ - ...jest.requireActual('react-leaflet'), - useMap: jest.fn(), -})); - const setup = async () => { await render(); const name = screen.getByRole('textbox', { @@ -68,19 +64,7 @@ test('LandscapeNew: Save form Pin boundary', async () => { }, }); - const eventCallback = {}; - useMap.mockReturnValue({ - getBounds: () => ({ - getSouthWest: () => ({ lat: 0, lng: 10 }), - getNorthEast: () => ({ lat: 1, lng: 11 }), - }), - fitBounds: () => {}, - addControl: () => {}, - removeControl: () => {}, - on: (event, callback) => { - eventCallback[event] = callback; - }, - }); + const spy = jest.spyOn(reactLeaflet, 'useMap'); const { inputs } = await setup(); @@ -109,10 +93,12 @@ test('LandscapeNew: Save form Pin boundary', async () => { ) ); + expect(spy).toHaveBeenCalled(); + const map = spy.mock.results[spy.mock.results.length - 1].value; await act(async () => - eventCallback['draw:created']({ + map.fireEvent(L.Draw.Event.CREATED, { layerType: 'marker', - layer: { getLatLng: () => ({ lat: 10, lng: 10 }) }, + layer: new L.Marker([10, 10]), }) ); @@ -129,7 +115,7 @@ test('LandscapeNew: Save form Pin boundary', async () => { website: 'https://www.other.org', location: 'AR', areaPolygon: - '{"type":"FeatureCollection","bbox":[10,0,11,1],"features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[10,10]}}]}', + '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[10,10]}}],"bbox":[0,0,0,0]}', }, }); }); diff --git a/src/landscape/components/LandscapeView.js b/src/landscape/components/LandscapeView.js index 08c319115..4a95e3aae 100644 --- a/src/landscape/components/LandscapeView.js +++ b/src/landscape/components/LandscapeView.js @@ -19,6 +19,7 @@ import { Typography, } from '@mui/material'; +import ExternalLink from 'common/components/ExternalLink'; import InlineHelp from 'common/components/InlineHelp'; import SocialShare from 'common/components/SocialShare.js'; import { useDocumentTitle } from 'common/document'; @@ -182,7 +183,8 @@ const LandscapeView = () => { { details: ( Prefix - link - + . ), diff --git a/src/landscape/landscapeService.js b/src/landscape/landscapeService.js index b432f5617..693b08507 100644 --- a/src/landscape/landscapeService.js +++ b/src/landscape/landscapeService.js @@ -65,30 +65,34 @@ export const fetchLandscapeToView = (slug, currentUser) => { ${landscapeFields} ${defaultGroup} `; - return ( - terrasoApi - .requestGraphQL(query, { slug, accountEmail: currentUser.email }) - .then(_.get('landscapes.edges[0].node')) - .then(landscape => landscape || Promise.reject('not_found')) - .then(landscape => ({ - ..._.omit('defaultGroup', landscape), - defaultGroup: getDefaultGroup(landscape), - })) - // TODO temporarily getting position from openstreetmap API. - // This should change when we store landscape polygon. - .then(landscape => - gisService.getPlaceInfoByName(landscape.location).then(placeInfo => ({ + return terrasoApi + .requestGraphQL(query, { slug, accountEmail: currentUser.email }) + .then(_.get('landscapes.edges[0].node')) + .then(landscape => landscape || Promise.reject('not_found')) + .then(landscape => ({ + ..._.omit('defaultGroup', landscape), + defaultGroup: getDefaultGroup(landscape), + })) + .then(landscape => ({ + ...landscape, + areaPolygon: landscape.areaPolygon + ? JSON.parse(landscape.areaPolygon) + : null, + })) + .then(landscape => { + if (landscape.areaPolygon || !landscape.location) { + return landscape; + } + + // Get bounding box from nominatim.openstreetmap.org if no areaPolygon data + // AreaPolygon is not present when the user decided to skip it. + return gisService + .getPlaceInfoByName(landscape.location) + .then(placeInfo => ({ ...landscape, - position: placeInfo, - })) - ) - .then(landscape => ({ - ...landscape, - areaPolygon: landscape.areaPolygon - ? JSON.parse(landscape.areaPolygon) - : null, - })) - ); + boundingBox: placeInfo?.boundingbox, + })); + }); }; export const fetchLandscapeToUploadSharedData = (slug, currentUser) => { diff --git a/src/landscape/landscapeUtils.js b/src/landscape/landscapeUtils.js index 1e7cee6b2..18a74cf17 100644 --- a/src/landscape/landscapeUtils.js +++ b/src/landscape/landscapeUtils.js @@ -12,20 +12,26 @@ const parseGeoJson = areaPolygon => { } }; +// Returns bounding box containing the defined areaPolygon data or +// the bounding box requested from the landsace.location data export const getLandscapeBoundingBox = (landscape = {}) => { - const { areaPolygon, position } = landscape; + const { areaPolygon, boundingBox: defaultBoundingBox } = landscape; const areaBoundingBox = areaPolygon && parseGeoJson(areaPolygon); - const positionBoundingBox = position && position.boundingbox; - const boundingBox = areaBoundingBox || positionBoundingBox; + if (areaBoundingBox) { + return [ + [areaBoundingBox[1], areaBoundingBox[0]], + [areaBoundingBox[3], areaBoundingBox[2]], + ]; + } - return ( - boundingBox && [ - [boundingBox[1], boundingBox[0]], - [boundingBox[3], boundingBox[2]], - ] - ); + if (defaultBoundingBox) { + return [ + [defaultBoundingBox[1], defaultBoundingBox[2]], + [defaultBoundingBox[0], defaultBoundingBox[3]], + ]; + } }; export const getLandscapePin = landscape => { diff --git a/src/landscape/landscapeUtils.test.js b/src/landscape/landscapeUtils.test.js index 73589684e..df2b42330 100644 --- a/src/landscape/landscapeUtils.test.js +++ b/src/landscape/landscapeUtils.test.js @@ -22,22 +22,19 @@ test('Landscape Utils: get bounding box by area geojson', () => { test('Landscape Utils: get bounding box by position', () => { const landscape = { areaPolygon: null, - position: { - boundingbox: [1, 2, 3, 4], - }, + boundingBox: [1, 2, 3, 4], }; const boundingBox = getLandscapeBoundingBox(landscape); expect(boundingBox).toStrictEqual([ - [2, 1], - [4, 3], + [2, 3], + [1, 4], ]); }); test('Landscape Utils: get bounding box without area nor position', () => { const landscape = { areaPolygon: null, - position: null, }; const boundingBox = getLandscapeBoundingBox(landscape); diff --git a/src/localization/locales/en-US.json b/src/localization/locales/en-US.json index 525db48ce..e54523eac 100644 --- a/src/localization/locales/en-US.json +++ b/src/localization/locales/en-US.json @@ -276,7 +276,7 @@ "form_step_info_label": "Landscape Details", "form_step_boundaries_options_label": "Landscape Boundary", "form_boundary_options_title": "Define Your Landscape’s Boundary", - "form_boundary_options_description": "Tell us about where your landscape is. Uploading a map is the most accurate. If you don’t have one, you can <1>create a GeoJSON file, drop a pin, or define your boundary later.", + "form_boundary_options_description": "<0><0>Select how you’d like to define the landscape boundary<1>We recommend updating the map regularly with the most accurate boundary of your landscape.<2>Don’t have a GeoJSON file? Create one.", "form_boundary_options_geojson": "Upload a GeoJSON file", "form_boundary_options_pin": "Drop a pin on a map", "form_boundary_options_skip": "Skip this step for now", @@ -297,7 +297,17 @@ "list_map_section_label": "Landscapes map", "list_map_popup_link": "View details about {{name}}", "breadcrumbs_view": "{{landscapeName}}", - "breadcrumbs_members": "Members" + "breadcrumbs_members": "Members", + "boundaries_update_save": "Update Map", + "form_boundary_draw_polygon_title": "Draw {{name}}’s Boundary", + "form_boundary_draw_polygon_description": "<0>Search for or zoom to your landscape. Use <1/> to change the background map. Use <3/> to start drawing.", + "form_boundary_draw_polygon_help": "Need help?", + "form_boundary_draw_polygon_help_url": "https://terraso.org/help/how-to-draw-your-landscape-boundary/", + "form_boundary_options_draw_polygon": "Draw the landscape’s boundary on a map", + "form_boundary_draw_polygon_saved_create": "Your landscape boundary is complete. Select <1/> to add more detail to your boundary or select Add Landscape to add your landscape to Terraso.", + "form_boundary_draw_polygon_saved_update": "Your landscape boundary is complete. Select <1/> to add more detail to your boundary or select Update Map to submit your changes.", + "form_boundary_draw_polygon_edit_help": "How to edit: Drag points to adjust the shape. Select points to remove. When you are finished, save and select {{saveLabel}}.", + "form_boundary_map_basemap_label": "Background map" }, "sharedData": { "title": "Shared files", @@ -347,7 +357,6 @@ "editable_text_save": "Save", "editable_text_cancel": "Cancel", "refreshing_loader_label": "Refreshing", - "map_search_placeholder": "Enter a city, town or region", "table_search_filter_results_other": "{{rows.length}} matches found for “{{query}}“", "table_search_filter_results_one": "{{rows.length}} match found for “{{query}}“", "table_search_filter_clear": "Clear search" @@ -399,7 +408,83 @@ "unique": "$t(terraso_api.models.{{model}}) {{field}} must be unique." }, "gis": { - "openstreetmap_api_error": "Failed to request data from nominatim.openstreetmap.org API" + "openstreetmap_api_error": "Failed to request data from nominatim.openstreetmap.org API", + "map_search_placeholder": "Enter a city, town or region", + "map_layer_streets": "Streets", + "map_layer_satellite": "Satellite", + "map_draw": { + "draw": { + "toolbar": { + "actions": { + "title": "Cancel drawing", + "text": "Cancel" + }, + "finish": { + "title": "Finish drawing", + "text": "Close shape" + }, + "undo": { + "title": "Delete last point drawn", + "text": "Delete last point" + }, + "buttons": { + "polygon": "Draw a polygon", + "marker": "Draw a marker" + } + }, + "handlers": { + "marker": { + "tooltip": { + "start": "Place marker on map." + } + }, + "polygon": { + "tooltip": { + "start": "Draw a rough outline of your landscape. Start at the boundary of the landscape.", + "cont": "Continue to select points at the edge of your boundary.", + "end": "Select the first point to close this shape." + } + } + } + }, + "edit": { + "toolbar": { + "actions": { + "save": { + "title": "Save changes", + "text": "Save" + }, + "cancel": { + "title": "Cancel editing, discarding all changes", + "text": "Cancel" + }, + "clearAll": { + "title": "Clear all layers", + "text": "Clear All" + } + }, + "buttons": { + "edit": "Edit polygon", + "editDisabled": "No polygon to edit", + "remove": "Delete polygon", + "removeDisabled": "No polygon to delete" + } + }, + "handlers": { + "edit": { + "tooltip": { + "text": "Drag points to adjust shape.", + "subtext": "Click a point to remove it." + } + }, + "remove": { + "tooltip": { + "text": "Select a shape to remove it." + } + } + } + } + } }, "contact": { "success": "Your message has been sent. Terraso will follow up within two business days.", diff --git a/src/localization/locales/es-ES.json b/src/localization/locales/es-ES.json index c3c2da57b..5cde445ea 100644 --- a/src/localization/locales/es-ES.json +++ b/src/localization/locales/es-ES.json @@ -298,7 +298,17 @@ "list_map_section_label": "Mapa de paisajes", "list_map_popup_link": "Ver detalles sobre {{name}}", "breadcrumbs_view": "{{landscapeName}}", - "breadcrumbs_members": "Miembros" + "breadcrumbs_members": "Miembros", + "boundaries_update_save": "Actualizar mapa", + "form_boundary_draw_polygon_title": "Dibuja el límite de {{name}}", + "form_boundary_draw_polygon_description": "<0>Busca o haz zoom en tu paisaje. Usa <1/> para cambiar el mapa de fondo. Usa <3/> para empezar a dibujar.", + "form_boundary_draw_polygon_help": "¿Necesitas ayuda?", + "form_boundary_draw_polygon_help_url": "https://terraso.org/es/ayuda/como-dibujar-el-limite-de-tu-paisaje/", + "form_boundary_options_draw_polygon": "Dibujar el límite del paisaje en un mapa", + "form_boundary_draw_polygon_saved_create": "Tu límite de paisaje está completo. Selecciona <1/> para agregar más detalles a tu límite o selecciona Crear un paisaje para agregar su paisaje a Terraso.", + "form_boundary_draw_polygon_saved_update": "Tu límite de paisaje está completo. Selecciona <1/> para agregar más detalles a tu límite o seleccione Actualizar mapa para enviar tus cambios.", + "form_boundary_draw_polygon_edit_help": "Cómo editar: Arrastra puntos para ajustar la forma. Seleccionar puntos para eliminar. Cuando hayas terminado, guarda y selecciona {{saveLabel}}.", + "form_boundary_map_basemap_label": "Mapa de fondo" }, "common": { "dialog_cancel_label": "Cancelar", @@ -316,7 +326,6 @@ "editable_text_save": "Guardar", "editable_text_cancel": "Cancelar", "refreshing_loader_label": "Actualizando", - "map_search_placeholder": "Ingresa una ciudad, pueblo o región", "table_search_filter_results_other": "{{rows.length}} resultados encontrados para “{{query}}“", "table_search_filter_results_one": "{{rows.length}} resulatdo encontrado para “{{query}}“", "table_search_filter_clear": "Borrar búsqueda" @@ -400,7 +409,83 @@ "unique": "$t(terraso_api.models.{{model}}) {{field}} Debe ser único." }, "gis": { - "openstreetmap_api_error": "Error al solicitar datos de la API nominatim.openstreetmap.org" + "openstreetmap_api_error": "Error al solicitar datos de la API nominatim.openstreetmap.org", + "map_search_placeholder": "Ingresa una ciudad, pueblo o región", + "map_layer_streets": "Calles", + "map_layer_satellite": "Satélite", + "map_draw": { + "draw": { + "toolbar": { + "actions": { + "title": "Cancelar dibujo", + "text": "Cancelar" + }, + "finish": { + "title": "Terminar dibujo", + "text": "Cerrar forma" + }, + "undo": { + "title": "Eliminar último punto dibujado", + "text": "Eliminar último punto" + }, + "buttons": { + "polygon": "Dibujar un polígono", + "marker": "Dibujar un marcador" + } + }, + "handlers": { + "marker": { + "tooltip": { + "start": "Coloque el marcador en el mapa." + } + }, + "polygon": { + "tooltip": { + "start": "Dibuja un contorno aproximado de tu paisaje. Comienza en el límite del paisaje.", + "cont": "Continúa seleccionando puntos en el borde de el límite.", + "end": "Selecciona el primer punto para cerrar esta forma." + } + } + } + }, + "edit": { + "toolbar": { + "actions": { + "save": { + "title": "Guardar cambios", + "text": "Guardar" + }, + "cancel": { + "title": "Cancelar edición, descartando todos los cambios", + "text": "Cancelar" + }, + "clearAll": { + "title": "Borrar todas las capas", + "text": "Limpiar todo" + } + }, + "buttons": { + "edit": "Editar polígono", + "editDisabled": "No hay polígono para editar", + "remove": "Eliminar polígono", + "removeDisabled": "No hay polígono para eliminar" + } + }, + "handlers": { + "edit": { + "tooltip": { + "text": "Arrastra puntos para ajustar la forma.", + "subtext": "Haga clic en un punto para eliminarlo." + } + }, + "remove": { + "tooltip": { + "text": "Seleccione una forma para eliminarla." + } + } + } + } + } }, "contact": { "success": "Tu mensaje ha sido enviado. Terraso hará un seguimiento dentro de dos días hábiles.", diff --git a/src/monitoring/analytics.js b/src/monitoring/analytics.js index 9080507c0..0df5f30a2 100644 --- a/src/monitoring/analytics.js +++ b/src/monitoring/analytics.js @@ -9,7 +9,6 @@ export const plausible = Plausible({ }); plausible.enableAutoPageviews(); -plausible.enableAutoOutboundTracking(); export const useAnalytics = () => { const { i18n } = useTranslation(); diff --git a/src/sharedData/components/SharedDataCard.js b/src/sharedData/components/SharedDataCard.js index 9350ebed5..4245e5b25 100644 --- a/src/sharedData/components/SharedDataCard.js +++ b/src/sharedData/components/SharedDataCard.js @@ -11,10 +11,10 @@ import { CardContent, CardHeader, CircularProgress, - Link, Typography, } from '@mui/material'; +import ExternalLink from 'common/components/ExternalLink'; import List from 'common/components/List'; import { useGroupContext } from 'group/groupContext'; @@ -97,9 +97,9 @@ const SharedFilesCard = props => { }} > Prefix - + link - + .