From 924891b7cba81416df7d7601e141df5ca1972b97 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 10:18:09 +0200 Subject: [PATCH 1/5] Move maps scripts out of templates, building popups from data --- gulpfile.js | 1 + templates/default/common_scripts.html.twig | 184 ++------- templates/default/maps.html.twig | 34 +- templates/default/maps_preferences.html.twig | 11 +- templates/default/mymap.html.twig | 135 +------ .../tests/units/MapsController.php | 70 ++-- webroot/galette_maps.js | 372 ++++++++++++++++++ 7 files changed, 467 insertions(+), 340 deletions(-) create mode 100644 webroot/galette_maps.js diff --git a/gulpfile.js b/gulpfile.js index 48b6e9d..aa43e87 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -75,6 +75,7 @@ function clean(cb) { plugin.public + '/**', '!' + plugin.public, '!' + plugin.public + '/galette_maps.css', + '!' + plugin.public + '/galette_maps.js', plugin.public + '/images/**', '!' + plugin.public + '/images', '!' + plugin.public + '/images/marker-galette.png', diff --git a/templates/default/common_scripts.html.twig b/templates/default/common_scripts.html.twig index d13b7db..bcccc53 100644 --- a/templates/default/common_scripts.html.twig +++ b/templates/default/common_scripts.html.twig @@ -15,157 +15,35 @@ {% endif %} - - +}|merge(page_config ?? {}) %} +{# HEX flags keep "" and friends harmless inside the script element #} + + diff --git a/templates/default/maps.html.twig b/templates/default/maps.html.twig index 2fa6b15..969c921 100644 --- a/templates/default/maps.html.twig +++ b/templates/default/maps.html.twig @@ -14,35 +14,9 @@ {% endblock %} {% block javascripts %} - {% include '@PluginGaletteMaps/common_scripts.html.twig' %} - + } %} {% endblock %} diff --git a/templates/default/maps_preferences.html.twig b/templates/default/maps_preferences.html.twig index 3bbe2d5..08cfb11 100644 --- a/templates/default/maps_preferences.html.twig +++ b/templates/default/maps_preferences.html.twig @@ -18,7 +18,7 @@ } %} -
+

{{ _T("Your own values", "maps") }}

{% include "components/forms/checkbox.html.twig" with { @@ -70,12 +70,5 @@ {% endblock %} {% block javascripts %} - + {% endblock %} diff --git a/templates/default/mymap.html.twig b/templates/default/mymap.html.twig index 75f16be..7be577c 100644 --- a/templates/default/mymap.html.twig +++ b/templates/default/mymap.html.twig @@ -30,7 +30,7 @@
    {% for t in towns %}
  • -
    +
    {{ t.full_name }}
    @@ -50,129 +50,14 @@ {% endblock %} {% block javascripts %} -{% include '@PluginGaletteMaps/common_scripts.html.twig' %} -{% if cur_route != 'maps_map' %} - -{% endif %} + } + } %} {% endblock %} diff --git a/tests/GaletteMaps/Controllers/tests/units/MapsController.php b/tests/GaletteMaps/Controllers/tests/units/MapsController.php index eea4eb6..d61732d 100644 --- a/tests/GaletteMaps/Controllers/tests/units/MapsController.php +++ b/tests/GaletteMaps/Controllers/tests/units/MapsController.php @@ -62,6 +62,24 @@ private function makeMemberTwoManager(array $members): void $this->assertTrue($group->setMembers($members)); } + /** + * Get maps configuration the page gives to its script + * + * @param string $body Page body + * + * @return array + */ + private function getMapsConfig(string $body): array + { + $this->assertSame( + 1, + preg_match('@@s', $body, $matches) + ); + $config = json_decode($matches[1], true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($config); + return $config; + } + /** * Post coordinates for a member * @@ -299,24 +317,26 @@ public function testManagerShowsCoords(): void $request = $this->createRequest('maps_localize_member', ['id' => (string)$member_one->id]); $test_response = $this->app->handle($request); $this->assertSame(200, $test_response->getStatusCode()); - $body = (string)$test_response->getBody(); - $this->assertStringContainsString('48.850000', $body); - $this->assertStringNotContainsString('id="removecoords"', $body); - $this->assertStringNotContainsString('onMapClick', $body); + $member = $this->getMapsConfig((string)$test_response->getBody())['member']; + $this->assertSame(['latitude' => '48.850000', 'longitude' => '2.350000'], $member['position']); + $this->assertFalse($member['can_edit']); $this->preferences->pref_bool_groupsmanagers_edit_member = true; $test_response = $this->app->handle($request); $this->assertSame(200, $test_response->getStatusCode()); - $body = (string)$test_response->getBody(); - $this->assertStringContainsString('id="removecoords"', $body); - $this->assertStringContainsString('onMapClick', $body); + $member = $this->getMapsConfig((string)$test_response->getBody())['member']; + $this->assertTrue($member['can_edit']); + $this->assertSame( + $this->routeparser->urlFor('maps_ilivehere', ['id' => (string)$member_one->id]), + $member['store_url'] + ); } /** - * Nicknames and company names in map popups are not interpreted as HTML + * Nicknames and company names reach the script as data, never as markup * * Member form strips tags, stored values may not have been through it. - * Names are safe anyway: Adherent::getNameWithCase() strips tags. + * The script shows them as text; the page must not hold them as HTML. */ public function testMapEscapesNames(): void { @@ -333,11 +353,14 @@ public function testMapEscapesNames(): void $test_response = $this->app->handle($this->createRequest('maps_map')); $this->assertSame(200, $test_response->getStatusCode()); $body = (string)$test_response->getBody(); - //a JS escaped "<" would be turned back into markup by the popup - $this->assertStringNotContainsString('\u003Cb\u003E', $body); - $this->assertStringNotContainsString('\u003Cimg', $body); - $this->assertStringContainsString('\u0026lt\u003Bb\u0026gt\u003Bnick', $body); - $this->assertStringContainsString('\u0026lt\u003Bimg\u0020src', $body); + $this->assertStringNotContainsString('', $body); + $this->assertStringNotContainsString('getMapsConfig($body)['markers']; + $this->assertCount(1, $markers); + $this->assertSame('nick', $markers[0]['nickname']); + $this->assertSame('', $markers[0]['company']); + //member IDs are not published + $this->assertArrayNotHasKey('id_adh', $markers[0]); } /** @@ -362,10 +385,8 @@ public function testPublicMap(): void $this->preferences->pref_publicpages_visibility_generic = \Galette\Core\Preferences::PUBLIC_PAGES_VISIBILITY_PUBLIC; $test_response = $this->app->handle($request); $this->assertSame(200, $test_response->getStatusCode()); - $body = (string)$test_response->getBody(); - $this->assertStringContainsString('_mapsBinded', $body); //member one does not display its information - $this->assertStringNotContainsString('48.850000', $body); + $this->assertSame([], $this->getMapsConfig((string)$test_response->getBody())['markers']); } /** @@ -385,16 +406,19 @@ public function testOwnPage(): void $this->assertSame(200, $test_response->getStatusCode()); $body = (string)$test_response->getBody(); $this->assertStringNotContainsString('id="possible_towns"', $body); - $this->assertStringNotContainsString('id="removecoords"', $body); - $this->assertStringContainsString('onMapClick', $body); + $config = $this->getMapsConfig($body); + $this->assertNull($config['member']['position']); + $this->assertTrue($config['member']['can_edit']); + $this->assertSame($this->routeparser->urlFor('maps_ilivehere'), $config['member']['store_url']); + $this->assertTrue($config['locate']); (new Coordinates($this->zdb, $this->login))->set($member_one->id, 48.85, 2.35); $test_response = $this->app->handle($request); $this->assertSame(200, $test_response->getStatusCode()); - $body = (string)$test_response->getBody(); - $this->assertStringContainsString('var _lat = 48.850000;', $body); - $this->assertStringContainsString('id="removecoords"', $body); - $this->assertStringContainsString('I\\u0020live\\u0020here\\u0021', $body); + $config = $this->getMapsConfig((string)$test_response->getBody()); + $this->assertSame(['lat' => '48.850000', 'lng' => '2.350000', 'zoom' => 12], $config['center']); + $this->assertSame(['latitude' => '48.850000', 'longitude' => '2.350000'], $config['member']['position']); + $this->assertSame('I live here!', $config['strings']['lives_here']); } /** diff --git a/webroot/galette_maps.js b/webroot/galette_maps.js new file mode 100644 index 0000000..0d44906 --- /dev/null +++ b/webroot/galette_maps.js @@ -0,0 +1,372 @@ +/** + * This file is part of Galette Maps plugin (https://galette.eu). + * SPDX-FileCopyrightText: Copyright © 2012-2026 The Galette Team + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/* + * Maps pages behaviour. + * + * Templates only provide data, as JSON in #maps-config; every text shown in + * the map goes through the DOM (textContent), never through HTML strings. + */ +(function ($) { + 'use strict'; + + /** content the locate control popup is created with, replaced on opening */ + const LOCATE_POPUP = 'maps-locate-popup'; + + /** + * Build an element + * + * @param {string} tag Tag name + * @param {string} [text] Text content + * @param {string} [classes] CSS classes + */ + function el(tag, text, classes) { + const elt = document.createElement(tag); + if (text !== undefined && text !== null) { + elt.textContent = text; + } + if (classes) { + elt.className = classes; + } + return elt; + } + + /** + * Display a message in a modal + * + * @param {string} message Message, displayed as text + * @param {boolean} reload Reload page on close + */ + function showMessage(message, reload) { + $('body').modal({ + class: 'tiny', + content: $('
    ').text(message).html(), + actions: [{ + text: config.strings.close, + click: function () { + if (reload) { + window.location.reload(); + } + } + }], + className: { + title: 'center aligned header', + content: 'center aligned content', + actions: 'center aligned actions' + } + }).modal('show'); + } + + /** + * Post a coordinates change + * + * @param {Object} data Posted data + * @param {HTMLElement} button Button to show as loading + * @param {string} fallback Error message when server gave none + */ + function postCoords(data, button, fallback) { + $.ajax({ + url: config.member.store_url, + type: 'POST', + data: data, + beforeSend: function () { + $(button).addClass('loading'); + }, + complete: function () { + $(button).removeClass('loading'); + }, + success: function (res) { + showMessage(res.message, true); + }, + error: function (xhr) { + showMessage(xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : fallback, false); + } + }); + } + + /** + * Build the "I live here" popup content for a position + * + * @param {L.LatLng} latlng Position + * @param {string} [name] Place name + */ + function livesHereContent(latlng, name) { + //a click on a copy of the world gives a longitude beyond 180 + const position = latlng.wrap(); + //the precision coordinates are stored with + const shown = position.lat.toFixed(6) + '/' + position.lng.toFixed(6); + const content = el('div'); + const where = el('p'); + if (name) { + where.appendChild(el('strong', name)); + where.appendChild(el('br')); + where.appendChild(el('em', shown)); + } else { + //"You clicked at %p", the position in italics + const parts = config.strings.clicked_at.split('%p'); + where.appendChild(document.createTextNode(parts[0])); + where.appendChild(el('em', shown)); + where.appendChild(document.createTextNode(parts.slice(1).join('%p'))); + } + content.appendChild(where); + + const button = el('button', config.strings.lives_here, 'ui button'); + button.type = 'button'; + button.addEventListener('click', function () { + postCoords( + {latitude: position.lat, longitude: position.lng}, + button, + config.strings.store_error + ); + }); + const actions = el('p'); + actions.appendChild(button); + content.appendChild(actions); + + return content; + } + + /** + * Build the known position popup content + */ + function knownPositionContent() { + const content = el('div'); + content.appendChild(el('strong', config.member.name)); + content.appendChild(el('br')); + content.appendChild(document.createTextNode(config.strings.lives_here)); + + if (config.member.can_edit) { + content.appendChild(el('br')); + const button = el('button', config.strings.remove, 'ui button'); + button.type = 'button'; + button.addEventListener('click', function () { + $('body').modal({ + title: config.strings.remove_title, + class: 'tiny', + content: $('
    ').text(config.strings.remove_confirm).html(), + actions: [{ + text: config.strings.remove, + class: 'red confirm_remove', + icon: 'trash alt', + click: function () { + postCoords({remove: true}, $('.confirm_remove'), config.strings.remove_error); + } + }, { + text: config.strings.close + }], + className: { + title: 'center aligned header', + content: 'center aligned content', + actions: 'center aligned actions' + } + }).modal('show'); + }); + content.appendChild(button); + } + + return content; + } + + /** + * Does the browser render vector tiles? They need WebGL 2 + */ + function hasWebGL2() { + try { + return !!(window.WebGL2RenderingContext && document.createElement('canvas').getContext('webgl2')); + } catch (err) { + return false; + } + } + + /** + * Add background map + * + * @param {L.Map} map Map + */ + function addTiles(map) { + const tiles = config.tiles; + if (tiles.vector && hasWebGL2()) { + const options = {style: tiles.url}; + if (tiles.attribution !== '') { + //given one, it wins; otherwise the bridge reads it off the style sources + options.attributionControl = {customAttribution: tiles.attribution}; + } + L.maplibreGL(options).addTo(map); + return; + } + + const raster = tiles.vector ? tiles.fallback : tiles; + const options = { + maxZoom: raster.maxzoom, + attribution: raster.attribution + }; + if (raster.subdomains) { + options.subdomains = raster.subdomains; + } + L.tileLayer(raster.url, options).addTo(map); + } + + /** + * Build the map and its controls + */ + function buildMap() { + const map = L.map('map', { + gestureHandling: true, + maxZoom: config.tiles.maxzoom + }).setView([config.center.lat, config.center.lng], config.center.zoom); + + new L.Control.FullScreen({ + position: 'topleft', + title: config.strings.fullscreen, + titleCancel: config.strings.fullscreen_exit, + forceSeparateButton: true + }).addTo(map); + + L.Control.geocoder({ + collapsed: false, + placeholder: config.strings.search_placeholder, + errorMessage: config.strings.search_error, + iconLabel: config.strings.search + }).addTo(map); + + map.addControl(new L.Control.Legend({position: 'topright'})); + $('.legend-container').append($('#legend')); + const toggle = el('span', null, 'legend-toggle-icon'); + const icon = el('i', null, 'big info circle blue icon'); + icon.setAttribute('aria-hidden', 'true'); + toggle.appendChild(icon); + toggle.appendChild(document.createTextNode(' ' + config.strings.legend)); + $('.legend-toggle').append(toggle); + + if (config.locate) { + L.control.locate({ + strings: { + title: config.strings.locate, + popup: LOCATE_POPUP, + outsideMapBoundsMsg: config.strings.locate_outside + } + }).addTo(map); + } + + addTiles(map); + return map; + } + + /** + * Members map + * + * @param {L.Map} map Map + */ + function showMembers(map) { + const group = L.markerClusterGroup(); + config.markers.forEach(function (m) { + const content = el('p'); + content.appendChild(el('strong', m.name)); + if (m.nickname) { + content.appendChild(document.createTextNode(' ' + config.strings.aka + ' ')); + content.appendChild(el('em', m.nickname)); + } + if (m.company) { + content.appendChild(el('br')); + content.appendChild(document.createTextNode(m.company)); + } + const marker = L.marker( + [parseFloat(m.lat), parseFloat(m.lng)], + {icon: m.company ? icons.company : icons.member} + ); + marker.bindPopup(content); + group.addLayer(marker); + }); + map.addLayer(group); + if (config.markers.length > 0) { + map.fitBounds(group.getBounds(), {padding: [50, 50], maxZoom: 12}); + } + } + + /** + * Member localization + * + * @param {L.Map} map Map + */ + function localizeMember(map) { + if (config.member.can_edit) { + map.on('click', function (e) { + L.popup().setLatLng(e.latlng).setContent(livesHereContent(e.latlng)).openOn(map); + }); + map.on('popupopen', function (e) { + if (e.popup.getContent() === LOCATE_POPUP) { + e.popup.setContent(livesHereContent(e.popup.getLatLng())); + } + }); + } + + if (config.member.position) { + L.marker( + [parseFloat(config.member.position.latitude), parseFloat(config.member.position.longitude)], + {icon: icons.member} + ).addTo(map).bindPopup(knownPositionContent()).openPopup(); + return; + } + + const towns = $('#possible_towns'); + if (towns.length === 0) { + return; + } + towns.modal('show'); + towns.find('.maps-town').on('click', function () { + const town = $(this); + const latlng = L.latLng(parseFloat(town.data('lat')), parseFloat(town.data('lng'))); + towns.modal('hide'); + map.setView(latlng, 13); + L.marker(latlng).addTo(map) + .bindPopup(livesHereContent(latlng, String(town.data('name')))) + .openPopup(); + }); + } + + /** + * Preferences page: own values are only meaningful with the custom provider + */ + function preferences() { + const custom = $('#maps_custom_tiles'); + $('#pref_maps_tiles_provider').on('change', function () { + custom.toggleClass('displaynone', this.value !== custom.data('custom')); + }); + } + + let config = null; + let icons = null; + + $(function () { + if ($('#maps_custom_tiles').length > 0) { + preferences(); + } + + const source = document.getElementById('maps-config'); + if (source === null) { + return; + } + config = JSON.parse(source.textContent); + + const icon_options = { + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] + }; + icons = { + member: L.icon($.extend({iconUrl: config.icons.member}, icon_options)), + company: L.icon($.extend({iconUrl: config.icons.company}, icon_options)) + }; + + const map = buildMap(); + if (config.markers) { + showMembers(map); + } else if (config.member) { + localizeMember(map); + } + }); +})(jQuery); From 9e26af59e9e28a02bd52fbbabcd048aac512d229 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 10:23:43 +0200 Subject: [PATCH 2/5] Make proposed towns keyboard buttons, and the legend a list --- templates/default/common_html.html.twig | 41 +++++++------------ templates/default/mymap.html.twig | 16 +++----- .../tests/units/MapsController.php | 2 +- webroot/galette_maps.css | 18 ++++++++ 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/templates/default/common_html.html.twig b/templates/default/common_html.html.twig index f1b21f8..c62bee3 100644 --- a/templates/default/common_html.html.twig +++ b/templates/default/common_html.html.twig @@ -4,31 +4,20 @@ # SPDX-License-Identifier: GPL-3.0-or-later #} +{# images are decorative: each one comes with its text #}
    - - - - - - - - - - - - - -
    - {{ _T( - - {{ _T("Member") }} -
    - {{ _T( - - {{ _T("Member (company)", "maps") }} -
    - {{ _T( - - {{ _T("Search result", "maps") }} -
    +
      +
    • + + {{ _T("Member") }} +
    • +
    • + + {{ _T("Member (company)", "maps") }} +
    • +
    • + + {{ _T("Search result", "maps") }} +
    • +
    diff --git a/templates/default/mymap.html.twig b/templates/default/mymap.html.twig index 7be577c..18b06d5 100644 --- a/templates/default/mymap.html.twig +++ b/templates/default/mymap.html.twig @@ -22,26 +22,20 @@

    {{ _T("Select your town.", "maps") }}
    {{ _T("In the database, town is set to: '%town'", "maps")|replace({'%town': member.town}) }}

    -
      -
    -
    {{ _T("Close") }}
    +
    {% endif %} diff --git a/tests/GaletteMaps/Controllers/tests/units/MapsController.php b/tests/GaletteMaps/Controllers/tests/units/MapsController.php index d61732d..1bcb4e3 100644 --- a/tests/GaletteMaps/Controllers/tests/units/MapsController.php +++ b/tests/GaletteMaps/Controllers/tests/units/MapsController.php @@ -248,7 +248,7 @@ public function testTownsProposed(): void $this->assertSame(200, $test_response->getStatusCode()); $body = (string)$test_response->getBody(); $this->assertStringContainsString('id="possible_towns"', $body); - $this->assertStringContainsString('50.3620/3.4729', $body); + $this->assertStringContainsString('data-lat="50.3620" data-lng="3.4729"', $body); $this->assertStringContainsString('<b>Petit</b> Valenciennes', $body); $this->assertStringNotContainsString('Petit', $body); } diff --git a/webroot/galette_maps.css b/webroot/galette_maps.css index b4e8dfd..a052629 100644 --- a/webroot/galette_maps.css +++ b/webroot/galette_maps.css @@ -10,10 +10,28 @@ min-height: 100%; } +#possible_towns ul, +#map .maps-legend { + list-style: none; + margin: 0; + padding: 0; +} + #possible_towns li { margin-bottom: .5em; } +#map .maps-legend li { + display: flex; + align-items: center; + gap: .5em; + margin: .2em 0; +} + +#map .maps-legend img { + height: 25px; +} + .leaflet-control-legend a { background-image: url(images/legend.png); background-position: -2px -2px; From a32a541fbb311178c6f30dda37c52ca864ca1b07 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 10:24:51 +0200 Subject: [PATCH 3/5] Name the towns modal as a dialog --- templates/default/mymap.html.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/default/mymap.html.twig b/templates/default/mymap.html.twig index 18b06d5..38f52da 100644 --- a/templates/default/mymap.html.twig +++ b/templates/default/mymap.html.twig @@ -10,8 +10,8 @@
    {% if towns is defined and towns|length > 0 %} -