Merge pull request #1573 from umap-project/map-preview

feat: add experimental "map preview" in /map/ endpoint
This commit is contained in:
Yohan Boniface 2024-02-07 19:14:37 +01:00 committed by GitHub
commit 10efc5d103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 219 additions and 108 deletions

View file

@ -69,6 +69,65 @@ L.U.Map.include({
'tilelayers', 'tilelayers',
], ],
editableOptions: {
'zoom': undefined,
'scrollWheelZoom': Boolean,
'scaleControl': Boolean,
'moreControl': Boolean,
'miniMap': Boolean,
'displayPopupFooter': undefined,
'onLoadPanel': String,
'defaultView': String,
'name': String,
'description': String,
'licence': undefined,
'tilelayer': undefined,
'overlay': undefined,
'limitBounds': undefined,
'color': String,
'iconClass': String,
'iconUrl': String,
'smoothFactor': undefined,
'iconOpacity': undefined,
'opacity': undefined,
'weight': undefined,
'fill': undefined,
'fillColor': undefined,
'fillOpacity': undefined,
'dashArray': undefined,
'popupShape': String,
'popupTemplate': String,
'popupContentTemplate': String,
'zoomTo': undefined,
'captionBar': Boolean,
'captionMenus': Boolean,
'slideshow': undefined,
'sortKey': undefined,
'labelKey': undefined,
'filterKey': undefined,
'facetKey': undefined,
'slugKey': undefined,
'showLabel': undefined,
'labelDirection': undefined,
'labelInteractive': undefined,
'outlinkTarget': undefined,
'shortCredit': undefined,
'longCredit': undefined,
'permanentCredit': undefined,
'permanentCreditBackground': undefined,
'zoomControl': 'NullableBoolean',
'datalayersControl': 'NullableBoolean',
'searchControl': 'NullableBoolean',
'locateControl': 'NullableBoolean',
'fullscreenControl': 'NullableBoolean',
'editinosmControl': 'NullableBoolean',
'embedControl': 'NullableBoolean',
'measureControl': 'NullableBoolean',
'tilelayersControl': 'NullableBoolean',
'starControl': 'NullableBoolean',
'easing': undefined,
},
initialize: function (el, geojson) { initialize: function (el, geojson) {
// Locale name (pt_PT, en_US…) // Locale name (pt_PT, en_US…)
// To be used for Django localization // To be used for Django localization
@ -89,7 +148,7 @@ L.U.Map.include({
? geojson.properties.fullscreenControl ? geojson.properties.fullscreenControl
: true : true
geojson.properties.fullscreenControl = false geojson.properties.fullscreenControl = false
L.Util.setBooleanFromQueryString(geojson.properties, 'scrollWheelZoom') this.setOptionsFromQueryString(geojson.properties)
L.Map.prototype.initialize.call(this, el, geojson.properties) L.Map.prototype.initialize.call(this, el, geojson.properties)
@ -109,32 +168,10 @@ L.U.Map.include({
this.demoTileInfos = this.options.demoTileInfos this.demoTileInfos = this.options.demoTileInfos
this.options.zoomControl = zoomControl this.options.zoomControl = zoomControl
this.options.fullscreenControl = fullscreenControl this.options.fullscreenControl = fullscreenControl
L.Util.setBooleanFromQueryString(this.options, 'moreControl')
L.Util.setBooleanFromQueryString(this.options, 'scaleControl')
L.Util.setBooleanFromQueryString(this.options, 'miniMap')
L.Util.setFromQueryString(this.options, 'editMode')
L.Util.setBooleanFromQueryString(this.options, 'displayDataBrowserOnLoad')
L.Util.setBooleanFromQueryString(this.options, 'displayCaptionOnLoad')
L.Util.setBooleanFromQueryString(this.options, 'captionBar')
L.Util.setBooleanFromQueryString(this.options, 'captionMenus')
for (let i = 0; i < this.HIDDABLE_CONTROLS.length; i++) {
L.Util.setNullableBooleanFromQueryString(
this.options,
`${this.HIDDABLE_CONTROLS[i]}Control`
)
}
// Specific case for datalayersControl
// which accept "expanded" value, on top of true/false/null
if (L.Util.queryString('datalayersControl') === 'expanded') {
L.Util.setFromQueryString(this.options, 'datalayersControl')
}
this.datalayersOnLoad = L.Util.queryString('datalayers') this.datalayersOnLoad = L.Util.queryString('datalayers')
this.options.onLoadPanel = L.Util.queryString( if (this.datalayersOnLoad) {
'onLoadPanel',
this.options.onLoadPanel
)
if (this.datalayersOnLoad)
this.datalayersOnLoad = this.datalayersOnLoad.toString().split(',') this.datalayersOnLoad = this.datalayersOnLoad.toString().split(',')
}
if (L.Browser.ielt9) this.options.editMode = 'disabled' // TODO include ie9 if (L.Browser.ielt9) this.options.editMode = 'disabled' // TODO include ie9
@ -233,18 +270,25 @@ L.U.Map.include({
// Creation mode // Creation mode
if (!this.options.umap_id) { if (!this.options.umap_id) {
this.isDirty = true if (!this.options.preview) {
this.isDirty = true
this.enableEdit()
}
this._default_extent = true this._default_extent = true
this.options.name = L._('Untitled map') this.options.name = L._('Untitled map')
this.options.editMode = 'advanced' let data = L.Util.queryString('data', null)
this.enableEdit()
let dataUrl = L.Util.queryString('dataUrl', null) let dataUrl = L.Util.queryString('dataUrl', null)
const dataFormat = L.Util.queryString('dataFormat', 'geojson') const dataFormat = L.Util.queryString('dataFormat', 'geojson')
if (dataUrl) { if (dataUrl) {
dataUrl = decodeURIComponent(dataUrl) dataUrl = decodeURIComponent(dataUrl)
dataUrl = this.localizeUrl(dataUrl) dataUrl = this.localizeUrl(dataUrl)
dataUrl = this.proxyUrl(dataUrl) dataUrl = this.proxyUrl(dataUrl)
const datalayer = this.createDataLayer()
datalayer.importFromUrl(dataUrl, dataFormat) datalayer.importFromUrl(dataUrl, dataFormat)
} else if (data) {
data = decodeURIComponent(data)
const datalayer = this.createDataLayer()
datalayer.importRaw(data, dataFormat)
} }
} }
@ -290,12 +334,37 @@ L.U.Map.include({
} }
}) })
window.onbeforeunload = () => this.isDirty || null window.onbeforeunload = () => this.editEnabled && this.isDirty || null
this.backup() this.backup()
this.initContextMenu() this.initContextMenu()
this.on('click contextmenu.show', this.closeInplaceToolbar) this.on('click contextmenu.show', this.closeInplaceToolbar)
}, },
setOptionsFromQueryString: function (options) {
// This is not an editable option
L.Util.setFromQueryString(options, 'editMode')
// FIXME retrocompat
L.Util.setBooleanFromQueryString(options, 'displayDataBrowserOnLoad')
L.Util.setBooleanFromQueryString(options, 'displayCaptionOnLoad')
for (const [key, type] of Object.entries(this.editableOptions)) {
switch (type) {
case Boolean:
L.Util.setBooleanFromQueryString(options, key)
break
case 'NullableBoolean':
L.Util.setNullableBooleanFromQueryString(options, key)
break
case String:
L.Util.setFromQueryString(options, key)
}
}
// Specific case for datalayersControl
// which accepts "expanded" value, on top of true/false/null
if (L.Util.queryString('datalayersControl') === 'expanded') {
L.Util.setFromQueryString(options, 'datalayersControl')
}
},
initControls: function () { initControls: function () {
this.helpMenuActions = {} this.helpMenuActions = {}
this._controls = {} this._controls = {}
@ -852,8 +921,7 @@ L.U.Map.include({
let mustReindex = false let mustReindex = false
for (let i = 0; i < this.editableOptions.length; i++) { for (const option of Object.keys(this.editableOptions)) {
const option = this.editableOptions[i]
if (typeof importedData.properties[option] !== 'undefined') { if (typeof importedData.properties[option] !== 'undefined') {
this.options[option] = importedData.properties[option] this.options[option] = importedData.properties[option]
if (option === 'sortKey') mustReindex = true if (option === 'sortKey') mustReindex = true
@ -980,70 +1048,11 @@ L.U.Map.include({
else this.fire('saved') else this.fire('saved')
}, },
editableOptions: [
'zoom',
'scrollWheelZoom',
'scaleControl',
'moreControl',
'miniMap',
'displayPopupFooter',
'onLoadPanel',
'defaultView',
'name',
'description',
'licence',
'tilelayer',
'overlay',
'limitBounds',
'color',
'iconClass',
'iconUrl',
'smoothFactor',
'iconOpacity',
'opacity',
'weight',
'fill',
'fillColor',
'fillOpacity',
'dashArray',
'popupShape',
'popupTemplate',
'popupContentTemplate',
'zoomTo',
'captionBar',
'captionMenus',
'slideshow',
'sortKey',
'labelKey',
'filterKey',
'facetKey',
'slugKey',
'showLabel',
'labelDirection',
'labelInteractive',
'outlinkTarget',
'shortCredit',
'longCredit',
'permanentCredit',
'permanentCreditBackground',
'zoomControl',
'datalayersControl',
'searchControl',
'locateControl',
'fullscreenControl',
'editinosmControl',
'embedControl',
'measureControl',
'tilelayersControl',
'starControl',
'easing',
],
exportOptions: function () { exportOptions: function () {
const properties = {} const properties = {}
for (let i = this.editableOptions.length - 1; i >= 0; i--) { for (const option of Object.keys(this.editableOptions)) {
if (typeof this.options[this.editableOptions[i]] !== 'undefined') { if (typeof this.options[option] !== 'undefined') {
properties[this.editableOptions[i]] = this.options[this.editableOptions[i]] properties[option] = this.options[option]
} }
} }
return properties return properties

View file

@ -0,0 +1,75 @@
import json
from urllib.parse import quote
import pytest
from playwright.sync_api import expect
pytestmark = pytest.mark.django_db
GEOJSON = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "Niagara Falls",
},
"geometry": {
"type": "Point",
"coordinates": [-79.04, 43.08],
},
}
],
}
CSV = "name,latitude,longitude\nNiagara Falls,43.08,-79.04"
def test_map_preview(page, live_server, tilelayer):
page.goto(f"{live_server.url}/map/")
# Edit mode is not enabled
edit_button = page.get_by_role("button", name="Edit")
expect(edit_button).to_be_visible()
def test_map_preview_can_load_remote_geojson(page, live_server, tilelayer):
def handle(route):
route.fulfill(json=GEOJSON)
# Intercept the route to the proxy
page.route("*/**/ajax-proxy/**", handle)
page.goto(f"{live_server.url}/map/?dataUrl=http://some.org/geo.json")
markers = page.locator(".leaflet-marker-icon")
expect(markers).to_have_count(1)
def test_map_preview_can_load_remote_csv(page, live_server, tilelayer):
def handle(route):
csv = """name,latitude,longitude\nNiagara Falls,43.08,-79.04"""
route.fulfill(body=csv)
# Intercept the route to the proxy
page.route("*/**/ajax-proxy/**", handle)
page.goto(f"{live_server.url}/map/?dataUrl=http://some.org/geo.csv&dataFormat=csv")
markers = page.locator(".leaflet-marker-icon")
expect(markers).to_have_count(1)
def test_map_preview_can_load_geojson_in_querystring(page, live_server, tilelayer):
page.goto(f"{live_server.url}/map/?data={quote(json.dumps(GEOJSON))}")
markers = page.locator(".leaflet-marker-icon")
expect(markers).to_have_count(1)
def test_map_preview_can_load_csv_in_querystring(page, live_server, tilelayer):
page.goto(f"{live_server.url}/map/?data={quote(CSV)}&dataFormat=csv")
markers = page.locator(".leaflet-marker-icon")
expect(markers).to_have_count(1)
def test_map_preview_can_change_styling_from_querystring(page, live_server, tilelayer):
page.goto(f"{live_server.url}/map/?data={quote(json.dumps(GEOJSON))}&color=DarkRed")
markers = page.locator(".leaflet-marker-icon .icon_container")
expect(markers).to_have_count(1)
expect(markers).to_have_css("background-color", "rgb(139, 0, 0)")

View file

@ -1,3 +1,5 @@
import re
import pytest import pytest
from playwright.sync_api import expect from playwright.sync_api import expect
@ -37,3 +39,14 @@ def test_datalayers_control(map, live_server, datalayer, page):
page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=expanded") page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=expanded")
expect(control).to_be_hidden() expect(control).to_be_hidden()
expect(box).to_be_visible() expect(box).to_be_visible()
def test_can_deactivate_wheel_from_query_string(map, live_server, page):
page.goto(f"{live_server.url}{map.get_absolute_url()}")
expect(page).to_have_url(re.compile(r".*#7/.+"))
page.mouse.wheel(0, 1)
expect(page).to_have_url(re.compile(r".*#6/.+"))
page.goto(f"{live_server.url}{map.get_absolute_url()}?scrollWheelZoom=false")
expect(page).to_have_url(re.compile(r".*#7/.+"))
page.mouse.wheel(0, 1)
expect(page).to_have_url(re.compile(r".*#7/.+"))

View file

@ -96,6 +96,7 @@ i18n_urls += decorated_patterns(
) )
i18n_urls += decorated_patterns( i18n_urls += decorated_patterns(
[ensure_csrf_cookie], [ensure_csrf_cookie],
re_path(r"^map/$", views.MapPreview.as_view(), name="map_preview"),
re_path(r"^map/new/$", views.MapNew.as_view(), name="map_new"), re_path(r"^map/new/$", views.MapNew.as_view(), name="map_new"),
) )
i18n_urls += decorated_patterns( i18n_urls += decorated_patterns(

View file

@ -455,8 +455,7 @@ class MapDetailMixin:
if domain and "{" not in domain: if domain and "{" not in domain:
context["preconnect_domains"] = [f"//{domain}"] context["preconnect_domains"] = [f"//{domain}"]
def get_context_data(self, **kwargs): def get_map_properties(self):
context = super().get_context_data(**kwargs)
user = self.request.user user = self.request.user
properties = { properties = {
"urls": _urls_for_js(), "urls": _urls_for_js(),
@ -486,6 +485,17 @@ class MapDetailMixin:
if self.get_short_url(): if self.get_short_url():
properties["shortUrl"] = self.get_short_url() properties["shortUrl"] = self.get_short_url()
if not user.is_anonymous:
properties["user"] = {
"id": user.pk,
"name": str(user),
"url": reverse("user_dashboard"),
}
return properties
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
properties = self.get_map_properties()
if settings.USE_I18N: if settings.USE_I18N:
lang = settings.LANGUAGE_CODE lang = settings.LANGUAGE_CODE
# Check attr in case the middleware is not active # Check attr in case the middleware is not active
@ -495,19 +505,13 @@ class MapDetailMixin:
locale = to_locale(lang) locale = to_locale(lang)
properties["locale"] = locale properties["locale"] = locale
context["locale"] = locale context["locale"] = locale
if not user.is_anonymous: geojson = self.get_geojson()
properties["user"] = { if "properties" not in geojson:
"id": user.pk, geojson["properties"] = {}
"name": str(user), geojson["properties"].update(properties)
"url": reverse("user_dashboard"), geojson["properties"]["datalayers"] = self.get_datalayers()
} context["map_settings"] = json.dumps(geojson, indent=settings.DEBUG)
map_settings = self.get_geojson() self.set_preconnect(geojson["properties"], context)
if "properties" not in map_settings:
map_settings["properties"] = {}
map_settings["properties"].update(properties)
map_settings["properties"]["datalayers"] = self.get_datalayers()
context["map_settings"] = json.dumps(map_settings, indent=settings.DEBUG)
self.set_preconnect(map_settings["properties"], context)
return context return context
def get_datalayers(self): def get_datalayers(self):
@ -709,6 +713,15 @@ class MapNew(MapDetailMixin, TemplateView):
template_name = "umap/map_detail.html" template_name = "umap/map_detail.html"
class MapPreview(MapDetailMixin, TemplateView):
template_name = "umap/map_detail.html"
def get_map_properties(self):
properties = super().get_map_properties()
properties["preview"] = True
return properties
class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView): class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView):
model = Map model = Map
form_class = MapSettingsForm form_class = MapSettingsForm