Skip to content

Commit d406943

Browse files
committed
Add configurable popups on markers (PR #339)
1 parent 36b737c commit d406943

File tree

16 files changed

+162
-7
lines changed

16 files changed

+162
-7
lines changed

docs/customization.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,34 @@ The second way overrides these templates for all your models.
197197
you need to create a sub-directory named ``mapentity`` in ``main/templates``.
198198
Then you can create a file named ``override_detail_pdf.html``(or ``.css``) and it will be used for all your models if a specific template is not provided.
199199

200+
Popups
201+
------
202+
203+
MapEntity displays a popup when clicking on a map object.
204+
By default, it shows the object's name and a button linking to its detail page.
205+
206+
Configure popup fields for each model using the ``POPUP_CONTENT`` setting:
207+
208+
.. code-block:: python
209+
POPUP_CONTENT = {
210+
"museum": ["name", "city", "public"],
211+
}
212+
The key is the model name in lowercase (e.g., ``"museum"``), and the value is a list of field names to display.
213+
The popup title shows the object's string representation (using `__str__()`), with the configured fields displayed below.
214+
215+
If there is a display function for a field in the model then it will be used in priority.
216+
217+
.. code-block:: python
218+
@property
219+
def public_display(self):
220+
return "Public" if self.public else "Not public"
221+
If no display function exists, the field’s string representation is used by default.
222+
223+
Non-existent fields can be used if a display function exist.
224+
225+
If a model isn't configured in ``POPUP_CONTENT``, the object's string representation is used as the title.
226+
If a specified field doesn't exist on the model, it won't be displayed. The detail page button is always shown.
227+
If the option ``displayPopup`` is setup to false, then the popup will not appear when clicking on the feature.
200228

201229
Settings
202230
-----------

mapentity/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,12 @@ def get_generic_detail_url(cls):
282282
def get_detail_url(self):
283283
return reverse(self._entity.url_name(ENTITY_DETAIL), args=[str(self.pk)])
284284

285+
def get_popup_url(self):
286+
return reverse(
287+
f"{self._meta.app_label.lower()}:{self._meta.model_name.lower()}-drf-popup-content",
288+
kwargs={"pk": self.pk},
289+
)
290+
285291
@property
286292
def map_image_url(self):
287293
return self.get_map_image_url()

mapentity/serializers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
MapentityGeojsonModelSerializer,
66
)
77
from .gpx import GPXSerializer
8-
from .helpers import json_django_dumps, plain_text, smart_plain_text
8+
from .helpers import field_as_string, json_django_dumps, plain_text, smart_plain_text
99
from .shapefile import ZipShapeSerializer
1010

1111
__all__ = [
1212
"plain_text",
1313
"smart_plain_text",
14+
"field_as_string",
1415
"CSVSerializer",
1516
"GPXSerializer",
1617
"MapentityDatatableSerializer",

mapentity/serializers/helpers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ def field_as_string(obj, field, ascii=False):
2727
or isinstance(value, Decimal)
2828
):
2929
value = number_format(value)
30+
if hasattr(value, "all"):
31+
# convert ManyRelatedManager to QuerySet
32+
value = value.all()
3033
if isinstance(value, list) or isinstance(value, QuerySet):
3134
value = ", ".join([str(val) for val in value])
3235
return smart_plain_text(value, ascii)

mapentity/static/mapentity/js/MaplibreMapentity.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ document.addEventListener('DOMContentLoaded', function() {
119119
dataUrl: layerUrl,
120120
primaryKey: primaryKey,
121121
isLazy: false,
122+
displayPopup: true,
122123
});
123124

124125
const mapReadyEvent = new CustomEvent('entity:map:ready', {

mapentity/static/mapentity/js/MaplibreObjectsLayer.js

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class MaplibreObjectsLayer {
4444
* @param e {Object} - Événement de clic
4545
* @private
4646
*/
47-
_onClick(e) {
47+
async _onClick(e) {
4848
if (this.options.readonly) {
4949
return;
5050
}
@@ -54,8 +54,14 @@ class MaplibreObjectsLayer {
5454

5555
if (features.length > 0 && features[0].source !== 'geojson') {
5656
const feature = features[0];
57-
if (this.options.objectUrl) {
58-
window.location = this.options.objectUrl(feature.properties, feature);
57+
if(this.options.displayPopup){
58+
var popup_content;
59+
try{
60+
popup_content = await this.getPopupContent(this.options.modelname, feature.id);
61+
} catch (error) {
62+
popup_content = gettext('Data unreachable');
63+
}
64+
new maplibregl.Popup().setLngLat(e.lngLat).setHTML(popup_content).addTo(this._map);
5965
}
6066
}
6167
}
@@ -625,4 +631,27 @@ class MaplibreObjectsLayer {
625631
getBoundsLayer() {
626632
return this.boundsLayer;
627633
}
634+
635+
/**
636+
* Fetch data to display in object popup
637+
* @returns {Promise<String>}
638+
*/
639+
async getPopupContent(modelname, id){
640+
const popup_url = window.SETTINGS.urls.popup.replace(new RegExp('modelname', 'g'), modelname)
641+
.replace('0', id);
642+
643+
// fetch data
644+
var response = await window.fetch(popup_url);
645+
if (!response.ok){
646+
throw new Error(`HTTP error! Status: ${response.status}`);
647+
} else {
648+
// parse data
649+
try {
650+
const data = await response.json();
651+
return data;
652+
} catch (error) {
653+
throw new Error('Cannot parse data');
654+
}
655+
}
656+
}
628657
}

mapentity/static/mapentity/js/maplibreTest.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ window.addEventListener("entity:map", () => {
2323
category: category,
2424
primaryKey: primaryKey,
2525
dataUrl: layerUrl,
26-
isLazy: true
26+
isLazy: true,
27+
displayPopup: false,
2728
});
2829

2930
objectsLayer.initialize(map.getMap());
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<div class="d-flex flex-column justify-content-center">
2+
<p class="text-center m-0 p-1"><strong>{{ title }}</strong></p>
3+
{% if attributes %}
4+
<p class="m-0 p-1">
5+
{% for field in attributes %}{{ field|truncatechars:100 }}<br>{% endfor %}
6+
</p>
7+
{% endif %}
8+
<button id="detail-btn" class="btn btn-sm btn-info mt-2" onclick="window.location.href='{{ detail_url }}'">{{ button_label }}</button>
9+
</div>

mapentity/templates/mapentity/widget.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,13 @@
5858
const objectsLayer = new MaplibreObjectsLayer(null, {
5959
style: typeof style === "function" ? {} : style,
6060
modelname: modelname,
61-
readonly: true,
61+
readonly: false,
6262
nameHTML: '<span style="color:' + style['color'] + ';">&#x25A3;</span>&nbsp;' + modelname,
6363
category: tr("Objects"),
6464
dataUrl: layerUrl,
6565
primaryKey: generateUniqueId(),
6666
isLazy: false,
67+
displayPopup: true,
6768
});
6869
objectsLayer.initialize(map.getMap());
6970

mapentity/tests/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def get_expected_geojson_attrs(self):
5757
def get_expected_datatables_attrs(self):
5858
return {}
5959

60+
def get_expected_popup_content(self):
61+
return ""
62+
6063
def setUp(self):
6164
if self.user:
6265
self.client.force_login(user=self.user)
@@ -444,6 +447,17 @@ def test_api_geojson_list_for_model(self):
444447
},
445448
)
446449

450+
def test_api_popup_content(self):
451+
if self.model is None:
452+
return # Abstract test should not run
453+
454+
self.obj = self.modelfactory.create()
455+
popup_url = self.obj.get_popup_url()
456+
response = self.client.get(popup_url)
457+
self.assertEqual(response.status_code, 200, f"{popup_url} not found")
458+
content_json = response.json()
459+
self.assertEqual(content_json, self.get_expected_popup_content())
460+
447461

448462
class MapEntityLiveTest(LiveServerTestCase):
449463
model = None

0 commit comments

Comments
 (0)