From 7b66dca2ba99dab7f1a3322e60f8f13b6f069727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Wed, 13 Mar 2024 20:15:46 +0100 Subject: [PATCH] maps: add option to include a search button (#88131) --- .../migrations/0023_include_search_button.py | 17 ++ combo/apps/maps/models.py | 4 + combo/apps/maps/static/css/combo.map.scss | 58 ++++ combo/apps/maps/static/js/combo.map.js | 11 + combo/apps/maps/static/js/leaflet-search.js | 269 ++++++++++++++++++ combo/apps/maps/templates/maps/map_cell.html | 5 + combo/apps/maps/urls.py | 8 +- combo/apps/maps/views.py | 25 +- combo/settings.py | 3 + tests/test_maps_cells.py | 27 ++ 10 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 combo/apps/maps/migrations/0023_include_search_button.py create mode 100644 combo/apps/maps/static/js/leaflet-search.js diff --git a/combo/apps/maps/migrations/0023_include_search_button.py b/combo/apps/maps/migrations/0023_include_search_button.py new file mode 100644 index 00000000..d3e19027 --- /dev/null +++ b/combo/apps/maps/migrations/0023_include_search_button.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2024-03-13 19:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('maps', '0022_increase_extra_css_class'), + ] + + operations = [ + migrations.AddField( + model_name='map', + name='include_search_button', + field=models.BooleanField(default=False, verbose_name='Include address search button'), + ), + ] diff --git a/combo/apps/maps/models.py b/combo/apps/maps/models.py index c7fad299..814b1f1f 100644 --- a/combo/apps/maps/models.py +++ b/combo/apps/maps/models.py @@ -433,6 +433,7 @@ class Map(CellBase): min_zoom = models.CharField(_('Minimal zoom level'), max_length=2, choices=ZOOM_LEVELS, default='0') max_zoom = models.CharField(_('Maximal zoom level'), max_length=2, choices=ZOOM_LEVELS, default=19) group_markers = models.BooleanField(_('Group markers in clusters'), default=False) + include_search_button = models.BooleanField(_('Include address search button'), default=False) marker_behaviour_onclick = models.CharField( _('Marker behaviour on click'), max_length=32, default='none', choices=MARKER_BEHAVIOUR_ONCLICK ) @@ -449,6 +450,7 @@ class Map(CellBase): '/jsi18n', 'xstatic/leaflet.js', 'js/leaflet-gps.js', + 'js/leaflet-search.js', 'js/combo.map.js', 'xstatic/leaflet.markercluster.js', 'xstatic/leaflet-gesture-handling.min.js', @@ -464,6 +466,7 @@ class Map(CellBase): fields = ( 'initial_state', 'group_markers', + 'include_search_button', 'marker_behaviour_onclick', ) return forms.models.modelform_factory(self.__class__, fields=fields) @@ -560,6 +563,7 @@ class Map(CellBase): ctx['tiles_layers'] = self.get_tiles_layers() ctx['max_bounds'] = settings.COMBO_MAP_MAX_BOUNDS ctx['group_markers'] = self.group_markers + ctx['include_search_button'] = self.include_search_button ctx['marker_behaviour_onclick'] = self.marker_behaviour_onclick return ctx diff --git a/combo/apps/maps/static/css/combo.map.scss b/combo/apps/maps/static/css/combo.map.scss index 58265c1e..a2aaf8df 100644 --- a/combo/apps/maps/static/css/combo.map.scss +++ b/combo/apps/maps/static/css/combo.map.scss @@ -281,3 +281,61 @@ ul#id_icon { } } } + +.leaflet-top.leaflet-right { + width: 40%; +} + +.leaflet-search { + width: 100%; + display: flex; + justify-content: right; + align-items: start; + + &.leaflet-control { + pointer-events: none; + &.open { + pointer-events: auto; + } + } + + .leaflet-bar { + pointer-events: auto; + } + + &--control { + width: 0; + display: flex; + flex-direction: column; + transition: all 0.2s; + } + + &.open &--control { + width: 100% + } + + &--input { + width: 100%; + } + + &--result-list { + padding-right: 0.7em; + background: white; + font-size: 100%; + } + + &--result-item { + text-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + padding: 6px; + font-size: 1em; + white-space: nowrap; + + &:hover, &.selected { + color: white; + background-color: #5897fb; + } + } +} diff --git a/combo/apps/maps/static/js/combo.map.js b/combo/apps/maps/static/js/combo.map.js index 77d94f33..dbdc7aca 100644 --- a/combo/apps/maps/static/js/combo.map.js +++ b/combo/apps/maps/static/js/combo.map.js @@ -293,6 +293,17 @@ $(function() { tooltipTitle: gettext('Display my position')}); map.addControl(gps_control); } + if (L.Control.Search && $map_widget.data('search-url')) { + var search_control = new L.Control.Search({ + labels: { + hint: gettext('Search address'), + error: gettext('An error occured while fetching results'), + searching: gettext('Searching...'), + }, + searchUrl: $map_widget.data('search-url') + }); + map.addControl(search_control); + } $map_widget[0].leaflet_map = map; $(cell).removeClass('empty-cell'); diff --git a/combo/apps/maps/static/js/leaflet-search.js b/combo/apps/maps/static/js/leaflet-search.js new file mode 100644 index 00000000..a0c2b0f5 --- /dev/null +++ b/combo/apps/maps/static/js/leaflet-search.js @@ -0,0 +1,269 @@ +/* global L, $ */ +class SearchControl extends L.Control { + options = { + labels: { + hint: 'Search adresses', + error: 'An error occured while fetching results', + searching: 'Searching...' + }, + position: 'topright', + searchUrl: '/api/geocoding', + maxResults: 5 + } + + constructor (options) { + super() + L.Util.setOptions(this, options) + this._refreshTimeout = 0 + } + + onAdd (map) { + this._map = map + this._container = L.DomUtil.create('div', 'leaflet-search') + this._resultLocations = [] + this._selectedIndex = -1 + + this._buttonBar = L.DomUtil.create('div', 'leaflet-bar', this._container) + + this._toggleButton = L.DomUtil.create('a', '', this._buttonBar) + this._toggleButton.href = '#' + this._toggleButton.role = 'button' + this._toggleButton.style.fontFamily = 'FontAwesome' + this._toggleButton.text = '\uf002' + this._toggleButton.title = this.options.labels.hint + this._toggleButton.setAttribute('aria-label', this.options.labels.hint) + + this._control = L.DomUtil.create('div', 'leaflet-search--control', this._container) + this._control.style.visibility = 'collapse' + + this._searchInput = L.DomUtil.create('input', 'leaflet-search--input', this._control) + this._searchInput.placeholder = this.options.labels.hint + + this._feedback = L.DomUtil.create('div', '', this._control) + + this._resultList = L.DomUtil.create('div', 'leaflet-search--result-list', this._control) + this._resultList.style.visibility = 'collapse' + this._resultList.tabIndex = 0 + this._resultList.setAttribute('aria-role', 'list') + + L.DomEvent + .on(this._container, 'click', L.DomEvent.stop, this) + .on(this._control, 'focusin', this._onControlFocusIn, this) + .on(this._control, 'focusout', this._onControlFocusOut, this) + .on(this._control, 'keydown', this._onControlKeyDown, this) + .on(this._toggleButton, 'click', this._onToggleButtonClick, this) + .on(this._searchInput, 'keydown', this._onSearchInputKeyDown, this) + .on(this._searchInput, 'input', this._onSearchInput, this) + .on(this._searchInput, 'mousemove', this._onSearchInputMove, this) + .on(this._searchInput, 'touchmove', this._onSearchInputMove, this) + .on(this._resultList, 'keydown', this._onResultListKeyDown, this) + + return this._container + } + + onRemove (map) { + } + + _showControl () { + this._container.classList.add('open') + this._buttonBar.style.visibility = 'collapse' + this._control.style.removeProperty('visibility') + this._initialBounds = this._map.getBounds() + setTimeout(() => this._searchInput.focus(), 50) + } + + _hideControl (resetBounds) { + this._container.classList.remove('open') + if (resetBounds) { + this._map.fitBounds(this._initialBounds) + } + + this._buttonBar.style.removeProperty('visibility') + this._control.style.visibility = 'collapse' + this._toggleButton.focus() + } + + _onControlFocusIn (event) { + clearTimeout(this._hideTimeout) + } + + _onControlFocusOut (event) { + // need to debounce here because leaflet raises focusout then focusin when + // clicking on an already focused child element. + this._hideTimeout = setTimeout(() => this._hideControl(), 50) + } + + _getSelectedLocation () { + if (this._selectedIndex === -1) { + return null + } + + return this._resultLocations[this._selectedIndex] + } + + _focusLocation (location) { + if (location.bounds !== undefined) { + this._map.fitBounds(location.bounds) + } else { + this._map.panTo(location.latlng) + } + } + + _validateLocation (location) { + this._focusLocation(location) + this._hideControl() + } + + _onSearchInputMove (event) { + event.stopPropagation() + } + + _onControlKeyDown (event) { + if (event.keyCode === 27) { // escape + this._hideControl(true) + event.preventDefault() + } else if (event.keyCode === 13) { // enter + const selectedLocation = this._getSelectedLocation() + if (selectedLocation) { + this._validateLocation(selectedLocation) + } + event.preventDefault() + } + } + + _onToggleButtonClick () { + this._showControl() + } + + _selectIndex (index) { + for (const resultItem of this._resultList.children) { + resultItem.classList.remove('selected') + } + + this._selectedIndex = index + + if (index === -1) { + this._map.fitBounds(this._initialBounds) + this._searchInput.focus() + } else { + this._focusLocation(this._resultLocations[index]) + const selectedElement = this._resultList.children[index] + selectedElement.classList.add('selected') + this._resultList.focus() + } + } + + _onSearchInputKeyDown (event) { + const results = this._resultLocations + if (results.length === 0) { + return + } + + if (event.keyCode === 38) { + this._selectIndex(results.length - 1) + event.preventDefault() + } else if (event.keyCode === 40) { + this._selectIndex(0) + event.preventDefault() + } + } + + _clearResults () { + while (this._resultList.lastElementChild) { + this._resultList.removeChild(this._resultList.lastElementChild) + } + this._resultList.style.visibility = 'collapse' + this._resultLocations = [] + } + + _fetchResults () { + const searchString = this._searchInput.value + + if (!searchString) { + return + } + + this._clearResults() + + this._feedback.innerHTML = this.options.labels.searching + this._feedback.classList.remove('error') + + $.ajax({ + url: this.options.searchUrl, + data: { q: searchString }, + success: (data) => { + this._feedback.innerHTML = '' + this._resultLocations = [] + const firstResults = data.slice(0, this.options.maxResults) + + if (firstResults.length === 0) { + return + } + + this._resultList.style.removeProperty('visibility') + + for (const result of firstResults) { + const resultItem = L.DomUtil.create('div', 'leaflet-search--result-item', this._resultList) + resultItem.innerHTML = result.display_name + resultItem.title = result.display_name + resultItem.setAttribute('aria-role', 'list-item') + L.DomEvent.on(resultItem, 'click', this._onResultItemClick, this) + + const itemLocation = { + latlng: L.latLng(result.lat, result.lon) + } + + const bbox = result.boundingbox + + if (bbox !== undefined) { + itemLocation.bounds = L.latLngBounds( + L.latLng(bbox[0], bbox[2]), + L.latLng(bbox[1], bbox[3]) + ) + } + + this._resultLocations.push(itemLocation) + } + }, + error: () => { + this._feedback.innerHTML = this.options.labels.error + this._feedback.classList.add('error') + } + }) + } + + _onSearchInput () { + clearTimeout(this._refreshTimeout) + if (this._searchInput.value === '') { + this._clearResults() + } else { + this._refreshTimeout = setTimeout(() => this._fetchResults(), 250) + } + } + + _onResultItemClick (event) { + const elementIndex = Array.prototype.indexOf.call(this._resultList.children, event.target) + this._selectIndex(elementIndex) + const selectedLocation = this._getSelectedLocation() + this._validateLocation(selectedLocation) + } + + _onResultListKeyDown (event) { + const results = this._resultLocations + if (event.keyCode === 38) { + this._selectIndex(this._selectedIndex - 1) + event.preventDefault() + } else if (event.keyCode === 40) { + if (this._selectedIndex === results.length - 1) { + this._selectIndex(-1) + } else { + this._selectIndex(this._selectedIndex + 1) + } + event.preventDefault() + } + } +} + +Object.assign(SearchControl.prototype, L.Mixin.Events) + +L.Control.Search = SearchControl diff --git a/combo/apps/maps/templates/maps/map_cell.html b/combo/apps/maps/templates/maps/map_cell.html index 8a5c45f8..93c07156 100644 --- a/combo/apps/maps/templates/maps/map_cell.html +++ b/combo/apps/maps/templates/maps/map_cell.html @@ -11,6 +11,11 @@ {% block map-include-geoloc-button %} data-include-geoloc-button="true" {% endblock %} + {% block map-include-search-button %} + {% if include_search_button %} + data-search-url="{% url 'mapcell-geocoding' %}" + {% endif %} + {% endblock %} {% if group_markers %}data-group-markers="1"{% endif %} data-marker-behaviour-onclick="{{ cell.marker_behaviour_onclick }}" {% if max_bounds.corner1.lat %} diff --git a/combo/apps/maps/urls.py b/combo/apps/maps/urls.py index ee1e76a6..094629a8 100644 --- a/combo/apps/maps/urls.py +++ b/combo/apps/maps/urls.py @@ -14,16 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.conf.urls import re_path -from django.urls import include +from django.urls import include, path, re_path from combo.urls_utils import decorated_includes, staff_required from . import manager_views -from .views import GeojsonView +from .views import GeojsonView, geocoding_view maps_manager_urls = [ - re_path('^$', manager_views.ManagerHomeView.as_view(), name='maps-manager-homepage'), + path('', manager_views.ManagerHomeView.as_view(), name='maps-manager-homepage'), re_path( '^layers/add/(?Pgeojson|tiles)/$', manager_views.LayerAddView.as_view(), @@ -68,4 +67,5 @@ urlpatterns = [ GeojsonView.as_view(), name='mapcell-geojson', ), + path('api/geocoding', geocoding_view, name='mapcell-geocoding'), ] diff --git a/combo/apps/maps/views.py b/combo/apps/maps/views.py index 681a87d5..ba929d0e 100644 --- a/combo/apps/maps/views.py +++ b/combo/apps/maps/views.py @@ -15,11 +15,16 @@ # along with this program. If not, see . import json +import urllib.parse -from django.http import HttpResponse, HttpResponseForbidden +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.views.generic.base import View +from combo.utils import requests + from .models import Map @@ -33,3 +38,21 @@ class GeojsonView(View): geojson = layer.get_geojson(request, options.properties) content_type = 'application/json' return HttpResponse(json.dumps(geojson), content_type=content_type) + + +def geocoding_view(request, *args, **kwargs): + if 'q' not in request.GET: + return HttpResponseBadRequest() + if not Map.objects.filter(include_search_button=True).exists(): + raise PermissionDenied() + q = request.GET['q'] + url = settings.COMBO_MAP_GEOCODING_URL + if '?' in url: + url += '&' + else: + url += '?' + url += 'format=json&q=%s' % urllib.parse.quote(q) + url += '&accept-language=%s' % settings.LANGUAGE_CODE.split('-')[0] + return HttpResponse( + requests.get(url, without_user=True, remote_service=False).text, content_type='application/json' + ) diff --git a/combo/settings.py b/combo/settings.py index d7187341..1237e8a6 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -304,6 +304,9 @@ COMBO_MAP_ATTRIBUTION = _( 'CC-BY-SA' ) +# geocoding service +COMBO_MAP_GEOCODING_URL = 'https://nominatim.entrouvert.org/search' + # send notifications about new invoices LINGO_INVOICE_NOTIFICATIONS_ENABLED = True diff --git a/tests/test_maps_cells.py b/tests/test_maps_cells.py index 582f14e7..d1e09b04 100644 --- a/tests/test_maps_cells.py +++ b/tests/test_maps_cells.py @@ -3,6 +3,7 @@ import re from unittest import mock import pytest +import responses from django.conf import settings from django.contrib.auth.models import Group, User from django.test.client import RequestFactory @@ -748,3 +749,29 @@ def test_duplicate(layer): assert options.map_layer == layer assert options.opacity == 0.5 assert options.properties == 'a, b' + + +@responses.activate +def test_geocoding(app): + page = Page(title='xxx', slug='test_map_cell', template_name='standard') + page.save() + cell = Map(page=page, placeholder='content', order=0, title='Map') + cell.save() + + resp = app.get('/test_map_cell/') + assert not resp.pyquery('[data-search-url]') + cell.include_search_button = True + cell.save() + resp = app.get('/test_map_cell/') + search_url = resp.pyquery('[data-search-url]').attr('data-search-url') + app.get(search_url, status=400) + responses.get( + settings.COMBO_MAP_GEOCODING_URL, json=[{'display_name': 'address', 'lat': '1', 'lon': '2'}] + ) + resp = app.get(search_url + '?q=test') + assert resp.json == [{'display_name': 'address', 'lat': '1', 'lon': '2'}] + assert responses.calls[0].request.path_url == '/search?format=json&q=test&accept-language=en' + + cell.include_search_button = False + cell.save() + app.get(search_url + '?q=test', status=403)