maps: add option to include a search button (#88131)
gitea/combo/pipeline/head This commit looks good Details

This commit is contained in:
Frédéric Péters 2024-03-13 20:15:46 +01:00
parent d964be219e
commit 7b66dca2ba
10 changed files with 422 additions and 5 deletions

View File

@ -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'),
),
]

View File

@ -433,6 +433,7 @@ class Map(CellBase):
min_zoom = models.CharField(_('Minimal zoom level'), max_length=2, choices=ZOOM_LEVELS, default='0') 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) 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) 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_onclick = models.CharField(
_('Marker behaviour on click'), max_length=32, default='none', choices=MARKER_BEHAVIOUR_ONCLICK _('Marker behaviour on click'), max_length=32, default='none', choices=MARKER_BEHAVIOUR_ONCLICK
) )
@ -449,6 +450,7 @@ class Map(CellBase):
'/jsi18n', '/jsi18n',
'xstatic/leaflet.js', 'xstatic/leaflet.js',
'js/leaflet-gps.js', 'js/leaflet-gps.js',
'js/leaflet-search.js',
'js/combo.map.js', 'js/combo.map.js',
'xstatic/leaflet.markercluster.js', 'xstatic/leaflet.markercluster.js',
'xstatic/leaflet-gesture-handling.min.js', 'xstatic/leaflet-gesture-handling.min.js',
@ -464,6 +466,7 @@ class Map(CellBase):
fields = ( fields = (
'initial_state', 'initial_state',
'group_markers', 'group_markers',
'include_search_button',
'marker_behaviour_onclick', 'marker_behaviour_onclick',
) )
return forms.models.modelform_factory(self.__class__, fields=fields) return forms.models.modelform_factory(self.__class__, fields=fields)
@ -560,6 +563,7 @@ class Map(CellBase):
ctx['tiles_layers'] = self.get_tiles_layers() ctx['tiles_layers'] = self.get_tiles_layers()
ctx['max_bounds'] = settings.COMBO_MAP_MAX_BOUNDS ctx['max_bounds'] = settings.COMBO_MAP_MAX_BOUNDS
ctx['group_markers'] = self.group_markers ctx['group_markers'] = self.group_markers
ctx['include_search_button'] = self.include_search_button
ctx['marker_behaviour_onclick'] = self.marker_behaviour_onclick ctx['marker_behaviour_onclick'] = self.marker_behaviour_onclick
return ctx return ctx

View File

@ -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;
}
}
}

View File

@ -293,6 +293,17 @@ $(function() {
tooltipTitle: gettext('Display my position')}); tooltipTitle: gettext('Display my position')});
map.addControl(gps_control); 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; $map_widget[0].leaflet_map = map;
$(cell).removeClass('empty-cell'); $(cell).removeClass('empty-cell');

View File

@ -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

View File

@ -11,6 +11,11 @@
{% block map-include-geoloc-button %} {% block map-include-geoloc-button %}
data-include-geoloc-button="true" data-include-geoloc-button="true"
{% endblock %} {% 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 %} {% if group_markers %}data-group-markers="1"{% endif %}
data-marker-behaviour-onclick="{{ cell.marker_behaviour_onclick }}" data-marker-behaviour-onclick="{{ cell.marker_behaviour_onclick }}"
{% if max_bounds.corner1.lat %} {% if max_bounds.corner1.lat %}

View File

@ -14,16 +14,15 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import re_path from django.urls import include, path, re_path
from django.urls import include
from combo.urls_utils import decorated_includes, staff_required from combo.urls_utils import decorated_includes, staff_required
from . import manager_views from . import manager_views
from .views import GeojsonView from .views import GeojsonView, geocoding_view
maps_manager_urls = [ 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( re_path(
'^layers/add/(?P<kind>geojson|tiles)/$', '^layers/add/(?P<kind>geojson|tiles)/$',
manager_views.LayerAddView.as_view(), manager_views.LayerAddView.as_view(),
@ -68,4 +67,5 @@ urlpatterns = [
GeojsonView.as_view(), GeojsonView.as_view(),
name='mapcell-geojson', name='mapcell-geojson',
), ),
path('api/geocoding', geocoding_view, name='mapcell-geocoding'),
] ]

View File

@ -15,11 +15,16 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import json 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.shortcuts import get_object_or_404
from django.views.generic.base import View from django.views.generic.base import View
from combo.utils import requests
from .models import Map from .models import Map
@ -33,3 +38,21 @@ class GeojsonView(View):
geojson = layer.get_geojson(request, options.properties) geojson = layer.get_geojson(request, options.properties)
content_type = 'application/json' content_type = 'application/json'
return HttpResponse(json.dumps(geojson), content_type=content_type) 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'
)

View File

@ -304,6 +304,9 @@ COMBO_MAP_ATTRIBUTION = _(
'<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>' '<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
) )
# geocoding service
COMBO_MAP_GEOCODING_URL = 'https://nominatim.entrouvert.org/search'
# send notifications about new invoices # send notifications about new invoices
LINGO_INVOICE_NOTIFICATIONS_ENABLED = True LINGO_INVOICE_NOTIFICATIONS_ENABLED = True

View File

@ -3,6 +3,7 @@ import re
from unittest import mock from unittest import mock
import pytest import pytest
import responses
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -748,3 +749,29 @@ def test_duplicate(layer):
assert options.map_layer == layer assert options.map_layer == layer
assert options.opacity == 0.5 assert options.opacity == 0.5
assert options.properties == 'a, b' 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)