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)