From a135edd0cd50d31e553bf8dad0cdad19451e7cea Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 14:27:46 -0300 Subject: [PATCH 01/16] [ADD] web_leaflet_lib: Add geocoding and routing support Add geocoding capabilities with Nominatim (OSM) and MapBox providers: - GeocodingMixin for address-to-coordinates and reverse geocoding - Settings UI for configuring providers, API tokens, and URLs - Rate limiting (throttling) for OSM Nominatim API compliance - Batch geocoding support with configurable delays - Routing configuration for OSRM and MapBox Co-authored-by: Luis Felipe Mileo --- requirements.txt | 1 + web_leaflet_lib/README.rst | 17 +- web_leaflet_lib/__manifest__.py | 17 +- web_leaflet_lib/data/ir_config_parameter.xml | 50 ++- web_leaflet_lib/models/__init__.py | 2 + web_leaflet_lib/models/geocoding_mixin.py | 302 ++++++++++++++++++ web_leaflet_lib/models/ir_http.py | 27 ++ web_leaflet_lib/models/res_config_settings.py | 92 ++++++ web_leaflet_lib/readme/CONTRIBUTORS.md | 2 + web_leaflet_lib/static/description/index.html | 37 ++- web_leaflet_lib/tests/__init__.py | 4 + web_leaflet_lib/tests/test_geocoding.py | 100 ++++++ web_leaflet_lib/views/res_config_settings.xml | 99 ++++++ 13 files changed, 707 insertions(+), 43 deletions(-) create mode 100644 web_leaflet_lib/models/geocoding_mixin.py create mode 100644 web_leaflet_lib/models/res_config_settings.py create mode 100644 web_leaflet_lib/tests/__init__.py create mode 100644 web_leaflet_lib/tests/test_geocoding.py create mode 100644 web_leaflet_lib/views/res_config_settings.xml diff --git a/requirements.txt b/requirements.txt index 9be6257ef..a1bac4fe9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # generated from manifests external_dependencies geojson +requests shapely diff --git a/web_leaflet_lib/README.rst b/web_leaflet_lib/README.rst index 3f9b1be4b..5ba5f7e4e 100644 --- a/web_leaflet_lib/README.rst +++ b/web_leaflet_lib/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ========================== Leaflet Javascript Library ========================== @@ -17,7 +13,7 @@ Leaflet Javascript Library .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github @@ -109,11 +105,15 @@ Authors ------- * GRAP +* KMEE Contributors ------------ - Sylvain LE GAL (https://www.twitter.com/legalsylvain) +- `KMEE `__: + + - Luis Felipe Mileo Other credits ------------- @@ -140,10 +140,13 @@ promote its widespread use. .. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px :target: https://github.com/legalsylvain :alt: legalsylvain +.. |maintainer-mileo| image:: https://github.com/mileo.png?size=40px + :target: https://github.com/mileo + :alt: mileo -Current `maintainer `__: +Current `maintainers `__: -|maintainer-legalsylvain| +|maintainer-legalsylvain| |maintainer-mileo| This module is part of the `OCA/geospatial `_ project on GitHub. diff --git a/web_leaflet_lib/__manifest__.py b/web_leaflet_lib/__manifest__.py index 5405145c8..cfc41baa6 100644 --- a/web_leaflet_lib/__manifest__.py +++ b/web_leaflet_lib/__manifest__.py @@ -1,18 +1,25 @@ # Copyright (C) 2024 - Today: GRAP (http://www.grap.coop) # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { "name": "Leaflet Javascript Library", - "summary": "Bring leaflet.js librairy in odoo.", - "version": "18.0.1.1.0", - "author": "GRAP, Odoo Community Association (OCA)", - "maintainers": ["legalsylvain"], + "summary": "Bring leaflet.js library in Odoo with geocoding and routing support.", + "version": "18.0.1.2.0", + "author": "GRAP, KMEE, Odoo Community Association (OCA)", + "maintainers": ["legalsylvain", "mileo"], "website": "https://github.com/OCA/geospatial", "license": "AGPL-3", "category": "Extra Tools", "depends": ["base"], - "data": ["data/ir_config_parameter.xml"], + "external_dependencies": { + "python": ["requests"], + }, + "data": [ + "data/ir_config_parameter.xml", + "views/res_config_settings.xml", + ], "assets": { "web.assets_backend": [ "/web_leaflet_lib/static/lib/leaflet/*", diff --git a/web_leaflet_lib/data/ir_config_parameter.xml b/web_leaflet_lib/data/ir_config_parameter.xml index 51d750053..b8af736b0 100644 --- a/web_leaflet_lib/data/ir_config_parameter.xml +++ b/web_leaflet_lib/data/ir_config_parameter.xml @@ -2,27 +2,53 @@ - + + leaflet.copyright OpenStreetMap]]> - + >OpenStreetMap]]> + + + leaflet.tile_url + False + + - leaflet.tile_url - False + leaflet.geocoding_provider + nominatim + + + leaflet.nominatim_url + https://nominatim.openstreetmap.org + + + leaflet.geocoding_throttle_ms + 1000 + + + + + leaflet.routing_provider + osrm + + + leaflet.osrm_url + https://router.project-osrm.org + + + leaflet.max_waypoints + 25 diff --git a/web_leaflet_lib/models/__init__.py b/web_leaflet_lib/models/__init__.py index 9a5eb7187..9fcbb3adb 100644 --- a/web_leaflet_lib/models/__init__.py +++ b/web_leaflet_lib/models/__init__.py @@ -1 +1,3 @@ +from . import geocoding_mixin from . import ir_http +from . import res_config_settings diff --git a/web_leaflet_lib/models/geocoding_mixin.py b/web_leaflet_lib/models/geocoding_mixin.py new file mode 100644 index 000000000..0bcc0e4f9 --- /dev/null +++ b/web_leaflet_lib/models/geocoding_mixin.py @@ -0,0 +1,302 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging +import time +from functools import wraps + +import requests + +from odoo import api, models + +_logger = logging.getLogger(__name__) + +# Throttling for Nominatim API (OSM requirement: max 1 request per second) +NOMINATIM_THROTTLE_MS = 1000 +_last_nominatim_request = 0 + + +def throttle_nominatim(func): + """Decorator to enforce Nominatim rate limiting (1 req/sec).""" + + @wraps(func) + def wrapper(*args, **kwargs): + global _last_nominatim_request + now = time.time() * 1000 # Convert to milliseconds + elapsed = now - _last_nominatim_request + if elapsed < NOMINATIM_THROTTLE_MS: + sleep_time = (NOMINATIM_THROTTLE_MS - elapsed) / 1000 + _logger.debug("Throttling Nominatim request, sleeping %.2fs", sleep_time) + time.sleep(sleep_time) + _last_nominatim_request = time.time() * 1000 + return func(*args, **kwargs) + + return wrapper + + +class GeocodingMixin(models.AbstractModel): + """Mixin providing geocoding capabilities using Nominatim (OSM) or MapBox.""" + + _name = "leaflet.geocoding.mixin" + _description = "Leaflet Geocoding Mixin" + + @api.model + def _get_geocoding_provider(self): + """Get the configured geocoding provider.""" + config = self.env["ir.config_parameter"].sudo() + provider = config.get_param("leaflet.geocoding_provider", "nominatim") + return provider + + @api.model + def _get_mapbox_token(self): + """Get MapBox API token if configured.""" + config = self.env["ir.config_parameter"].sudo() + return config.get_param("leaflet.mapbox_token", "") + + @api.model + def _get_nominatim_url(self): + """Get Nominatim server URL.""" + config = self.env["ir.config_parameter"].sudo() + return config.get_param( + "leaflet.nominatim_url", "https://nominatim.openstreetmap.org" + ) + + @api.model + def geocode_address(self, address, country_code=None): + """ + Geocode an address to coordinates. + + Args: + address: String address to geocode + country_code: Optional 2-letter ISO country code to limit results + + Returns: + dict with 'lat', 'lng', 'display_name' or None if not found + """ + provider = self._get_geocoding_provider() + mapbox_token = self._get_mapbox_token() + + # Try MapBox first if token is configured + if provider == "mapbox" or (provider == "auto" and mapbox_token): + result = self._geocode_mapbox(address, country_code) + if result: + return result + if provider == "mapbox": + _logger.warning("MapBox geocoding failed, no fallback configured") + return None + + # Fallback to Nominatim (OSM) + return self._geocode_nominatim(address, country_code) + + @api.model + @throttle_nominatim + def _geocode_nominatim(self, address, country_code=None): + """Geocode using Nominatim (OpenStreetMap).""" + base_url = self._get_nominatim_url() + url = f"{base_url}/search" + + params = { + "q": address, + "format": "json", + "limit": 1, + "addressdetails": 1, + } + if country_code: + params["countrycodes"] = country_code.lower() + + headers = { + "User-Agent": "Odoo-Leaflet-Map/1.0 (contact@yourcompany.com)", + } + + try: + response = requests.get(url, params=params, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + + if data: + result = data[0] + return { + "lat": float(result["lat"]), + "lng": float(result["lon"]), + "display_name": result.get("display_name", address), + "provider": "nominatim", + } + return None + + except requests.RequestException as e: + _logger.warning("Nominatim geocoding failed: %s", e) + return None + + @api.model + def _geocode_mapbox(self, address, country_code=None): + """Geocode using MapBox API.""" + token = self._get_mapbox_token() + if not token: + return None + + import urllib.parse + + encoded_address = urllib.parse.quote(address) + url = ( + f"https://api.mapbox.com/geocoding/v5/mapbox.places/{encoded_address}.json" + ) + + params = { + "access_token": token, + "limit": 1, + } + if country_code: + params["country"] = country_code.lower() + + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + features = data.get("features", []) + if features: + result = features[0] + coords = result["geometry"]["coordinates"] + return { + "lat": coords[1], + "lng": coords[0], + "display_name": result.get("place_name", address), + "provider": "mapbox", + } + return None + + except requests.RequestException as e: + _logger.warning("MapBox geocoding failed: %s", e) + return None + + @api.model + def reverse_geocode(self, lat, lng): + """ + Reverse geocode coordinates to an address. + + Args: + lat: Latitude + lng: Longitude + + Returns: + dict with 'address', 'display_name' or None if not found + """ + provider = self._get_geocoding_provider() + mapbox_token = self._get_mapbox_token() + + # Try MapBox first if token is configured + if provider == "mapbox" or (provider == "auto" and mapbox_token): + result = self._reverse_geocode_mapbox(lat, lng) + if result: + return result + + # Fallback to Nominatim + return self._reverse_geocode_nominatim(lat, lng) + + @api.model + @throttle_nominatim + def _reverse_geocode_nominatim(self, lat, lng): + """Reverse geocode using Nominatim (OpenStreetMap).""" + base_url = self._get_nominatim_url() + url = f"{base_url}/reverse" + + params = { + "lat": lat, + "lon": lng, + "format": "json", + "addressdetails": 1, + } + + headers = { + "User-Agent": "Odoo-Leaflet-Map/1.0 (contact@yourcompany.com)", + } + + try: + response = requests.get(url, params=params, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + + if data and "address" in data: + return { + "address": data.get("address", {}), + "display_name": data.get("display_name", ""), + "provider": "nominatim", + } + return None + + except requests.RequestException as e: + _logger.warning("Nominatim reverse geocoding failed: %s", e) + return None + + @api.model + def _reverse_geocode_mapbox(self, lat, lng): + """Reverse geocode using MapBox API.""" + token = self._get_mapbox_token() + if not token: + return None + + url = f"https://api.mapbox.com/geocoding/v5/mapbox.places/{lng},{lat}.json" + params = { + "access_token": token, + "limit": 1, + } + + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + features = data.get("features", []) + if features: + result = features[0] + return { + "address": result.get("context", {}), + "display_name": result.get("place_name", ""), + "provider": "mapbox", + } + return None + + except requests.RequestException as e: + _logger.warning("MapBox reverse geocoding failed: %s", e) + return None + + @api.model + def validate_coordinates(self, lat, lng): + """ + Validate that coordinates are within valid ranges. + + Args: + lat: Latitude (-90 to 90) + lng: Longitude (-180 to 180) + + Returns: + bool: True if valid, False otherwise + """ + try: + lat = float(lat) + lng = float(lng) + return -90 <= lat <= 90 and -180 <= lng <= 180 + except (TypeError, ValueError): + return False + + @api.model + def batch_geocode(self, addresses, country_code=None, delay_ms=None): + """ + Batch geocode multiple addresses with rate limiting. + + Args: + addresses: List of address strings + country_code: Optional country code to limit results + delay_ms: Optional delay between requests (default: provider-specific) + + Returns: + List of geocoding results (or None for failed lookups) + """ + results = [] + for address in addresses: + result = self.geocode_address(address, country_code) + results.append(result) + # Additional delay if specified (on top of provider throttling) + if delay_ms: + time.sleep(delay_ms / 1000) + return results diff --git a/web_leaflet_lib/models/ir_http.py b/web_leaflet_lib/models/ir_http.py index 9fe6aad64..d5b21ead7 100644 --- a/web_leaflet_lib/models/ir_http.py +++ b/web_leaflet_lib/models/ir_http.py @@ -1,5 +1,6 @@ # Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import models @@ -13,8 +14,34 @@ def session_info(self): config = self.env["ir.config_parameter"].sudo() result.update( { + # Display configuration "leaflet.tile_url": config.get_param("leaflet.tile_url", default=""), "leaflet.copyright": config.get_param("leaflet.copyright", default=""), + # Geocoding configuration + "leaflet.geocoding_provider": config.get_param( + "leaflet.geocoding_provider", default="nominatim" + ), + "leaflet.nominatim_url": config.get_param( + "leaflet.nominatim_url", + default="https://nominatim.openstreetmap.org", + ), + "leaflet.geocoding_throttle_ms": int( + config.get_param("leaflet.geocoding_throttle_ms", default="1000") + ), + # Routing configuration + "leaflet.routing_provider": config.get_param( + "leaflet.routing_provider", default="osrm" + ), + "leaflet.osrm_url": config.get_param( + "leaflet.osrm_url", default="https://router.project-osrm.org" + ), + "leaflet.max_waypoints": int( + config.get_param("leaflet.max_waypoints", default="25") + ), + # MapBox token (if configured) + "leaflet.mapbox_token": config.get_param( + "leaflet.mapbox_token", default="" + ), } ) return result diff --git a/web_leaflet_lib/models/res_config_settings.py b/web_leaflet_lib/models/res_config_settings.py new file mode 100644 index 000000000..868753eac --- /dev/null +++ b/web_leaflet_lib/models/res_config_settings.py @@ -0,0 +1,92 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + # Geocoding Configuration + leaflet_geocoding_provider = fields.Selection( + selection=[ + ("nominatim", "Nominatim (OSM) - Free"), + ("mapbox", "MapBox - Premium"), + ("auto", "Auto (MapBox with Nominatim fallback)"), + ], + string="Geocoding Provider", + default="nominatim", + config_parameter="leaflet.geocoding_provider", + help="Provider for address-to-coordinates conversion.\n" + "Nominatim is free but limited to 1 request/second.\n" + "MapBox requires an API token but has higher limits.", + ) + + leaflet_mapbox_token = fields.Char( + string="MapBox Access Token", + config_parameter="leaflet.mapbox_token", + help="Your MapBox public access token for geocoding and routing.\n" + "Get one at https://account.mapbox.com/access-tokens/", + ) + + leaflet_nominatim_url = fields.Char( + string="Nominatim Server URL", + default="https://nominatim.openstreetmap.org", + config_parameter="leaflet.nominatim_url", + help="Nominatim server URL. Use default for OSM public server\n" + "or specify your own self-hosted instance.", + ) + + # Routing Configuration + leaflet_routing_provider = fields.Selection( + selection=[ + ("osrm", "OSRM - Free"), + ("mapbox", "MapBox - Premium"), + ("auto", "Auto (MapBox with OSRM fallback)"), + ], + string="Routing Provider", + default="osrm", + config_parameter="leaflet.routing_provider", + help="Provider for route calculation and directions.\n" + "OSRM is free and can be self-hosted.\n" + "MapBox requires an API token.", + ) + + leaflet_osrm_url = fields.Char( + string="OSRM Server URL", + default="https://router.project-osrm.org", + config_parameter="leaflet.osrm_url", + help="OSRM routing server URL. Use default for public server\n" + "or specify your own self-hosted instance for production use.", + ) + + # Tile Configuration + leaflet_tile_url = fields.Char( + string="Tile Server URL", + config_parameter="leaflet.tile_url", + help="Custom map tile server URL. Leave empty for default OSM tiles.\n" + "Example: https://api.mapbox.com/styles/v1/{username}/{style_id}/tiles/{z}/{x}/{y}", + ) + + leaflet_copyright = fields.Char( + string="Map Copyright", + config_parameter="leaflet.copyright", + help="Copyright attribution for the map tiles.", + ) + + # Performance Settings + leaflet_geocoding_throttle_ms = fields.Integer( + string="Geocoding Throttle (ms)", + default=1000, + config_parameter="leaflet.geocoding_throttle_ms", + help="Minimum delay between geocoding requests in milliseconds.\n" + "Default: 1000ms (required for OSM Nominatim).", + ) + + leaflet_max_waypoints = fields.Integer( + string="Max Routing Waypoints", + default=25, + config_parameter="leaflet.max_waypoints", + help="Maximum number of waypoints for routing requests.\n" + "OSRM and MapBox limit: 25 waypoints.", + ) diff --git a/web_leaflet_lib/readme/CONTRIBUTORS.md b/web_leaflet_lib/readme/CONTRIBUTORS.md index 4a6b63400..f4fbab081 100644 --- a/web_leaflet_lib/readme/CONTRIBUTORS.md +++ b/web_leaflet_lib/readme/CONTRIBUTORS.md @@ -1 +1,3 @@ - Sylvain LE GAL () +- [KMEE](https://kmee.com.br/): + - Luis Felipe Mileo \<\> diff --git a/web_leaflet_lib/static/description/index.html b/web_leaflet_lib/static/description/index.html index 76651fc02..34afe52fb 100644 --- a/web_leaflet_lib/static/description/index.html +++ b/web_leaflet_lib/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Leaflet Javascript Library -
+
+

Leaflet Javascript Library

- - -Odoo Community Association - -
-

Leaflet Javascript Library

-

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

This module extends odoo to include Leaflet Javacript library.

This module is used by web_view_leaflet_map.

Important Note

@@ -426,7 +421,7 @@

Leaflet Javascript Library

-

Configuration

+

Configuration

  • Go to Settings > Technical > Parameters > System Parameters
  • Create or edit the parameter with the key leaflet.tile_url
  • @@ -435,7 +430,7 @@

    Configuration

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -443,21 +438,26 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • GRAP
  • +
  • KMEE
-

Other credits

+

Other credits

The module embed:

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -474,13 +474,12 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

Current maintainer:

-

legalsylvain

+

Current maintainers:

+

legalsylvain mileo

This module is part of the OCA/geospatial project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/web_leaflet_lib/tests/__init__.py b/web_leaflet_lib/tests/__init__.py new file mode 100644 index 000000000..59227a09d --- /dev/null +++ b/web_leaflet_lib/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_geocoding diff --git a/web_leaflet_lib/tests/test_geocoding.py b/web_leaflet_lib/tests/test_geocoding.py new file mode 100644 index 000000000..397a00f1b --- /dev/null +++ b/web_leaflet_lib/tests/test_geocoding.py @@ -0,0 +1,100 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase + + +class TestGeocodingMixin(TransactionCase): + """Tests for the Leaflet Geocoding Mixin.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.GeocodingMixin = cls.env["leaflet.geocoding.mixin"] + + def test_validate_coordinates_valid(self): + """Test coordinate validation with valid coordinates.""" + self.assertTrue(self.GeocodingMixin.validate_coordinates(45.0, 90.0)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(-45.0, -90.0)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(0, 0)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(90, 180)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(-90, -180)) + + def test_validate_coordinates_invalid(self): + """Test coordinate validation with invalid coordinates.""" + self.assertFalse(self.GeocodingMixin.validate_coordinates(91, 0)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(-91, 0)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(0, 181)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(0, -181)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(None, None)) + self.assertFalse(self.GeocodingMixin.validate_coordinates("invalid", 0)) + + def test_get_geocoding_provider_default(self): + """Test default geocoding provider is nominatim.""" + provider = self.GeocodingMixin._get_geocoding_provider() + self.assertEqual(provider, "nominatim") + + def test_get_nominatim_url_default(self): + """Test default Nominatim URL.""" + url = self.GeocodingMixin._get_nominatim_url() + self.assertEqual(url, "https://nominatim.openstreetmap.org") + + @patch("odoo.addons.web_leaflet_lib.models.geocoding_mixin.requests.get") + def test_geocode_nominatim_success(self, mock_get): + """Test successful Nominatim geocoding.""" + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "lat": "-22.9068", + "lon": "-43.1729", + "display_name": "Rio de Janeiro, Brazil", + } + ] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = self.GeocodingMixin._geocode_nominatim("Rio de Janeiro, Brazil", "BR") + + self.assertIsNotNone(result) + self.assertAlmostEqual(result["lat"], -22.9068, places=4) + self.assertAlmostEqual(result["lng"], -43.1729, places=4) + self.assertEqual(result["provider"], "nominatim") + + @patch("odoo.addons.web_leaflet_lib.models.geocoding_mixin.requests.get") + def test_geocode_nominatim_not_found(self, mock_get): + """Test Nominatim geocoding when address not found.""" + mock_response = MagicMock() + mock_response.json.return_value = [] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = self.GeocodingMixin._geocode_nominatim("NonexistentPlace12345", None) + + self.assertIsNone(result) + + @patch("odoo.addons.web_leaflet_lib.models.geocoding_mixin.requests.get") + def test_reverse_geocode_nominatim_success(self, mock_get): + """Test successful Nominatim reverse geocoding.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "address": { + "city": "Rio de Janeiro", + "country": "Brazil", + }, + "display_name": "Rio de Janeiro, Brazil", + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = self.GeocodingMixin._reverse_geocode_nominatim(-22.9068, -43.1729) + + self.assertIsNotNone(result) + self.assertEqual(result["display_name"], "Rio de Janeiro, Brazil") + self.assertEqual(result["provider"], "nominatim") + + def test_batch_geocode_empty_list(self): + """Test batch geocoding with empty list.""" + result = self.GeocodingMixin.batch_geocode([]) + self.assertEqual(result, []) diff --git a/web_leaflet_lib/views/res_config_settings.xml b/web_leaflet_lib/views/res_config_settings.xml new file mode 100644 index 000000000..7fa1719e4 --- /dev/null +++ b/web_leaflet_lib/views/res_config_settings.xml @@ -0,0 +1,99 @@ + + + + + res.config.settings.view.form.leaflet + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ms + + + + + + + + + + From 1f6d7eb2485ed35db0b876ac007777eab5dfa5ab Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 14:35:13 -0300 Subject: [PATCH 02/16] [ADD] web_view_leaflet_map: Add sidebar, routing, and enhanced markers Major feature update with the following enhancements: Sidebar/Pin List: - Collapsible sidebar showing all map markers in a list - Search and filter within the sidebar - Click items to center map on marker - Visual grouping by field (e.g., category) - Show located/unlocated record counts - Draggable variant for reordering Enhanced Markers: - Numbered markers showing sequence - Color coding by group field - Google Maps navigation buttons in popups - Coordinate validation (lat: -90..90, lng: -180..180) Routing Support: - Route polylines between markers - Group-based route coloring Architecture: - Refactored to proper Odoo 18 MVC view architecture - Separate arch parser, model, controller, renderer - Loading overlay for better UX - Responsive design for mobile New view attributes: show_pin_list, panel_title, numbered_markers, routing, enable_navigation, group_by Co-authored-by: Luis Felipe Mileo --- web_view_leaflet_map/README.rst | 171 +++++- web_view_leaflet_map/__manifest__.py | 32 +- web_view_leaflet_map/readme/CONFIGURE.md | 69 ++- web_view_leaflet_map/readme/CONTRIBUTORS.md | 5 +- web_view_leaflet_map/readme/DESCRIPTION.md | 49 +- .../static/description/index.html | 272 ++++++-- .../pin-list/draggable_pin_list.css | 121 ++++ .../pin-list/draggable_pin_list.esm.js | 219 +++++++ .../pin-list/draggable_pin_list.xml | 205 ++++++ .../src/components/pin-list/pin_list.css | 303 +++++++++ .../src/components/pin-list/pin_list.esm.js | 265 ++++++++ .../src/components/pin-list/pin_list.xml | 173 ++++++ .../leaflet_map_arch_parser.esm.js | 152 +++++ .../leaflet_map_controller.esm.js | 168 +++++ .../leaflet_map_view/leaflet_map_model.esm.js | 268 ++++++++ .../leaflet_map_renderer.esm.js | 581 ++++++++++++++++++ .../leaflet_map_view/leaflet_map_view.esm.js | 74 +++ .../src/leaflet_map_view/leaflet_map_view.xml | 62 ++ .../leaflet_map/leaflet_map_renderer.css | 62 +- 19 files changed, 3144 insertions(+), 107 deletions(-) create mode 100644 web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css create mode 100644 web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js create mode 100644 web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml create mode 100644 web_view_leaflet_map/static/src/components/pin-list/pin_list.css create mode 100644 web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js create mode 100644 web_view_leaflet_map/static/src/components/pin-list/pin_list.xml create mode 100644 web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js create mode 100644 web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js create mode 100644 web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js create mode 100644 web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js create mode 100644 web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js create mode 100644 web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml diff --git a/web_view_leaflet_map/README.rst b/web_view_leaflet_map/README.rst index e421864bd..2f02c57d0 100644 --- a/web_view_leaflet_map/README.rst +++ b/web_view_leaflet_map/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================================ Leaflet Map View (OpenStreetMap) ================================ @@ -11,13 +7,13 @@ Leaflet Map View (OpenStreetMap) !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:74bf9361fbf3e37a4355f272c0cfb21bff1711d71639a7164dfe915bfc7773ab + !! source digest: sha256:f9a77df7f01c74ae830ace8e6334efc9780ff34d21ff2f50cb4bd2e63576aa81 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github @@ -32,31 +28,53 @@ Leaflet Map View (OpenStreetMap) |badge1| |badge2| |badge3| |badge4| |badge5| -This module extends odoo views, to add a new kind of view, named -``leaflet_map`` that is using the Leaflet javascript library to use -maps. (https://leafletjs.com/) This library is for exemple, used in the -OpenStreetMap project. (https://www.openstreetmap.org/) +This module extends Odoo views to add a new kind of view named +``leaflet_map`` that uses the Leaflet javascript library for interactive +maps. (https://leafletjs.com/) This library is used by projects like +OpenStreetMap. (https://www.openstreetmap.org/) + +**Core Map View:** + +- Display records as markers on an interactive map +- Automatic marker clustering for better visibility when zoomed out +- Custom marker icons from image fields +- Click markers to open popups with record details +- Click popup to navigate to record form view + +**Sidebar/Pin List (NEW):** + +- Collapsible sidebar showing all map markers in a list +- Search and filter within the sidebar +- Click items to center map on marker +- Visual grouping by field (e.g., category) +- Show located/unlocated record counts + +**Enhanced Markers (NEW):** -You can see a simple usage in the module -``web_view_leaflet_map_partner`` in the same OCA repository that -displays your contact in a map, if latitude and longitude are defined. -(To define latitude and longitude, refer to the Odoo module -``base_geolocalize``) +- Numbered markers showing sequence +- Color coding by group +- Google Maps navigation buttons in popups +- Coordinate validation (lat: -90..90, lng: -180..180) -A marker will be displayed for each item that has a localization. +**Routing Support (NEW - requires web_leaflet_routing):** -|image1| +- Route polylines between markers +- OSRM integration for real road routes +- Route distance and duration display -If user zooms out, the markers will overlap, which won't be very -visible. +See ``web_view_leaflet_map_partner`` module for a complete example that +displays contacts on a map with avatar markers, grouped sidebar, +auto-geocoding, and Google Maps navigation. -In that case, nearby markers are grouped together, thanks to +|Precise Map View| + +If user zooms out, nearby markers are grouped together thanks to ``Leaflet.markercluster`` plugin. -|image2| +|Large Map View| -.. |image1| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_precise.png -.. |image2| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_large.png +.. |Precise Map View| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_precise.png +.. |Large Map View| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_large.png **Table of contents** @@ -66,7 +84,92 @@ In that case, nearby markers are grouped together, thanks to Configuration ============= -- See configuration of the module ``web_leaflet_lib``. +To use this view, define a ``leaflet_map`` view in your XML: + +.. code:: xml + + + + + + + + + +**Required Attributes:** + +=================== ====================================== +Attribute Description +=================== ====================================== +``field_latitude`` Field containing latitude coordinates +``field_longitude`` Field containing longitude coordinates +=================== ====================================== + +**Display Attributes:** + ++-----------------------------+------------------+-------------------------+ +| Attribute | Default | Description | ++=============================+==================+=========================+ +| ``field_title`` | ``display_name`` | Field for marker | +| | | title/label | ++-----------------------------+------------------+-------------------------+ +| ``field_address`` | - | Field for address in | +| | | popup | ++-----------------------------+------------------+-------------------------+ +| ``field_marker_icon_image`` | - | Image field for custom | +| | | marker icon | ++-----------------------------+------------------+-------------------------+ +| ``marker_icon_size_x`` | 64 | Custom icon width in | +| | | pixels | ++-----------------------------+------------------+-------------------------+ +| ``marker_icon_size_y`` | 64 | Custom icon height in | +| | | pixels | ++-----------------------------+------------------+-------------------------+ + +**Map Options:** + +================ ======= ====================== +Attribute Default Description +================ ======= ====================== +``default_zoom`` 7 Initial map zoom level +``max_zoom`` 19 Maximum zoom level +``zoom_snap`` 1 Zoom level increments +================ ======= ====================== + +**New Attributes (v18.0.1.2.0):** + +===================== =========== ====================================== +Attribute Default Description +===================== =========== ====================================== +``show_pin_list`` "1" Show sidebar with marker list +``panel_title`` "Locations" Title for the sidebar +``numbered_markers`` "0" Show numbered markers instead of icons +``routing`` "0" Enable route polylines between markers +``enable_navigation`` "1" Show Google Maps navigation buttons +``group_by`` - Field for grouping markers by color +===================== =========== ====================================== + +**System Configuration:** + +Configure geocoding and routing providers in Settings > Leaflet Maps: + +- Geocoding Provider: Nominatim (OSM) free with 1 req/s limit, or MapBox + (premium) +- Routing Provider: OSRM free and self-hostable, or MapBox (premium) +- Default Nominatim URL: ``https://nominatim.openstreetmap.org`` +- Default OSRM URL: ``https://router.project-osrm.org`` Development =========== @@ -137,14 +240,6 @@ Known issues / Roadmap items if longitude and latitude are available. We could imagine other kind of usages, with Polylines, Polygons, etc... See all the leaflet options : https://leafletjs.com/reference.html -- Search bar is not implemented in this view. All records are displayed - for now. We should: - - - implement records refresh, when adding / removing domain in the - search bar. - - implement a custom search based on the displayed map. (no need to - load records that are out of the scope of the current displayed - map). Bug Tracker =========== @@ -163,12 +258,17 @@ Authors ------- * GRAP +* KMEE Contributors ------------ - Sylvain LE GAL (https://www.twitter.com/legalsylvain) +- `KMEE `__: + + - Luis Felipe Mileo + Maintainers ----------- @@ -185,10 +285,13 @@ promote its widespread use. .. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px :target: https://github.com/legalsylvain :alt: legalsylvain +.. |maintainer-mileo| image:: https://github.com/mileo.png?size=40px + :target: https://github.com/mileo + :alt: mileo -Current `maintainer `__: +Current `maintainers `__: -|maintainer-legalsylvain| +|maintainer-legalsylvain| |maintainer-mileo| This module is part of the `OCA/geospatial `_ project on GitHub. diff --git a/web_view_leaflet_map/__manifest__.py b/web_view_leaflet_map/__manifest__.py index 96549cd84..98ed3e5fc 100644 --- a/web_view_leaflet_map/__manifest__.py +++ b/web_view_leaflet_map/__manifest__.py @@ -1,13 +1,14 @@ # Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { "name": "Leaflet Map View (OpenStreetMap)", - "summary": "Add new 'leaflet_map' view, to display markers.", - "version": "18.0.1.1.2", - "author": "GRAP, Odoo Community Association (OCA)", - "maintainers": ["legalsylvain"], + "summary": "Leaflet map view with sidebar, routing, and numbered markers.", + "version": "18.0.2.0.0", + "author": "GRAP, KMEE, Odoo Community Association (OCA)", + "maintainers": ["legalsylvain", "mileo"], "website": "https://github.com/OCA/geospatial", "license": "AGPL-3", "category": "Extra Tools", @@ -17,12 +18,23 @@ ], "assets": { "web.assets_backend": [ - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js", + # Pin List component (base) + "web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js", + "web_view_leaflet_map/static/src/components/pin-list/pin_list.xml", + "web_view_leaflet_map/static/src/components/pin-list/pin_list.css", + # Draggable Pin List component (generic drag-drop) + "web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js", + "web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml", + "web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css", + # Leaflet Map View - MVC architecture + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml", + # Shared styles + "web_view_leaflet_map/static/src/components/map-component/web_view_leaflet_map.css", ], }, "installable": True, diff --git a/web_view_leaflet_map/readme/CONFIGURE.md b/web_view_leaflet_map/readme/CONFIGURE.md index 801711609..ed5898df0 100644 --- a/web_view_leaflet_map/readme/CONFIGURE.md +++ b/web_view_leaflet_map/readme/CONFIGURE.md @@ -1 +1,68 @@ -- See configuration of the module `web_leaflet_lib`. +To use this view, define a `leaflet_map` view in your XML: + +```xml + + + + + + + +``` + +**Required Attributes:** + +| Attribute | Description | +|-----------|-------------| +| `field_latitude` | Field containing latitude coordinates | +| `field_longitude` | Field containing longitude coordinates | + +**Display Attributes:** + +| Attribute | Default | Description | +|-----------|---------|-------------| +| `field_title` | `display_name` | Field for marker title/label | +| `field_address` | - | Field for address in popup | +| `field_marker_icon_image` | - | Image field for custom marker icon | +| `marker_icon_size_x` | 64 | Custom icon width in pixels | +| `marker_icon_size_y` | 64 | Custom icon height in pixels | + +**Map Options:** + +| Attribute | Default | Description | +|-----------|---------|-------------| +| `default_zoom` | 7 | Initial map zoom level | +| `max_zoom` | 19 | Maximum zoom level | +| `zoom_snap` | 1 | Zoom level increments | + +**New Attributes (v18.0.1.2.0):** + +| Attribute | Default | Description | +|-----------|---------|-------------| +| `show_pin_list` | "1" | Show sidebar with marker list | +| `panel_title` | "Locations" | Title for the sidebar | +| `numbered_markers` | "0" | Show numbered markers instead of icons | +| `routing` | "0" | Enable route polylines between markers | +| `enable_navigation` | "1" | Show Google Maps navigation buttons | +| `group_by` | - | Field for grouping markers by color | + +**System Configuration:** + +Configure geocoding and routing providers in Settings > Leaflet Maps: + +- Geocoding Provider: Nominatim (OSM) free with 1 req/s limit, or MapBox (premium) +- Routing Provider: OSRM free and self-hostable, or MapBox (premium) +- Default Nominatim URL: `https://nominatim.openstreetmap.org` +- Default OSRM URL: `https://router.project-osrm.org` diff --git a/web_view_leaflet_map/readme/CONTRIBUTORS.md b/web_view_leaflet_map/readme/CONTRIBUTORS.md index 4a6b63400..5a85a028c 100644 --- a/web_view_leaflet_map/readme/CONTRIBUTORS.md +++ b/web_view_leaflet_map/readme/CONTRIBUTORS.md @@ -1 +1,4 @@ -- Sylvain LE GAL () +* Sylvain LE GAL () +- [KMEE](https://kmee.com.br/): + - Luis Felipe Mileo \<\> + diff --git a/web_view_leaflet_map/readme/DESCRIPTION.md b/web_view_leaflet_map/readme/DESCRIPTION.md index 710c0946a..cbc133635 100644 --- a/web_view_leaflet_map/readme/DESCRIPTION.md +++ b/web_view_leaflet_map/readme/DESCRIPTION.md @@ -1,23 +1,44 @@ -This module extends odoo views, to add a new kind of view, named -`leaflet_map` that is using the Leaflet javascript library to use maps. -() This library is for exemple, used in the -OpenStreetMap project. () +This module extends Odoo views to add a new kind of view named +`leaflet_map` that uses the Leaflet javascript library for interactive maps. +(https://leafletjs.com/) This library is used by projects like +OpenStreetMap. (https://www.openstreetmap.org/) -You can see a simple usage in the module `web_view_leaflet_map_partner` -in the same OCA repository that displays your contact in a map, if -latitude and longitude are defined. (To define latitude and longitude, -refer to the Odoo module `base_geolocalize`) +**Core Map View:** -A marker will be displayed for each item that has a localization. +- Display records as markers on an interactive map +- Automatic marker clustering for better visibility when zoomed out +- Custom marker icons from image fields +- Click markers to open popups with record details +- Click popup to navigate to record form view -![](../static/description/view_res_partner_map_precise.png) +**Sidebar/Pin List (NEW):** -If user zooms out, the markers will overlap, which won't be very visible. +- Collapsible sidebar showing all map markers in a list +- Search and filter within the sidebar +- Click items to center map on marker +- Visual grouping by field (e.g., category) +- Show located/unlocated record counts -In that case, nearby markers are grouped together, thanks to -`Leaflet.markercluster` plugin. +**Enhanced Markers (NEW):** + +- Numbered markers showing sequence +- Color coding by group +- Google Maps navigation buttons in popups +- Coordinate validation (lat: -90..90, lng: -180..180) + +**Routing Support (NEW - requires web_leaflet_routing):** -![](../static/description/view_res_partner_map_large.png) +- Route polylines between markers +- OSRM integration for real road routes +- Route distance and duration display +See `web_view_leaflet_map_partner` module for a complete example +that displays contacts on a map with avatar markers, grouped sidebar, +auto-geocoding, and Google Maps navigation. +![Precise Map View](../static/description/view_res_partner_map_precise.png) + +If user zooms out, nearby markers are grouped together thanks to +`Leaflet.markercluster` plugin. +![Large Map View](../static/description/view_res_partner_map_large.png) diff --git a/web_view_leaflet_map/static/description/index.html b/web_view_leaflet_map/static/description/index.html index 0e9b1721e..52a20ba60 100644 --- a/web_view_leaflet_map/static/description/index.html +++ b/web_view_leaflet_map/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Leaflet Map View (OpenStreetMap) -
+
+

Leaflet Map View (OpenStreetMap)

- - -Odoo Community Association - -
-

Leaflet Map View (OpenStreetMap)

-

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

-

This module extends odoo views, to add a new kind of view, named -leaflet_map that is using the Leaflet javascript library to use -maps. (https://leafletjs.com/) This library is for exemple, used in the -OpenStreetMap project. (https://www.openstreetmap.org/)

-

You can see a simple usage in the module -web_view_leaflet_map_partner in the same OCA repository that -displays your contact in a map, if latitude and longitude are defined. -(To define latitude and longitude, refer to the Odoo module -base_geolocalize)

-

A marker will be displayed for each item that has a localization.

-

image1

-

If user zooms out, the markers will overlap, which won’t be very -visible.

-

In that case, nearby markers are grouped together, thanks to +

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

This module extends Odoo views to add a new kind of view named +leaflet_map that uses the Leaflet javascript library for interactive +maps. (https://leafletjs.com/) This library is used by projects like +OpenStreetMap. (https://www.openstreetmap.org/)

+

Core Map View:

+
    +
  • Display records as markers on an interactive map
  • +
  • Automatic marker clustering for better visibility when zoomed out
  • +
  • Custom marker icons from image fields
  • +
  • Click markers to open popups with record details
  • +
  • Click popup to navigate to record form view
  • +
+

Sidebar/Pin List (NEW):

+
    +
  • Collapsible sidebar showing all map markers in a list
  • +
  • Search and filter within the sidebar
  • +
  • Click items to center map on marker
  • +
  • Visual grouping by field (e.g., category)
  • +
  • Show located/unlocated record counts
  • +
+

Enhanced Markers (NEW):

+
    +
  • Numbered markers showing sequence
  • +
  • Color coding by group
  • +
  • Google Maps navigation buttons in popups
  • +
  • Coordinate validation (lat: -90..90, lng: -180..180)
  • +
+

Routing Support (NEW - requires web_leaflet_routing):

+
    +
  • Route polylines between markers
  • +
  • OSRM integration for real road routes
  • +
  • Route distance and duration display
  • +
+

See web_view_leaflet_map_partner module for a complete example that +displays contacts on a map with avatar markers, grouped sidebar, +auto-geocoding, and Google Maps navigation.

+

Precise Map View

+

If user zooms out, nearby markers are grouped together thanks to Leaflet.markercluster plugin.

-

image2

+

Large Map View

Table of contents

    @@ -407,13 +426,179 @@

    Leaflet Map View (OpenStreetMap)

-

Configuration

+

Configuration

+

To use this view, define a leaflet_map view in your XML:

+
+<leaflet_map
+    field_latitude="partner_latitude"
+    field_longitude="partner_longitude"
+    field_title="display_name"
+    field_address="contact_address"
+    field_marker_icon_image="avatar_128"
+    show_pin_list="1"
+    numbered_markers="1"
+    routing="1"
+    enable_navigation="1"
+    group_by="category_id"
+    panel_title="Locations"
+>
+    <field name="display_name"/>
+    <field name="partner_latitude"/>
+    <field name="partner_longitude"/>
+    <field name="contact_address"/>
+    <field name="category_id"/>
+</leaflet_map>
+
+

Required Attributes:

+ ++++ + + + + + + + + + + + + + +
AttributeDescription
field_latitudeField containing latitude coordinates
field_longitudeField containing longitude coordinates
+

Display Attributes:

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDefaultDescription
field_titledisplay_nameField for marker +title/label
field_address
    +
  • +
+
Field for address in +popup
field_marker_icon_image
    +
  • +
+
Image field for custom +marker icon
marker_icon_size_x64Custom icon width in +pixels
marker_icon_size_y64Custom icon height in +pixels
+

Map Options:

+ +++++ + + + + + + + + + + + + + + + + + + + + +
AttributeDefaultDescription
default_zoom7Initial map zoom level
max_zoom19Maximum zoom level
zoom_snap1Zoom level increments
+

New Attributes (v18.0.1.2.0):

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDefaultDescription
show_pin_list“1”Show sidebar with marker list
panel_title“Locations”Title for the sidebar
numbered_markers“0”Show numbered markers instead of icons
routing“0”Enable route polylines between markers
enable_navigation“1”Show Google Maps navigation buttons
group_by
    +
  • +
+
Field for grouping markers by color
+

System Configuration:

+

Configure geocoding and routing providers in Settings > Leaflet Maps:

    -
  • See configuration of the module web_leaflet_lib.
  • +
  • Geocoding Provider: Nominatim (OSM) free with 1 req/s limit, or MapBox +(premium)
  • +
  • Routing Provider: OSRM free and self-hostable, or MapBox (premium)
  • +
  • Default Nominatim URL: https://nominatim.openstreetmap.org
  • +
  • Default OSRM URL: https://router.project-osrm.org
-

Development

+

Development

Create a new view :

 <record id="view_my_model_map" model="ir.ui.view">
@@ -467,7 +652,7 @@ 

Development

globally, or per model.

-

Known issues / Roadmap

+

Known issues / Roadmap

  • For the time being, at the start of the map loading, the call of invalidateSize() is required. We should investigate why and try to @@ -477,19 +662,10 @@

    Known issues / Roadmap

    items if longitude and latitude are available. We could imagine other kind of usages, with Polylines, Polygons, etc… See all the leaflet options : https://leafletjs.com/reference.html
  • -
  • Search bar is not implemented in this view. All records are displayed -for now. We should:
      -
    • implement records refresh, when adding / removing domain in the -search bar.
    • -
    • implement a custom search based on the displayed map. (no need to -load records that are out of the scope of the current displayed -map).
    • -
    -
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -497,21 +673,26 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • GRAP
  • +
  • KMEE
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -519,13 +700,12 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

Current maintainer:

-

legalsylvain

+

Current maintainers:

+

legalsylvain mileo

This module is part of the OCA/geospatial project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css new file mode 100644 index 000000000..9dc0b1529 --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + * + * Styles for DraggablePinList component - drag-and-drop functionality + */ + +/* ====================================== + Drag Handle + ====================================== */ + +.o_drag_handle { + cursor: grab; + opacity: 0.4; + transition: opacity 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + flex-shrink: 0; +} + +.o_pin_item_draggable:hover .o_drag_handle { + opacity: 1; +} + +.o_drag_handle:active { + cursor: grabbing; +} + +/* ====================================== + Drag States + ====================================== */ + +/* During drag - element being dragged */ +.o_pin_item_draggable.o_dragging { + opacity: 0.5; + background: var(--o-gray-200, #e9ecef); + border: 2px dashed var(--o-primary, #007bff); + border-radius: 4px; +} + +/* Dragged element class (from sortable system) */ +.o_pin_item_draggable.o_dragged { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + background: white; + z-index: 1000; +} + +/* ====================================== + Drop Target + ====================================== */ + +/* Drop target (group receiving the dragged element) */ +.o_pin_group.o_drop_target { + background: rgba(0, 123, 255, 0.08); + border: 2px dashed var(--o-primary, #007bff); + border-radius: 6px; + transition: + background-color 0.15s ease, + border-color 0.15s ease; +} + +.o_pin_group.o_drop_target .o_pin_group_header { + background: rgba(0, 123, 255, 0.1); +} + +/* ====================================== + Draggable Item Layout + ====================================== */ + +.o_pin_item_draggable { + display: flex; + align-items: center; + transition: + background-color 0.15s ease, + opacity 0.15s ease; +} + +/* Placeholder for drop position */ +.o_pin_item_draggable.o_sortable_placeholder { + background: rgba(0, 123, 255, 0.15); + border: 2px dashed var(--o-primary, #007bff); + border-radius: 4px; + min-height: 40px; +} + +/* ====================================== + Navigate All Button + ====================================== */ + +.o_group_navigate_all { + padding: 2px 8px; + font-size: 0.75rem; + line-height: 1.2; + border-radius: 4px; + white-space: nowrap; +} + +.o_group_navigate_all:hover { + background: var(--o-primary, #007bff); + color: white; + border-color: var(--o-primary, #007bff); +} + +/* ====================================== + Mobile/Touch Enhancements + ====================================== */ + +@media (max-width: 768px) { + .o_drag_handle { + width: 30px; + opacity: 0.6; + padding: 8px 4px; + } + + .o_group_navigate_all { + padding: 4px 10px; + font-size: 0.85rem; + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js new file mode 100644 index 000000000..87efd022f --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js @@ -0,0 +1,219 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +/* global console, document, window */ + +import {useRef} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {useSortable} from "@web/core/utils/sortable_owl"; + +import {PinList} from "./pin_list.esm"; + +/** + * DraggablePinList extends PinList with drag-and-drop reordering capabilities. + * + * This is a generic component that can be used by any model that supports + * resequencing. It enables users to: + * - Reorder items within the same group (drag up/down) + * - Move items between groups (drag to different group) + * - Navigate to all items in a group via Google Maps + * + * Configuration is done via props: + * - groupField: The field name for the group (e.g., "order_id") + * - onResequence: Callback function for resequencing + */ +export class DraggablePinList extends PinList { + static template = "web_view_leaflet_map.DraggablePinList"; + // Inherits props from PinList which includes "*": true for extensibility + // Additional props used: onResequence (Function), groupField (String) + + setup() { + super.setup(); + this.notification = useService("notification"); + this.rootRef = useRef("root"); + + // Track drag state + this._draggedRecordId = null; + this._sourceGroupId = null; + + // Initialize sortable only if onResequence is provided + if (this.props.onResequence) { + this._setupSortable(); + } + } + + /** + * Setup the sortable functionality for drag-and-drop. + */ + _setupSortable() { + useSortable({ + ref: this.rootRef, + elements: ".o_pin_item_draggable", + handle: ".o_drag_handle", + groups: ".o_pin_group", + connectGroups: true, + cursor: "grabbing", + + onDragStart: ({element, group}) => { + element.classList.add("o_dragging"); + this._draggedRecordId = parseInt(element.dataset.id, 10); + this._sourceGroupId = group ? this._getGroupId(group) : null; + }, + + onGroupEnter: ({group}) => { + if (group) { + group.classList.add("o_drop_target"); + } + }, + + onGroupLeave: ({group}) => { + if (group) { + group.classList.remove("o_drop_target"); + } + }, + + onDrop: async ({element, parent, previous}) => { + await this._handleDrop(element, parent, previous); + }, + + onDragEnd: ({element}) => { + element.classList.remove("o_dragging"); + document + .querySelectorAll(".o_drop_target") + .forEach((el) => el.classList.remove("o_drop_target")); + }, + }); + } + + /** + * Handle drop event after drag-and-drop operation. + * + * @param {HTMLElement} element - The dragged element + * @param {HTMLElement|null} parent - The new parent group element + * @param {HTMLElement|null} previous - The preceding sibling element + */ + async _handleDrop(element, parent, previous) { + const recordId = parseInt(element.dataset.id, 10); + let targetGroupId = parent ? this._getGroupId(parent) : this._sourceGroupId; + + // Fallback to source group if target group ID couldn't be determined + if (targetGroupId === null && this._sourceGroupId !== null) { + console.warn( + "DraggablePinList: Could not determine target group, using source group" + ); + targetGroupId = this._sourceGroupId; + } + + // Find reference record (previous element after drop) + let previousRecordId = null; + if (previous && previous.dataset.id) { + previousRecordId = parseInt(previous.dataset.id, 10); + } + + try { + if (this.props.onResequence) { + await this.props.onResequence( + recordId, + targetGroupId, + previousRecordId + ); + } + } catch (error) { + this.notification.add(error.message || "Failed to reorder item", { + type: "danger", + }); + } + } + + /** + * Extract group ID from a group element. + * + * @param {HTMLElement} groupElement - The group DOM element + * @returns {Number|null} The group ID or null + */ + _getGroupId(groupElement) { + const groupId = groupElement?.dataset?.groupId; + if ( + !groupId || + groupId === "" || + groupId === "null" || + groupId === "undefined" + ) { + return null; + } + const parsed = parseInt(groupId, 10); + return isNaN(parsed) ? null : parsed; + } + + /** + * Get the group ID from a record based on the groupField prop. + * + * @param {Object} record - The record object + * @returns {Number|null} The group ID or null + */ + getRecordGroupId(record) { + // Use groupField from props if available, otherwise fall back to groupBy + const fieldName = this.props.groupField || this.props.groupBy; + if (!fieldName) return null; + + const value = record[fieldName]; + if (!value) return null; + + // Handle Many2one fields (array with [id, name]) + return Array.isArray(value) ? value[0] : value; + } + + /** + * Generate Google Maps URL for all records in a group (route). + * Uses the same format as the backend generate_google_maps_url method. + * + * @param {Object} group - The group object with records + * @returns {String|null} The Google Maps URL or null + */ + getGroupGoogleMapsUrl(group) { + const records = group.records + .filter((r) => this.hasValidCoordinates(r)) + .sort((a, b) => (a.sequence || 0) - (b.sequence || 0)); + + if (records.length < 2) { + return null; + } + + const coords = records.map((r) => [ + r[this.props.fieldLatitude], + r[this.props.fieldLongitude], + ]); + + const origin = `${coords[0][0]},${coords[0][1]}`; + const destination = `${coords[coords.length - 1][0]},${coords[coords.length - 1][1]}`; + const waypoints = coords + .slice(1, -1) + .map((c) => `${c[0]},${c[1]}`) + .join("|"); + + let url = `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${destination}`; + if (waypoints) { + url += `&waypoints=${waypoints}`; + } + return url; + } + + /** + * Handler for "Navigate All" button click on group header. + * Opens Google Maps with full route for the group. + * + * @param {Event} ev - The click event + * @param {Object} group - The group object + */ + onGroupNavigateClick(ev, group) { + ev.stopPropagation(); + const url = this.getGroupGoogleMapsUrl(group); + if (url) { + window.open(url, "_blank"); + } + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml new file mode 100644 index 000000000..905ee6be4 --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml @@ -0,0 +1,205 @@ + + + + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + located + + + + not located + + + +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+ + + + + + + + + + + +
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.css b/web_view_leaflet_map/static/src/components/pin-list/pin_list.css new file mode 100644 index 000000000..a69287840 --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.css @@ -0,0 +1,303 @@ +/* + Pin List Component Styles + Sidebar component for displaying map markers in a list format +*/ + +/* Main container */ +.o_leaflet_pin_list { + display: flex; + flex-direction: column; + width: 320px; + min-width: 320px; + background-color: #fff; + border-right: 1px solid #dee2e6; + overflow: hidden; + transition: + width 0.2s ease-in-out, + min-width 0.2s ease-in-out; +} + +.o_leaflet_pin_list.o_collapsed { + width: 48px; + min-width: 48px; +} + +/* Header */ +.o_pin_list_header { + display: flex; + align-items: center; + padding: 12px; + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; + min-height: 48px; +} + +.o_pin_list_toggle { + padding: 4px 8px; + margin-right: 8px; + color: #495057; +} + +.o_collapsed .o_pin_list_toggle { + margin-right: 0; +} + +.o_pin_list_title { + font-weight: 600; + font-size: 14px; + color: #212529; +} + +.o_pin_list_count { + font-size: 12px; +} + +/* Search */ +.o_pin_list_search .input-group-text { + background-color: transparent; + border-right: none; +} + +.o_pin_list_search .form-control { + border-left: none; +} + +.o_pin_list_search .form-control:focus { + border-color: #ced4da; + box-shadow: none; +} + +/* Stats */ +.o_pin_list_stats { + border-bottom: 1px solid #dee2e6; +} + +/* Groups container */ +.o_pin_list_groups { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* Group */ +.o_pin_group { + border-bottom: 1px solid #f0f0f0; +} + +.o_pin_group:last-child { + border-bottom: none; +} + +/* Group header */ +.o_pin_group_header { + display: flex; + align-items: center; + padding: 8px 12px; + background-color: #f8f9fa; + cursor: pointer; + user-select: none; + gap: 8px; +} + +.o_pin_group_header:hover { + background-color: #e9ecef; +} + +.o_pin_group_header i { + width: 12px; + color: #6c757d; +} + +.o_pin_group_color { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.o_pin_group_name { + font-weight: 500; + font-size: 13px; + color: #495057; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_pin_group_count { + flex-shrink: 0; +} + +/* Unassigned group styling */ +.o_pin_group.o_pin_group_unassigned { + border-left: 3px solid #fd7e14; +} + +.o_pin_group_header.o_unassigned_header { + background-color: #fff8f0; +} + +.o_pin_group_header.o_unassigned_header:hover { + background-color: #ffe8d6; +} + +/* Group items */ +.o_pin_group_items { + /* No padding - items handle their own */ +} + +/* Pin item */ +.o_pin_item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #f5f5f5; + transition: background-color 0.15s ease; + gap: 10px; +} + +.o_pin_item:hover { + background-color: #f0f7ff; +} + +.o_pin_item:last-child { + border-bottom: none; +} + +.o_pin_item.o_pin_item_unlocated { + opacity: 0.6; + cursor: default; +} + +.o_pin_item.o_pin_item_unlocated:hover { + background-color: #fff3cd; +} + +/* Pin number */ +.o_pin_number { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #007bff; + color: white; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.o_pin_item_unlocated .o_pin_number { + background-color: #ffc107; + color: #212529; +} + +/* Pin content */ +.o_pin_content { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.o_pin_title { + font-size: 13px; + font-weight: 500; + color: #212529; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_pin_address { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + +/* Navigate button */ +.o_pin_navigate { + flex-shrink: 0; + padding: 4px 8px; + font-size: 12px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.o_pin_item:hover .o_pin_navigate { + opacity: 1; +} + +/* Status icon for unlocated items */ +.o_pin_status { + flex-shrink: 0; + font-size: 14px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .o_leaflet_pin_list { + width: 100%; + min-width: 100%; + max-height: 40vh; + border-right: none; + border-bottom: 1px solid #dee2e6; + } + + .o_leaflet_pin_list.o_collapsed { + width: 100%; + min-width: 100%; + max-height: 48px; + } + + .o_pin_navigate { + opacity: 1; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .o_leaflet_pin_list { + background-color: #212529; + border-color: #495057; + } + + .o_pin_list_header { + background-color: #343a40; + border-color: #495057; + } + + .o_pin_list_title { + color: #f8f9fa; + } + + .o_pin_list_toggle { + color: #adb5bd; + } + + .o_pin_group_header { + background-color: #343a40; + } + + .o_pin_group_header:hover { + background-color: #495057; + } + + .o_pin_group_name { + color: #e9ecef; + } + + .o_pin_item { + border-color: #343a40; + } + + .o_pin_item:hover { + background-color: #495057; + } + + .o_pin_title { + color: #f8f9fa; + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js new file mode 100644 index 000000000..341d96d1e --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js @@ -0,0 +1,265 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {Component, useState} from "@odoo/owl"; + +/** + * PinList component displays a sidebar with a list of map markers. + * Supports grouping, collapsing, and click-to-center functionality. + */ +export class PinList extends Component { + static template = "web_view_leaflet_map.PinList"; + static props = { + records: {type: Array}, + groupBy: {type: [String, {value: null}, {value: undefined}], optional: true}, + groupColors: {type: Object, optional: true}, + panelTitle: {type: String, optional: true}, + onPinClick: {type: Function}, + onNavigateClick: {type: Function, optional: true}, + fieldTitle: {type: String, optional: true}, + fieldAddress: {type: String, optional: true}, + fieldLatitude: {type: String}, + fieldLongitude: {type: String}, + unassignedGroupName: {type: String, optional: true}, + // Accept additional props from extending modules (groupField, onResequence, etc.) + // without strict type validation - enables extensibility + "*": true, + }; + static defaultProps = { + panelTitle: "Locations", + groupColors: {}, + unassignedGroupName: "Unassigned", + }; + + setup() { + this.state = useState({ + collapsed: false, + collapsedGroups: {}, + searchQuery: "", + }); + } + + /** + * Get filtered records based on search query + */ + get filteredRecords() { + if (!this.state.searchQuery) { + return this.props.records; + } + const query = this.state.searchQuery.toLowerCase(); + return this.props.records.filter((record) => { + const title = this.getRecordTitle(record).toLowerCase(); + const address = this.getRecordAddress(record).toLowerCase(); + return title.includes(query) || address.includes(query); + }); + } + + /** + * Get records organized by groups. + * Records without a groupBy value are placed in the unassigned group. + */ + get groupedRecords() { + const records = this.filteredRecords; + const UNASSIGNED_GROUP_NAME = this.props.unassignedGroupName; + // Orange color for unassigned group + const UNASSIGNED_COLOR = "#fd7e14"; + + if (!this.props.groupBy) { + return [{name: null, records, color: null, isUnassigned: false}]; + } + + const groups = {}; + for (const record of records) { + const groupValue = record[this.props.groupBy]; + let isUnassigned = false; + let groupKey = UNASSIGNED_GROUP_NAME; + + // Handle Many2one fields (array with [id, name]) and empty values + if (!groupValue || (Array.isArray(groupValue) && !groupValue[0])) { + // No group value - unassigned + isUnassigned = true; + } else { + groupKey = Array.isArray(groupValue) ? groupValue[1] : groupValue; + } + + if (!groups[groupKey]) { + groups[groupKey] = { + name: groupKey, + records: [], + color: isUnassigned + ? UNASSIGNED_COLOR + : this.getGroupColor(groupKey), + isUnassigned: isUnassigned, + }; + } + groups[groupKey].records.push(record); + } + + // Sort: alphabetically, then unassigned group last + return Object.values(groups).sort((a, b) => { + if (a.isUnassigned && !b.isUnassigned) return 1; + if (!a.isUnassigned && b.isUnassigned) return -1; + return String(a.name).localeCompare(String(b.name)); + }); + } + + /** + * Get total count of located records + */ + get locatedCount() { + return this.filteredRecords.filter( + (r) => + r[this.props.fieldLatitude] && + r[this.props.fieldLongitude] && + this.validateCoordinates( + r[this.props.fieldLatitude], + r[this.props.fieldLongitude] + ) + ).length; + } + + /** + * Get count of records without valid coordinates + */ + get unlocatedCount() { + return this.filteredRecords.length - this.locatedCount; + } + + /** + * Validate coordinates are within valid ranges + */ + validateCoordinates(lat, lng) { + return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; + } + + /** + * Get display title for a record + */ + getRecordTitle(record) { + if (this.props.fieldTitle) { + return record[this.props.fieldTitle] || record.display_name || ""; + } + return record.display_name || ""; + } + + /** + * Get address for a record + */ + getRecordAddress(record) { + if (this.props.fieldAddress) { + return record[this.props.fieldAddress] || ""; + } + return ""; + } + + /** + * Check if a record has valid coordinates + */ + hasValidCoordinates(record) { + const lat = record[this.props.fieldLatitude]; + const lng = record[this.props.fieldLongitude]; + return lat && lng && this.validateCoordinates(lat, lng); + } + + /** + * Get a color for a group (generates consistent colors based on group name) + */ + getGroupColor(groupName) { + if (this.props.groupColors[groupName]) { + return this.props.groupColors[groupName]; + } + + // Generate a color based on the hash of the group name + const colors = [ + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#96CEB4", + "#FFEAA7", + "#DDA0DD", + "#98D8C8", + "#F7DC6F", + "#BB8FCE", + "#85C1E9", + ]; + + let hash = 0; + for (let i = 0; i < String(groupName).length; i++) { + hash = String(groupName).charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; + } + + /** + * Toggle sidebar collapse state + */ + toggleSidebar() { + this.state.collapsed = !this.state.collapsed; + } + + /** + * Toggle group collapse state + */ + toggleGroup(groupName) { + this.state.collapsedGroups[groupName] = !this.state.collapsedGroups[groupName]; + } + + /** + * Check if a group is collapsed. + * The unassigned group is collapsed by default. + */ + isGroupCollapsed(groupName) { + // If explicitly set, use that value; otherwise default to collapsed for unassigned + if (groupName in this.state.collapsedGroups) { + return this.state.collapsedGroups[groupName]; + } + // Default: unassigned group is collapsed, others are expanded + return groupName === this.props.unassignedGroupName; + } + + /** + * Handle click on a pin item + */ + onPinItemClick(record) { + if (this.hasValidCoordinates(record)) { + this.props.onPinClick(record); + } + } + + /** + * Handle click on navigate button + */ + onNavigateButtonClick(ev, record) { + ev.stopPropagation(); + if (this.props.onNavigateClick && this.hasValidCoordinates(record)) { + this.props.onNavigateClick(record); + } + } + + /** + * Update search query + */ + onSearchInput(ev) { + this.state.searchQuery = ev.target.value; + } + + /** + * Clear search + */ + clearSearch() { + this.state.searchQuery = ""; + } + + /** + * Generate Google Maps navigation URL + */ + getGoogleMapsUrl(record) { + const lat = record[this.props.fieldLatitude]; + const lng = record[this.props.fieldLongitude]; + return `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml new file mode 100644 index 000000000..2345c709a --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml @@ -0,0 +1,173 @@ + + + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + located + + + + not located + + + +
+ + +
+ +
+ + +
+ + + + + + + + + + + +
+
+ + +
+ +
+ + + + + + +
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js new file mode 100644 index 000000000..0d33502c7 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js @@ -0,0 +1,152 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {visitXML} from "@web/core/utils/xml"; + +/** + * LeafletMapArchParser parses the XML architecture of leaflet_map views. + * Extracts field definitions and view options from the arch XML. + */ +export class LeafletMapArchParser { + /** + * Parse the arch XML and extract view configuration. + * + * @param {Element} arch - The XML arch element + * @returns {Object} Parsed arch information + */ + parse(arch) { + const archInfo = { + // Required fields that are always loaded + fieldNames: ["id", "display_name"], + // Fields displayed in marker popup + fieldNamesMarkerPopup: [], + // Field metadata from arch + fieldNodes: {}, + }; + + visitXML(arch, (node) => { + if (node.tagName === "leaflet_map") { + this._parseMapAttributes(node, archInfo); + } + + if (node.tagName === "field") { + this._parseFieldNode(node, archInfo); + } + }); + + return archInfo; + } + + /** + * Parse attributes from the leaflet_map element. + * + * @param {Element} node - The leaflet_map XML element + * @param {Object} archInfo - The arch info object to populate + */ + _parseMapAttributes(node, archInfo) { + const getAttr = (name, defaultVal = null) => + node.getAttribute(name) || defaultVal; + + // Coordinate fields (required) + archInfo.fieldLatitude = getAttr("field_latitude"); + archInfo.fieldLongitude = getAttr("field_longitude"); + if (archInfo.fieldLatitude) { + archInfo.fieldNames.push(archInfo.fieldLatitude); + } + if (archInfo.fieldLongitude) { + archInfo.fieldNames.push(archInfo.fieldLongitude); + } + + // Display fields + archInfo.fieldTitle = getAttr("field_title"); + archInfo.fieldAddress = getAttr("field_address"); + archInfo.fieldMarkerIconImage = getAttr("field_marker_icon_image"); + if (archInfo.fieldTitle) { + archInfo.fieldNames.push(archInfo.fieldTitle); + } + if (archInfo.fieldAddress) { + archInfo.fieldNames.push(archInfo.fieldAddress); + } + if (archInfo.fieldMarkerIconImage) { + archInfo.fieldNames.push(archInfo.fieldMarkerIconImage); + } + + // Marker icon configuration + archInfo.markerIconSizeX = parseInt(getAttr("marker_icon_size_x", "64"), 10); + archInfo.markerIconSizeY = parseInt(getAttr("marker_icon_size_y", "64"), 10); + archInfo.markerPopupAnchorX = parseInt( + getAttr("marker_popup_anchor_x", "0"), + 10 + ); + archInfo.markerPopupAnchorY = parseInt( + getAttr("marker_popup_anchor_y", "-32"), + 10 + ); + + // Map configuration + archInfo.defaultZoom = parseInt(getAttr("default_zoom", "7"), 10); + archInfo.maxZoom = parseInt(getAttr("max_zoom", "19"), 10); + archInfo.zoomSnap = parseInt(getAttr("zoom_snap", "1"), 10); + + // View options + archInfo.showPinList = getAttr("show_pin_list") !== "0"; + archInfo.panelTitle = getAttr("panel_title") || "Locations"; + archInfo.numberedMarkers = getAttr("numbered_markers") === "1"; + archInfo.routing = getAttr("routing") === "1"; + archInfo.enableNavigation = getAttr("enable_navigation") !== "0"; + + // Grouping configuration + archInfo.groupBy = getAttr("group_by"); + if (archInfo.groupBy) { + archInfo.fieldNames.push(archInfo.groupBy); + } + + // Drag-and-drop configuration (NEW) + archInfo.draggable = getAttr("draggable") === "1"; + archInfo.groupField = getAttr("group_field"); + archInfo.defaultOrder = getAttr("default_order"); + if (archInfo.groupField) { + archInfo.fieldNames.push(archInfo.groupField); + } + if (archInfo.defaultOrder) { + archInfo.fieldNames.push(archInfo.defaultOrder); + } + + // Data limits + archInfo.limit = parseInt(getAttr("limit", "500"), 10); + + // Custom js_class for extended views + archInfo.jsClass = getAttr("js_class"); + + // Configurable unassigned group name (default: "Unassigned") + archInfo.unassignedGroupName = getAttr("unassigned_group_name"); + } + + /** + * Parse a field node from the arch. + * + * @param {Element} node - The field XML element + * @param {Object} archInfo - The arch info object to populate + */ + _parseFieldNode(node, archInfo) { + const fieldName = node.getAttribute("name"); + if (!fieldName) { + return; + } + + archInfo.fieldNames.push(fieldName); + archInfo.fieldNodes[fieldName] = { + name: fieldName, + string: node.getAttribute("string"), + invisible: node.getAttribute("invisible") === "1", + }; + archInfo.fieldNamesMarkerPopup.push({ + fieldName, + string: node.getAttribute("string"), + }); + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js new file mode 100644 index 000000000..0922d7221 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js @@ -0,0 +1,168 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {Component, onWillStart, useState, useSubEnv} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {Layout} from "@web/search/layout"; + +import {LeafletMapModel} from "./leaflet_map_model.esm"; +import {LeafletMapRenderer} from "./leaflet_map_renderer.esm"; + +/** + * LeafletMapController is the main controller for the leaflet map view. + * It manages the model lifecycle and coordinates between the search panel + * and the renderer. + */ +export class LeafletMapController extends Component { + static template = "web_view_leaflet_map.LeafletMapController"; + static components = {Layout, LeafletMapRenderer}; + + static props = { + resModel: {type: String}, + arch: {type: Object, optional: true}, + archInfo: {type: Object}, + domain: {type: Array, optional: true}, + context: {type: Object, optional: true}, + fields: {type: Object, optional: true}, + limit: {type: Number, optional: true}, + display: {type: Object, optional: true}, + // Model and Renderer classes to use (allows overriding) + Model: {type: Function, optional: true}, + Renderer: {type: Function, optional: true}, + // Standard view controller props passed by Odoo framework (WithSearch) + // Using wildcard to accept all standard props without explicit declaration + "*": true, + }; + + static defaultProps = { + domain: [], + context: {}, + fields: {}, + }; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.notification = useService("notification"); + + // State for reactive updates + // dataVersion is incremented after each data reload to force re-render + this.state = useState({ + loading: true, + dataVersion: 0, + }); + + // Set up sub-environment for child components + useSubEnv({ + config: { + ...this.env.config, + }, + }); + + // Create model instance + const ModelClass = this.props.Model || LeafletMapModel; + this.model = new ModelClass( + this.env, + { + resModel: this.props.resModel, + archInfo: this.props.archInfo, + fields: this.props.fields, + context: this.props.context, + }, + {orm: this.orm} + ); + + // Initial data load + onWillStart(async () => { + await this.loadData(); + }); + } + + /** + * Get the Renderer component class to use. + */ + get RendererComponent() { + return this.props.Renderer || LeafletMapRenderer; + } + + /** + * Load data from the model. + */ + async loadData() { + this.state.loading = true; + try { + await this.model.load({ + domain: this.props.domain, + limit: this.props.limit, + context: this.props.context, + }); + } finally { + this.state.loading = false; + } + } + + /** + * Reload data (called after resequencing or domain changes). + */ + async reloadData() { + this.state.loading = true; + try { + await this.model.reload(); + } finally { + this.state.loading = false; + // Increment dataVersion to force re-render of child components + this.state.dataVersion++; + } + } + + /** + * Handle resequence event from the renderer. + * + * @param {Number} recordId - ID of the record being moved + * @param {Number} targetGroupId - ID of the target group + * @param {Number|null} previousRecordId - ID of the preceding record + */ + async onResequence(recordId, targetGroupId, previousRecordId) { + try { + const result = await this.model.resequence( + recordId, + targetGroupId, + previousRecordId + ); + + if (result.success) { + await this.reloadData(); + } else { + this.notification.add(result.error || "Failed to reorder item", { + type: "danger", + }); + } + return result; + } catch (error) { + this.notification.add(error.message || "Failed to reorder item", { + type: "danger", + }); + return {success: false, error: error.message}; + } + } + + /** + * Get props to pass to the Renderer component. + */ + get rendererProps() { + return { + resModel: this.props.resModel, + archInfo: this.props.archInfo, + fields: this.props.fields, + context: this.props.context, + model: this.model, + onResequence: this.onResequence.bind(this), + // DataVersion triggers re-render when data changes + dataVersion: this.state.dataVersion, + }; + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js new file mode 100644 index 000000000..2d3c74cb9 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js @@ -0,0 +1,268 @@ +/** @odoo-module **/ + +import {KeepLast} from "@web/core/utils/concurrency"; + +/** + * LeafletMapModel handles data loading and manipulation for the leaflet map view. + */ +export class LeafletMapModel { + /** + * @param {Object} env - The OWL environment + * @param {Object} params - Model parameters + * @param {Object} services - Available services (orm, etc.) + */ + constructor(env, params, services) { + this.env = env; + this.orm = services.orm; + this.keepLast = new KeepLast(); + + // Model configuration from params + this.resModel = params.resModel; + this.archInfo = params.archInfo; + this.fields = params.fields || {}; + this.context = params.context || {}; + + // Data state + this.data = { + records: [], + recordGroups: [], + count: 0, + loading: false, + }; + + // Current load parameters (for reload) + this._loadParams = null; + } + + /** + * Load records from the server. + * + * @param {Object} params - Load parameters + * @param {Array} params.domain - Domain filter + * @param {Number} params.limit - Record limit + * @param {Number} params.offset - Record offset + * @param {Object} params.context - Additional context + * @returns {Promise} The loaded data + */ + async load(params) { + this._loadParams = params; + const {domain = [], limit, offset = 0, context = {}} = params; + + const fields = this._getFieldsToLoad(); + const recordLimit = limit || this.archInfo.limit || 500; + + this.data.loading = true; + + try { + const records = await this.keepLast.add( + this.orm.searchRead(this.resModel, domain, fields, { + limit: recordLimit, + offset, + context: {...this.context, ...context}, + }) + ); + + this.data.records = records; + this.data.count = records.length; + + // Group records if groupBy is configured + if (this.archInfo.groupBy) { + this.data.recordGroups = this._groupRecords(records); + } else { + this.data.recordGroups = [{name: null, id: null, records}]; + } + + return this.data; + } finally { + this.data.loading = false; + } + } + + /** + * Reload data with current parameters. + * + * @returns {Promise} The reloaded data + */ + async reload() { + if (this._loadParams) { + return this.load(this._loadParams); + } + return this.data; + } + + /** + * Get the list of fields to load based on archInfo. + * + * @returns {Array} Field names to load + */ + _getFieldsToLoad() { + const fields = new Set(this.archInfo.fieldNames || []); + + // Ensure essential fields are always loaded + fields.add("id"); + fields.add("display_name"); + + // Add sequence field if available (for ordering) + if (this.fields.sequence) { + fields.add("sequence"); + } + + return Array.from(fields); + } + + /** + * Group records by the groupBy field. + * + * @param {Array} records - Records to group + * @returns {Array} Grouped records + */ + _groupRecords(records) { + const groups = {}; + const groupBy = this.archInfo.groupBy; + + for (const record of records) { + const groupValue = record[groupBy]; + // Handle Many2one fields (array with [id, name]) + const groupName = Array.isArray(groupValue) + ? groupValue[1] + : groupValue || "Undefined"; + const groupId = Array.isArray(groupValue) ? groupValue[0] : groupValue; + + if (!groups[groupName]) { + groups[groupName] = { + name: groupName, + id: groupId, + records: [], + }; + } + groups[groupName].records.push(record); + } + + // Sort groups alphabetically by name + return Object.values(groups).sort((a, b) => + String(a.name).localeCompare(String(b.name)) + ); + } + + /** + * Generic resequence method for drag-and-drop operations. + * This base implementation updates the sequence field directly. + * Override in subclasses for model-specific behavior. + * + * @param {Number} recordId - ID of the record being moved + * @param {Number} targetGroupId - ID of the target group + * @param {Number|null} previousRecordId - ID of the preceding record (null = first) + * @returns {Promise} Result of the operation + */ + async resequence(recordId, targetGroupId, previousRecordId) { + if (!this.archInfo.defaultOrder) { + return {success: false, error: "Resequencing not configured"}; + } + + const record = this.data.records.find((r) => r.id === recordId); + if (!record) { + return {success: false, error: "Record not found"}; + } + + const updates = {}; + const sequenceField = this.archInfo.defaultOrder; + + // Calculate new sequence value + if (previousRecordId) { + const prevRecord = this.data.records.find((r) => r.id === previousRecordId); + if (prevRecord && prevRecord[sequenceField] !== undefined) { + updates[sequenceField] = prevRecord[sequenceField] + 1; + } else { + updates[sequenceField] = 10; + } + } else { + // Insert at beginning - find minimum sequence in target group + const targetRecords = this.data.records.filter((r) => { + if (!this.archInfo.groupField) return true; + const groupValue = r[this.archInfo.groupField]; + const groupId = Array.isArray(groupValue) ? groupValue[0] : groupValue; + return groupId === targetGroupId; + }); + + if (targetRecords.length > 0) { + const minSeq = Math.min( + ...targetRecords.map((r) => r[sequenceField] || 0) + ); + updates[sequenceField] = minSeq - 10; + } else { + updates[sequenceField] = 10; + } + } + + // Update group field if moving between groups + if (this.archInfo.groupField && targetGroupId !== null) { + const currentGroupValue = record[this.archInfo.groupField]; + const currentGroupId = Array.isArray(currentGroupValue) + ? currentGroupValue[0] + : currentGroupValue; + + if (currentGroupId !== targetGroupId) { + updates[this.archInfo.groupField] = targetGroupId; + } + } + + try { + await this.orm.write(this.resModel, [recordId], updates); + return {success: true}; + } catch (error) { + return {success: false, error: error.message}; + } + } + + /** + * Get records with valid coordinates. + * + * @returns {Array} Records with valid lat/lng + */ + getLocatedRecords() { + const {fieldLatitude, fieldLongitude} = this.archInfo; + return this.data.records.filter((r) => { + const lat = r[fieldLatitude]; + const lng = r[fieldLongitude]; + return this._validateCoordinates(lat, lng); + }); + } + + /** + * Get records without valid coordinates. + * + * @returns {Array} Records without valid lat/lng + */ + getUnlocatedRecords() { + const {fieldLatitude, fieldLongitude} = this.archInfo; + return this.data.records.filter((r) => { + const lat = r[fieldLatitude]; + const lng = r[fieldLongitude]; + return !this._validateCoordinates(lat, lng); + }); + } + + /** + * Validate that coordinates are within valid ranges. + * + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Boolean} + */ + _validateCoordinates(lat, lng) { + try { + const parsedLat = parseFloat(lat); + const parsedLng = parseFloat(lng); + return ( + !isNaN(parsedLat) && + !isNaN(parsedLng) && + parsedLat >= -90 && + parsedLat <= 90 && + parsedLng >= -180 && + parsedLng <= 180 + ); + } catch { + return false; + } + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js new file mode 100644 index 000000000..c66b695a0 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js @@ -0,0 +1,581 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +/* global L, document, window */ + +import { + Component, + onMounted, + onPatched, + onWillStart, + useRef, + useState, +} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {session} from "@web/session"; + +import {PinList} from "../components/pin-list/pin_list.esm"; + +// Default colors for group markers +const GROUP_COLORS = [ + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#96CEB4", + "#FFEAA7", + "#DDA0DD", + "#98D8C8", + "#F7DC6F", + "#BB8FCE", + "#85C1E9", +]; + +/** + * LeafletMapRenderer component for displaying records on a Leaflet map. + * Supports markers, clustering, popups, routing, and a sidebar pin list. + */ +export class LeafletMapRenderer extends Component { + static template = "web_view_leaflet_map.LeafletMapRenderer"; + static components = {PinList}; + + static props = { + resModel: {type: String}, + archInfo: {type: Object}, + fields: {type: Object, optional: true}, + context: {type: Object, optional: true}, + model: {type: Object}, + onResequence: {type: Function, optional: true}, + // DataVersion changes when data is reloaded, triggering re-render + dataVersion: {type: Number, optional: true}, + // Accept additional props from extending modules for extensibility + "*": true, + }; + + /** + * Initializes the LeafletMapRenderer component. + */ + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.mapRef = useRef("mapContainer"); + + // Session configuration + this.leafletTileUrl = session["leaflet.tile_url"]; + this.leafletCopyright = session["leaflet.copyright"]; + + // Extract configuration from archInfo + const archInfo = this.props.archInfo; + this.resModel = this.props.resModel; + this.fieldLatitude = archInfo.fieldLatitude; + this.fieldLongitude = archInfo.fieldLongitude; + this.fieldTitle = archInfo.fieldTitle; + this.fieldAddress = archInfo.fieldAddress; + this.fieldMarkerIconImage = archInfo.fieldMarkerIconImage; + + // Marker icon configuration + this.markerIconSizeX = archInfo.markerIconSizeX || 64; + this.markerIconSizeY = archInfo.markerIconSizeY || 64; + this.markerPopupAnchorX = archInfo.markerPopupAnchorX || 0; + this.markerPopupAnchorY = archInfo.markerPopupAnchorY || -32; + + // Map configuration + this.defaultZoom = archInfo.defaultZoom || 7; + this.maxZoom = archInfo.maxZoom || 19; + this.zoomSnap = archInfo.zoomSnap || 1; + + // View options + this.showPinList = archInfo.showPinList !== false; + this.groupBy = archInfo.groupBy; + this.panelTitle = archInfo.panelTitle || "Locations"; + this.showNumberedMarkers = archInfo.numberedMarkers === true; + this.enableRouting = archInfo.routing === true; + this.enableNavigation = archInfo.enableNavigation !== false; + + // Drag-and-drop configuration + this.draggable = archInfo.draggable === true; + this.groupField = archInfo.groupField; + this.defaultOrder = archInfo.defaultOrder; + + // Configurable unassigned group name + this.unassignedGroupName = archInfo.unassignedGroupName || "Unassigned"; + + // Internal state (for backward compatibility with direct rendering) + this.state = useState({ + selectedRecord: null, + }); + + // Map references + this.leafletMap = null; + this.mainLayer = null; + this.routeLayer = null; + this.markersById = {}; + this.groupColors = {}; + this.defaultLatLng = null; + + onWillStart(async () => { + await this.initDefaultPosition(); + }); + + onMounted(() => { + this.initMap(); + this.renderMarkers(); + }); + + onPatched(() => { + if (this.leafletMap) { + this.renderMarkers(); + } + }); + } + + /** + * Get records from the model. + */ + get records() { + return this.props.model.data.records || []; + } + + /** + * Get loading state from the model. + */ + get loading() { + return this.props.model.data.loading; + } + + /** + * Validates that coordinates are within valid ranges. + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Boolean} + */ + validateCoordinates(lat, lng) { + try { + const parsedLat = parseFloat(lat); + const parsedLng = parseFloat(lng); + return ( + !isNaN(parsedLat) && + !isNaN(parsedLng) && + parsedLat >= -90 && + parsedLat <= 90 && + parsedLng >= -180 && + parsedLng <= 180 + ); + } catch { + return false; + } + } + + /** + * Initializes the default position of the map by calling the server method. + * @returns {Promise} + */ + async initDefaultPosition() { + const result = await this.orm.call( + "res.users", + "get_default_leaflet_position", + [this.resModel] + ); + this.defaultLatLng = L.latLng(result.lat, result.lng); + } + + /** + * Initializes the Leaflet map in the container. + */ + initMap() { + const mapDiv = this.mapRef.el; + if (!mapDiv) { + return; + } + + this.leafletMap = L.map(mapDiv, { + zoomSnap: this.zoomSnap, + }).setView(this.defaultLatLng, this.defaultZoom); + + L.tileLayer(this.leafletTileUrl, { + maxZoom: this.maxZoom, + attribution: this.leafletCopyright, + }).addTo(this.leafletMap); + + // Initialize route layer for polylines + if (this.enableRouting) { + this.routeLayer = L.layerGroup().addTo(this.leafletMap); + } + } + + /** + * Gets a color for a group based on its name. + * @param {String} groupName + * @returns {String} + */ + getGroupColor(groupName) { + if (this.groupColors[groupName]) { + return this.groupColors[groupName]; + } + + let hash = 0; + for (let i = 0; i < String(groupName).length; i++) { + hash = String(groupName).charCodeAt(i) + ((hash << 5) - hash); + } + const color = GROUP_COLORS[Math.abs(hash) % GROUP_COLORS.length]; + this.groupColors[groupName] = color; + return color; + } + + /** + * Gets the group name for a record. + * @param {Object} record + * @returns {string|null} + */ + getGroupName(record) { + if (!this.groupBy) return null; + const value = record[this.groupBy]; + // Handle Many2one fields (array with [id, name]) + return Array.isArray(value) ? value[1] : value || "Undefined"; + } + + /** + * Renders the markers on the map based on the loaded records. + */ + renderMarkers() { + if (!this.leafletMap) { + return; + } + + if (this.mainLayer) { + this.leafletMap.removeLayer(this.mainLayer); + } + + this.mainLayer = L.markerClusterGroup(); + this.markersById = {}; + + let markerIndex = 0; + for (const record of this.records) { + const marker = this.prepareMarker(record, markerIndex); + if (marker) { + this.mainLayer.addLayer(marker); + this.markersById[record.id] = marker; + markerIndex++; + } + } + + const bounds = this.mainLayer.getBounds(); + if (bounds.isValid()) { + this.leafletMap.fitBounds(bounds.pad(0.1)); + } + + this.leafletMap.addLayer(this.mainLayer); + + // Draw route lines if routing is enabled + if (this.enableRouting && this.records.length > 1) { + this.renderRouteLines(); + } + } + + /** + * Renders route lines connecting records in order. + * Groups records by groupBy field if configured. + */ + renderRouteLines() { + if (!this.routeLayer) { + this.routeLayer = L.layerGroup().addTo(this.leafletMap); + } + this.routeLayer.clearLayers(); + + // Group records by groupBy field if configured + const groups = {}; + for (const record of this.records) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + if (!this.validateCoordinates(lat, lng)) { + continue; + } + + let groupKey = "default"; + if (this.groupBy && record[this.groupBy]) { + const groupValue = record[this.groupBy]; + groupKey = Array.isArray(groupValue) ? groupValue[0] : groupValue; + } + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push({ + lat, + lng, + sequence: record.sequence || 0, + }); + } + + // Draw lines for each group + const colors = [ + "#007bff", + "#28a745", + "#dc3545", + "#ffc107", + "#17a2b8", + "#6f42c1", + ]; + let colorIndex = 0; + + for (const groupKey in groups) { + const stops = groups[groupKey].sort((a, b) => a.sequence - b.sequence); + + if (stops.length < 2) { + continue; + } + + const waypoints = stops.map((s) => [s.lat, s.lng]); + const color = colors[colorIndex % colors.length]; + + const polyline = L.polyline(waypoints, { + color: color, + weight: 3, + opacity: 0.7, + dashArray: "10, 10", + }); + + polyline.addTo(this.routeLayer); + colorIndex++; + } + } + + /** + * Prepares a Leaflet marker for the given record. + * @param {Object} record - The record object containing marker data + * @param {Number} index - The marker index for numbering + * @returns {L.Marker|null} + */ + prepareMarker(record, index) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + if (!lat || !lng || !this.validateCoordinates(lat, lng)) { + return null; + } + + const latlng = L.latLng(lat, lng); + const markerOptions = this.prepareMarkerOptions(record, index); + + const marker = L.marker(latlng, markerOptions); + const popup = L.popup().setContent(this.preparePopUpData(record, index)); + + marker.bindPopup(popup).on("popupopen", () => { + const selector = document.querySelector(".o_map_selector"); + if (selector) { + selector.addEventListener("click", (ev) => { + ev.preventDefault(); + this.onClickLeafletPopup(record); + }); + } + }); + + return marker; + } + + /** + * Creates a numbered marker icon using SVG. + * @param {Number} number - The number to display + * @param {String} color - The marker color + * @returns {L.DivIcon} + */ + createNumberedMarker(number, color = "#007bff") { + const svg = ` + + + + ${number} + + `; + + return L.divIcon({ + className: "o_leaflet_numbered_marker", + html: svg, + iconSize: [30, 40], + iconAnchor: [15, 40], + popupAnchor: [0, -40], + }); + } + + /** + * Prepares the Leaflet icon for the marker using the image field. + * @param {Object} record - The record object containing marker data + * @returns {L.Icon} + */ + prepareMarkerIcon(record) { + // Use any date field as cache buster, or fall back to current time + const lastUpdate = + record.date_localization || + record.write_date || + record.id || + new Date().toISOString(); + const unique = String(lastUpdate).replace(/[^0-9]/g, ""); + const iconUrl = `/web/image?model=${this.resModel}&id=${record.id}&field=${this.fieldMarkerIconImage}&unique=${unique}`; + + return L.icon({ + iconUrl: iconUrl, + className: "leaflet_marker_icon", + iconSize: [this.markerIconSizeX, this.markerIconSizeY], + popupAnchor: [this.markerPopupAnchorX, this.markerPopupAnchorY], + }); + } + + /** + * Prepares the options for the leaflet marker. + * @param {Object} record - The record object containing marker data + * @param {Number} index - The marker index + * @returns {Object} + */ + prepareMarkerOptions(record, index) { + const title = record[this.fieldTitle] || ""; + const result = { + title: title, + alt: title, + riseOnHover: true, + }; + + // Use numbered markers if enabled + if (this.showNumberedMarkers) { + const groupName = this.getGroupName(record); + const color = groupName ? this.getGroupColor(groupName) : "#007bff"; + result.icon = this.createNumberedMarker(index + 1, color); + } else if (this.fieldMarkerIconImage) { + result.icon = this.prepareMarkerIcon(record); + } + + return result; + } + + /** + * Prepares the HTML content for the leaflet popup. + * @param {Object} record - The record object containing marker data + * @param {Number} index - The marker index + * @returns {String} + */ + preparePopUpData(record, index) { + const title = record[this.fieldTitle] || record.display_name || ""; + const address = record[this.fieldAddress] || ""; + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + // Build navigation URL + const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; + + let html = ` +
+
+ ${this.showNumberedMarkers ? `${index + 1}. ` : ""} + ${this.escapeHtml(title)} +
+ ${address ? `
${this.escapeHtml(address)}
` : ""} + `; + + // Add navigation button if enabled + if (this.enableNavigation) { + html += ` + + `; + } + + html += `
`; + return html; + } + + /** + * Escapes HTML to prevent XSS. + * @param {String} text + * @returns {String} + */ + escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + /** + * Handles click on the leaflet popup to open the record form view. + * @param {Object} record - The record object containing marker data + */ + onClickLeafletPopup(record) { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: this.resModel, + res_id: record.id, + views: [[false, "form"]], + target: "current", + }); + } + + /** + * Centers the map on a specific record and opens its popup. + * Called from PinList component. + * @param {Object} record + */ + onPinClick(record) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + if (!this.validateCoordinates(lat, lng)) { + return; + } + + const marker = this.markersById[record.id]; + if (marker && this.leafletMap) { + // Center map on the marker + this.leafletMap.setView( + [lat, lng], + Math.max(this.leafletMap.getZoom(), 14) + ); + + // Open the marker popup (may need to unspider if in cluster) + if (this.mainLayer.hasLayer(marker)) { + this.mainLayer.zoomToShowLayer(marker, () => { + marker.openPopup(); + }); + } else { + marker.openPopup(); + } + } + } + + /** + * Handles navigation button click from PinList. + * Opens Google Maps directions in a new tab. + * @param {Object} record + */ + onNavigateClick(record) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + if (this.validateCoordinates(lat, lng)) { + const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; + window.open(url, "_blank"); + } + } + + /** + * Callback for resequence events from DraggablePinList. + * Delegates to the controller's onResequence handler. + */ + async onResequence(recordId, targetGroupId, previousRecordId) { + if (this.props.onResequence) { + return this.props.onResequence(recordId, targetGroupId, previousRecordId); + } + } + + /** + * Get the PinList component class to use. + * Override in subclasses to use DraggablePinList. + */ + get PinListComponent() { + return PinList; + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js new file mode 100644 index 000000000..6cfe486c4 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js @@ -0,0 +1,74 @@ +/** @odoo-module **/ + +import {registry} from "@web/core/registry"; + +import {LeafletMapArchParser} from "./leaflet_map_arch_parser.esm"; +import {LeafletMapController} from "./leaflet_map_controller.esm"; +import {LeafletMapModel} from "./leaflet_map_model.esm"; +import {LeafletMapRenderer} from "./leaflet_map_renderer.esm"; + +/** + * Definition of the leaflet_map view for Odoo. + * - Separate ArchParser, Model, Controller, Renderer classes + * - Extensible via spread syntax for custom views + * - Uses js_class attribute to select custom view implementations + */ +export const leafletMapView = { + type: "leaflet_map", + display_name: "Map", + icon: "fa fa-map-o", + multiRecord: true, + + // Core components + Controller: LeafletMapController, + Renderer: LeafletMapRenderer, + Model: LeafletMapModel, + ArchParser: LeafletMapArchParser, + + // Search menu configuration + searchMenuTypes: ["filter", "favorite"], + + /** + * Transform generic props into view-specific props. + * + * @param {Object} genericProps - Props from the view registry + * @param {Object} view - The view definition + * @returns {Object} Transformed props for the controller + */ + props(genericProps, view) { + const {arch, fields} = genericProps; + + // Parse the arch using the ArchParser + const archParser = new (view.ArchParser || LeafletMapArchParser)(); + const archInfo = archParser.parse(arch); + + // Check if a custom js_class is specified + let viewDefinition = view; + if (archInfo.jsClass) { + const customView = registry.category("views").get(archInfo.jsClass, null); + if (customView) { + viewDefinition = customView; + } + } + + return { + ...genericProps, + archInfo, + fields, + // Allow overriding Model and Renderer via js_class + Model: viewDefinition.Model || LeafletMapModel, + Renderer: viewDefinition.Renderer || LeafletMapRenderer, + }; + }, +}; + +// Register the view without force:true +registry.category("views").add("leaflet_map", leafletMapView); + +// Export components for extension +export { + LeafletMapArchParser, + LeafletMapController, + LeafletMapModel, + LeafletMapRenderer, +}; diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml new file mode 100644 index 000000000..81e7b42bb --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml @@ -0,0 +1,62 @@ + + + + + + + + +
+ + + + + + + +
+ + + + + +
+
+ + +
+
+ Loading... +
+
+
+
+
+
+ + diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css index cc88d6356..0b1230d86 100644 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css +++ b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css @@ -2,19 +2,44 @@ Custom CSS for the elements introduced by web_view_leaflet_map module */ +/* Main container with flex layout for sidebar + map */ .o_leaflet_main_container { display: flex; + flex-direction: row; align-content: stretch; - overflow-x: visible; + overflow: hidden; height: 100%; width: 100%; } +/* Map wrapper for positioning */ +.o_leaflet_map_wrapper { + flex: 1; + position: relative; + min-width: 0; +} + +/* Map container */ .o_leaflet_map_container { height: 100%; width: 100%; } +/* Loading overlay */ +.o_leaflet_loading_overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +/* Popup selector styling */ .o_map_selector { cursor: pointer; } @@ -23,12 +48,40 @@ background-color: #eee; } +/* Popup styling */ +.o_map_popup { + min-width: 150px; +} + +.o_map_popup .o_popup_number { + font-weight: bold; + color: #007bff; +} + +.o_map_popup .o_popup_address { + color: #6c757d; + font-size: 0.9em; + margin-top: 4px; +} + +.o_map_popup .o_popup_actions { + border-top: 1px solid #dee2e6; + padding-top: 8px; +} + +/* Marker icon styling */ .leaflet_marker_icon { background-color: white; border: 1px black solid; border-radius: 50% !important; } +/* Numbered marker styling */ +.o_leaflet_numbered_marker { + background: transparent !important; + border: none !important; +} + /* Overload leaflet CSS to work with custom specific things of Odoo CSS */ @@ -58,3 +111,10 @@ [aria-hidden="1"] { display: inline !important; } + +/* Responsive: Stack sidebar above map on mobile */ +@media (max-width: 768px) { + .o_leaflet_main_container { + flex-direction: column; + } +} From 3967929a88d31a25d4bc7f002ec2520bee7c8bfa Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 14:41:54 -0300 Subject: [PATCH 03/16] [ADD] web_view_leaflet_map_partner: Add auto-geocoding support Enhance partner map integration with automatic geocoding: Auto-geocoding: - Automatically geocode partner addresses on create/write - New auto_geocode boolean field to enable/disable per partner - Geocoding metadata: date and provider tracking - Uses leaflet.geocoding.mixin from web_leaflet_lib Partner Form Enhancements: - Geolocation group with lat/lng and geocoding fields - "View on Map" button (visible when coordinates exist) - "Geocode" button (visible when no coordinates) - Batch geocoding server action for list view Enhanced Map View: - Enable pin list sidebar - Enable navigation buttons - Declare additional fields (phone, email) for popups Co-authored-by: Luis Felipe Mileo --- web_view_leaflet_map_partner/README.rst | 18 +- web_view_leaflet_map_partner/__manifest__.py | 11 +- .../models/res_partner.py | 188 +++++++++++++++++- .../readme/CONTRIBUTORS.md | 4 +- .../static/description/index.html | 37 ++-- .../views/res_partner.xml | 79 +++++++- 6 files changed, 305 insertions(+), 32 deletions(-) diff --git a/web_view_leaflet_map_partner/README.rst b/web_view_leaflet_map_partner/README.rst index 5d8b42adc..a3486756c 100644 --- a/web_view_leaflet_map_partner/README.rst +++ b/web_view_leaflet_map_partner/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============================================= Leaflet Map View for Partners (OpenStreetMap) ============================================= @@ -17,7 +13,7 @@ Leaflet Map View for Partners (OpenStreetMap) .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github @@ -79,12 +75,17 @@ Authors ------- * GRAP +* KMEE Contributors ------------ - Sylvain LE GAL (https://www.twitter.com/legalsylvain) +- `KMEE `__: + + - Luis Felipe Mileo + Maintainers ----------- @@ -101,10 +102,13 @@ promote its widespread use. .. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px :target: https://github.com/legalsylvain :alt: legalsylvain +.. |maintainer-mileo| image:: https://github.com/mileo.png?size=40px + :target: https://github.com/mileo + :alt: mileo -Current `maintainer `__: +Current `maintainers `__: -|maintainer-legalsylvain| +|maintainer-legalsylvain| |maintainer-mileo| This module is part of the `OCA/geospatial `_ project on GitHub. diff --git a/web_view_leaflet_map_partner/__manifest__.py b/web_view_leaflet_map_partner/__manifest__.py index 3845fb3cc..ef073f0d3 100644 --- a/web_view_leaflet_map_partner/__manifest__.py +++ b/web_view_leaflet_map_partner/__manifest__.py @@ -1,8 +1,12 @@ +# Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + { "name": "Leaflet Map View for Partners (OpenStreetMap)", - "summary": "Add a leaflet map view for partners model", - "version": "18.0.1.0.1", - "author": "GRAP, Odoo Community Association (OCA)", + "summary": "Add a leaflet map view for partners with auto-geocoding support.", + "version": "18.0.1.1.0", + "author": "GRAP, KMEE, Odoo Community Association (OCA)", "website": "https://github.com/OCA/geospatial", "license": "AGPL-3", "category": "Extra Tools", @@ -19,5 +23,6 @@ "installable": True, "maintainers": [ "legalsylvain", + "mileo", ], } diff --git a/web_view_leaflet_map_partner/models/res_partner.py b/web_view_leaflet_map_partner/models/res_partner.py index c296e6015..53f8def33 100644 --- a/web_view_leaflet_map_partner/models/res_partner.py +++ b/web_view_leaflet_map_partner/models/res_partner.py @@ -1,6 +1,12 @@ # Copyright (C) 2019, Open Source Integrators +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import fields, models + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) class ResPartner(models.Model): @@ -8,6 +14,186 @@ class ResPartner(models.Model): display_address = fields.Char(compute="_compute_display_address") + # Flag to enable/disable auto-geocoding per partner + auto_geocode = fields.Boolean( + string="Auto-geocode Address", + default=True, + help="Automatically update coordinates when address changes.", + ) + + # Geocoding metadata + geocoding_date = fields.Datetime( + string="Last Geocoding", + readonly=True, + help="Date when coordinates were last updated via geocoding.", + ) + geocoding_provider = fields.Char( + readonly=True, + help="Provider used for the last geocoding (nominatim, mapbox).", + ) + def _compute_display_address(self): for partner in self: partner.display_address = partner._display_address(without_company=True) + + @api.model + def _get_geocoding_fields(self): + """ + Return the list of address fields that trigger auto-geocoding. + """ + return ["street", "street2", "city", "zip", "state_id", "country_id"] + + def write(self, vals): + """ + Override write to trigger auto-geocoding when address changes. + """ + result = super().write(vals) + + # Check if any geocoding-triggering field was modified + geocoding_fields = self._get_geocoding_fields() + if any(field in vals for field in geocoding_fields): + # Auto-geocode partners that have the flag enabled + partners_to_geocode = self.filtered( + lambda p: p.auto_geocode and not p.partner_latitude + ) + if partners_to_geocode: + partners_to_geocode._auto_geocode_address() + + return result + + @api.model_create_multi + def create(self, vals_list): + """ + Override create to trigger auto-geocoding for new partners with address. + """ + partners = super().create(vals_list) + + # Auto-geocode partners with address but no coordinates + geocoding_fields = self._get_geocoding_fields() + for partner, vals in zip(partners, vals_list, strict=False): + if ( + partner.auto_geocode + and any(vals.get(field) for field in geocoding_fields) + and not partner.partner_latitude + ): + partner._auto_geocode_address() + + return partners + + def _auto_geocode_address(self): + """ + Automatically geocode the partner's address. + + Uses the leaflet.geocoding.mixin if available. + Rate-limited to respect Nominatim's 1 req/sec requirement. + """ + GeocodingMixin = self.env.get("leaflet.geocoding.mixin") + if not GeocodingMixin: + _logger.warning( + "Geocoding mixin not available. " + "Install web_leaflet_lib for auto-geocoding." + ) + return + + for partner in self: + # Build address string + address = partner._build_geocoding_address() + if not address: + continue + + # Get country code for better geocoding results + country_code = None + if partner.country_id: + country_code = partner.country_id.code + + try: + result = GeocodingMixin.geocode_address(address, country_code) + + if result: + partner.write( + { + "partner_latitude": result["lat"], + "partner_longitude": result["lng"], + "geocoding_date": fields.Datetime.now(), + "geocoding_provider": result.get("provider", "unknown"), + } + ) + _logger.info( + "Auto-geocoded partner %s: %s -> (%s, %s)", + partner.id, + address, + result["lat"], + result["lng"], + ) + else: + _logger.warning( + "Failed to geocode partner %s: %s", + partner.id, + address, + ) + except Exception as e: + _logger.error( + "Error geocoding partner %s: %s", + partner.id, + e, + ) + + def _build_geocoding_address(self): + """ + Build address string for geocoding from partner fields. + + Returns: + str: Formatted address string + """ + self.ensure_one() + + parts = [] + + if self.street: + parts.append(self.street) + if self.street2: + parts.append(self.street2) + if self.city: + parts.append(self.city) + if self.state_id: + parts.append(self.state_id.name) + if self.zip: + parts.append(self.zip) + if self.country_id: + parts.append(self.country_id.name) + + return ", ".join(parts) + + def action_geocode_address(self): + """ + Manual action to geocode the partner's address. + """ + self._auto_geocode_address() + return True + + def action_clear_coordinates(self): + """ + Clear the partner's coordinates. + """ + self.write( + { + "partner_latitude": 0.0, + "partner_longitude": 0.0, + "geocoding_date": False, + "geocoding_provider": False, + } + ) + return True + + def action_open_in_map(self): + """ + Open the partner's location on a map. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": f"Location: {self.display_name}", + "res_model": "res.partner", + "view_mode": "leaflet_map", + "domain": [("id", "=", self.id)], + } diff --git a/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md b/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md index 4a6b63400..ad11e40c5 100644 --- a/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md +++ b/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md @@ -1 +1,3 @@ -- Sylvain LE GAL () +* Sylvain LE GAL () +- [KMEE](https://kmee.com.br/): + - Luis Felipe Mileo \<\> diff --git a/web_view_leaflet_map_partner/static/description/index.html b/web_view_leaflet_map_partner/static/description/index.html index 1b9d4efe6..41a964b5f 100644 --- a/web_view_leaflet_map_partner/static/description/index.html +++ b/web_view_leaflet_map_partner/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Leaflet Map View for Partners (OpenStreetMap) -
+
+

Leaflet Map View for Partners (OpenStreetMap)

- - -Odoo Community Association - -
-

Leaflet Map View for Partners (OpenStreetMap)

-

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

This module extends the web_view_leaflet_map odoo module, to add a map view on the res.partner model.

Table of contents

@@ -392,12 +387,12 @@

Leaflet Map View for Partners (OpenStreetMap)

-

Configuration

+

Configuration

You should configure first the module web_view_leaflet_map to enable the feature.

-

Usage

+

Usage

  • go to ‘Contact’.
  • a new map icon is available.
  • @@ -410,7 +405,7 @@

    Usage

    image2

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -418,21 +413,26 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • GRAP
  • +
  • KMEE
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -440,13 +440,12 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

Current maintainer:

-

legalsylvain

+

Current maintainers:

+

legalsylvain mileo

This module is part of the OCA/geospatial project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/web_view_leaflet_map_partner/views/res_partner.xml b/web_view_leaflet_map_partner/views/res_partner.xml index b5f871303..cb6e6cfd8 100644 --- a/web_view_leaflet_map_partner/views/res_partner.xml +++ b/web_view_leaflet_map_partner/views/res_partner.xml @@ -1,5 +1,11 @@ + + res.partner @@ -9,18 +15,89 @@ field_title="display_name" field_address="display_address" field_marker_icon_image="avatar_128" - /> + show_pin_list="1" + enable_navigation="1" + panel_title="Contacts" + > + + + + + + + + + kanban,list,form,activity,leaflet_map + leaflet_map + + + + res.partner.form.geocoding + res.partner + + + + + + + + + + + + + + + + + + + + + + + + Geocode Selected Partners + + + list + code + +if records: + records.filtered(lambda p: not p.partner_latitude)._auto_geocode_address() + + From a2a4a4bf1593ab1c06e01f3518e5e2039d13e713 Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 14:49:02 -0300 Subject: [PATCH 04/16] [ADD] web_leaflet_routing: Add routing and geocoding services New module providing comprehensive routing and geocoding services for Leaflet map views. Backend (Python): - RoutingMixin with OSRM and MapBox support - Route calculation between waypoints - Optimized routes (TSP - Traveling Salesman Problem) - Distance matrix calculation - Configurable providers and fallback Frontend (JavaScript): - RoutingService for client-side route calculation - GeocodingService for address lookup with: - Forward geocoding (address to coordinates) - Reverse geocoding (coordinates to address) - Autocomplete/search suggestions - Rate limiting for Nominatim compliance - Batch geocoding support Features: - OSRM integration (free, self-hostable) - MapBox integration (premium, higher limits) - Automatic provider fallback - Distance and duration formatting Co-authored-by: Luis Felipe Mileo --- web_leaflet_routing/README.rst | 95 ++++ web_leaflet_routing/__init__.py | 4 + web_leaflet_routing/__manifest__.py | 31 ++ web_leaflet_routing/models/__init__.py | 4 + web_leaflet_routing/models/routing_mixin.py | 419 +++++++++++++++++ web_leaflet_routing/pyproject.toml | 3 + web_leaflet_routing/readme/CONTRIBUTORS.md | 1 + web_leaflet_routing/readme/DESCRIPTION.md | 11 + .../security/ir.model.access.csv | 2 + .../static/description/index.html | 435 +++++++++++++++++ .../src/components/routing_renderer.esm.js | 234 ++++++++++ .../static/src/geocoding_service.esm.js | 440 ++++++++++++++++++ .../static/src/routing_service.esm.js | 364 +++++++++++++++ 13 files changed, 2043 insertions(+) create mode 100644 web_leaflet_routing/README.rst create mode 100644 web_leaflet_routing/__init__.py create mode 100644 web_leaflet_routing/__manifest__.py create mode 100644 web_leaflet_routing/models/__init__.py create mode 100644 web_leaflet_routing/models/routing_mixin.py create mode 100644 web_leaflet_routing/pyproject.toml create mode 100644 web_leaflet_routing/readme/CONTRIBUTORS.md create mode 100644 web_leaflet_routing/readme/DESCRIPTION.md create mode 100644 web_leaflet_routing/security/ir.model.access.csv create mode 100644 web_leaflet_routing/static/description/index.html create mode 100644 web_leaflet_routing/static/src/components/routing_renderer.esm.js create mode 100644 web_leaflet_routing/static/src/geocoding_service.esm.js create mode 100644 web_leaflet_routing/static/src/routing_service.esm.js diff --git a/web_leaflet_routing/README.rst b/web_leaflet_routing/README.rst new file mode 100644 index 000000000..5998425f9 --- /dev/null +++ b/web_leaflet_routing/README.rst @@ -0,0 +1,95 @@ +=============== +Leaflet Routing +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:83c0e79839a2e4d73c9c51893d41ce2e3eef5f737b9d790d4ab7935d15ff14a6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github + :target: https://github.com/OCA/geospatial/tree/18.0/web_leaflet_routing + :alt: OCA/geospatial +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/geospatial-18-0/geospatial-18-0-web_leaflet_routing + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/geospatial&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides routing services for Leaflet map views. + +Features: + +- OSRM routing for real road-based routes +- MapBox routing support (premium) +- Geocoding services (Nominatim/MapBox) +- Distance matrix calculation +- Route optimization + +The module adds a mixin that can be used by other modules to integrate +routing functionality into their map views. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* KMEE + +Contributors +------------ + +- Luis Felipe Mileo mileo@kmee.com.br + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-miloefb| image:: https://github.com/miloefb.png?size=40px + :target: https://github.com/miloefb + :alt: miloefb + +Current `maintainer `__: + +|maintainer-miloefb| + +This module is part of the `OCA/geospatial `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_leaflet_routing/__init__.py b/web_leaflet_routing/__init__.py new file mode 100644 index 000000000..10b23894f --- /dev/null +++ b/web_leaflet_routing/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/web_leaflet_routing/__manifest__.py b/web_leaflet_routing/__manifest__.py new file mode 100644 index 000000000..10b144789 --- /dev/null +++ b/web_leaflet_routing/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Leaflet Routing", + "summary": "Add OSRM/MapBox routing and geocoding services to Leaflet maps.", + "version": "18.0.1.0.0", + "author": "KMEE, Odoo Community Association (OCA)", + "maintainers": ["miloefb"], + "development_status": "Beta", + "website": "https://github.com/OCA/geospatial", + "license": "AGPL-3", + "category": "Extra Tools", + "depends": [ + "web_view_leaflet_map", + ], + "external_dependencies": { + "python": ["requests"], + }, + "data": [ + "security/ir.model.access.csv", + ], + "assets": { + "web.assets_backend": [ + "web_leaflet_routing/static/src/routing_service.esm.js", + "web_leaflet_routing/static/src/geocoding_service.esm.js", + "web_leaflet_routing/static/src/components/routing_renderer.esm.js", + ], + }, + "installable": True, +} diff --git a/web_leaflet_routing/models/__init__.py b/web_leaflet_routing/models/__init__.py new file mode 100644 index 000000000..fe0b05d7f --- /dev/null +++ b/web_leaflet_routing/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import routing_mixin diff --git a/web_leaflet_routing/models/routing_mixin.py b/web_leaflet_routing/models/routing_mixin.py new file mode 100644 index 000000000..712d04405 --- /dev/null +++ b/web_leaflet_routing/models/routing_mixin.py @@ -0,0 +1,419 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +import requests + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class RoutingMixin(models.AbstractModel): + """Mixin providing routing capabilities using OSRM or MapBox.""" + + _name = "leaflet.routing.mixin" + _description = "Leaflet Routing Mixin" + + @api.model + def _get_routing_provider(self): + """Get the configured routing provider.""" + config = self.env["ir.config_parameter"].sudo() + return config.get_param("leaflet.routing_provider", "osrm") + + @api.model + def _get_osrm_url(self): + """Get OSRM server URL.""" + config = self.env["ir.config_parameter"].sudo() + return config.get_param("leaflet.osrm_url", "https://router.project-osrm.org") + + @api.model + def _get_mapbox_token(self): + """Get MapBox API token if configured.""" + config = self.env["ir.config_parameter"].sudo() + return config.get_param("leaflet.mapbox_token", "") + + @api.model + def _get_max_waypoints(self): + """Get maximum waypoints for routing.""" + config = self.env["ir.config_parameter"].sudo() + return int(config.get_param("leaflet.max_waypoints", "25")) + + @api.model + def get_route(self, waypoints, profile="driving"): + """ + Get a route between waypoints. + + Args: + waypoints: List of [lat, lng] coordinate pairs + profile: Routing profile (driving, walking, cycling) + + Returns: + dict with: + - geometry: List of [lat, lng] coordinates for polyline + - distance: Total distance in meters + - duration: Total duration in seconds + - legs: Route segments between waypoints + """ + if len(waypoints) < 2: + return None + + max_waypoints = self._get_max_waypoints() + if len(waypoints) > max_waypoints: + _logger.warning( + "Too many waypoints (%d), truncating to %d", + len(waypoints), + max_waypoints, + ) + waypoints = waypoints[:max_waypoints] + + provider = self._get_routing_provider() + mapbox_token = self._get_mapbox_token() + + # Try MapBox first if configured + if provider == "mapbox" or (provider == "auto" and mapbox_token): + result = self._get_route_mapbox(waypoints, profile) + if result: + return result + if provider == "mapbox": + _logger.warning("MapBox routing failed, no fallback configured") + return None + + # Fallback to OSRM + return self._get_route_osrm(waypoints, profile) + + @api.model + def _get_route_osrm(self, waypoints, profile="driving"): + """Get route using OSRM API.""" + base_url = self._get_osrm_url() + + # OSRM expects lng,lat order + coords = ";".join([f"{wp[1]},{wp[0]}" for wp in waypoints]) + url = f"{base_url}/route/v1/{profile}/{coords}" + + params = { + "overview": "full", + "geometries": "geojson", + "steps": "true", + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("OSRM routing failed: %s", data.get("message")) + return None + + route = data["routes"][0] + + # Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + geometry = [ + [coord[1], coord[0]] for coord in route["geometry"]["coordinates"] + ] + + return { + "geometry": geometry, + "distance": route["distance"], + "duration": route["duration"], + "legs": [ + { + "distance": leg["distance"], + "duration": leg["duration"], + "steps": [ + { + "distance": step["distance"], + "duration": step["duration"], + "instruction": step.get("maneuver", {}).get( + "instruction", "" + ), + "name": step.get("name", ""), + } + for step in leg.get("steps", []) + ], + } + for leg in route["legs"] + ], + "provider": "osrm", + } + + except requests.RequestException as e: + _logger.warning("OSRM routing request failed: %s", e) + return None + + @api.model + def _get_route_mapbox(self, waypoints, profile="driving"): + """Get route using MapBox Directions API.""" + token = self._get_mapbox_token() + if not token: + return None + + # Map profile names to MapBox profiles + profile_map = { + "driving": "driving", + "walking": "walking", + "cycling": "cycling", + } + mapbox_profile = profile_map.get(profile, "driving") + + # MapBox expects lng,lat order + coords = ";".join([f"{wp[1]},{wp[0]}" for wp in waypoints]) + url = f"https://api.mapbox.com/directions/v5/mapbox/{mapbox_profile}/{coords}" + + params = { + "access_token": token, + "overview": "full", + "geometries": "geojson", + "steps": "true", + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("MapBox routing failed: %s", data.get("message")) + return None + + route = data["routes"][0] + + # Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + geometry = [ + [coord[1], coord[0]] for coord in route["geometry"]["coordinates"] + ] + + return { + "geometry": geometry, + "distance": route["distance"], + "duration": route["duration"], + "legs": [ + { + "distance": leg["distance"], + "duration": leg["duration"], + "steps": [ + { + "distance": step["distance"], + "duration": step["duration"], + "instruction": step.get("maneuver", {}).get( + "instruction", "" + ), + "name": step.get("name", ""), + } + for step in leg.get("steps", []) + ], + } + for leg in route["legs"] + ], + "provider": "mapbox", + } + + except requests.RequestException as e: + _logger.warning("MapBox routing request failed: %s", e) + return None + + @api.model + def get_optimized_route(self, waypoints, profile="driving", roundtrip=False): + """ + Get an optimized route visiting all waypoints (TSP). + + Args: + waypoints: List of [lat, lng] coordinate pairs + profile: Routing profile + roundtrip: Whether to return to the starting point + + Returns: + dict with optimized route and waypoint ordering + """ + if len(waypoints) < 2: + return None + + provider = self._get_routing_provider() + mapbox_token = self._get_mapbox_token() + + # Try MapBox first if configured (has optimization API) + if provider == "mapbox" or (provider == "auto" and mapbox_token): + result = self._get_optimized_route_mapbox(waypoints, profile, roundtrip) + if result: + return result + + # Fallback to OSRM trip endpoint + return self._get_optimized_route_osrm(waypoints, profile, roundtrip) + + @api.model + def _get_optimized_route_osrm(self, waypoints, profile="driving", roundtrip=False): + """Get optimized route using OSRM Trip API.""" + base_url = self._get_osrm_url() + + # OSRM expects lng,lat order + coords = ";".join([f"{wp[1]},{wp[0]}" for wp in waypoints]) + url = f"{base_url}/trip/v1/{profile}/{coords}" + + params = { + "overview": "full", + "geometries": "geojson", + "steps": "true", + "roundtrip": "true" if roundtrip else "false", + "source": "first", + "destination": "last", + } + + try: + response = requests.get(url, params=params, timeout=60) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("OSRM trip failed: %s", data.get("message")) + return None + + trip = data["trips"][0] + + # Get waypoint ordering + waypoint_order = [wp["waypoint_index"] for wp in data["waypoints"]] + + # Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + geometry = [ + [coord[1], coord[0]] for coord in trip["geometry"]["coordinates"] + ] + + return { + "geometry": geometry, + "distance": trip["distance"], + "duration": trip["duration"], + "waypoint_order": waypoint_order, + "optimized_waypoints": [waypoints[i] for i in waypoint_order], + "provider": "osrm", + } + + except requests.RequestException as e: + _logger.warning("OSRM trip request failed: %s", e) + return None + + @api.model + def _get_optimized_route_mapbox( + self, waypoints, profile="driving", roundtrip=False + ): + """Get optimized route using MapBox Optimization API.""" + token = self._get_mapbox_token() + if not token: + return None + + # Map profile names + profile_map = { + "driving": "driving", + "walking": "walking", + "cycling": "cycling", + } + mapbox_profile = profile_map.get(profile, "driving") + + # MapBox expects lng,lat order + coords = ";".join([f"{wp[1]},{wp[0]}" for wp in waypoints]) + url = ( + f"https://api.mapbox.com/optimized-trips/v1/" + f"mapbox/{mapbox_profile}/{coords}" + ) + + params = { + "access_token": token, + "overview": "full", + "geometries": "geojson", + "steps": "true", + "roundtrip": "true" if roundtrip else "false", + "source": "first", + "destination": "last", + } + + try: + response = requests.get(url, params=params, timeout=60) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("MapBox optimization failed: %s", data.get("message")) + return None + + trip = data["trips"][0] + + # Get waypoint ordering + waypoint_order = [wp["waypoint_index"] for wp in data["waypoints"]] + + # Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + geometry = [ + [coord[1], coord[0]] for coord in trip["geometry"]["coordinates"] + ] + + return { + "geometry": geometry, + "distance": trip["distance"], + "duration": trip["duration"], + "waypoint_order": waypoint_order, + "optimized_waypoints": [waypoints[i] for i in waypoint_order], + "provider": "mapbox", + } + + except requests.RequestException as e: + _logger.warning("MapBox optimization request failed: %s", e) + return None + + @api.model + def get_distance_matrix(self, origins, destinations=None): + """ + Get distance matrix between origins and destinations. + + Args: + origins: List of [lat, lng] coordinate pairs + destinations: List of [lat, lng] pairs (defaults to origins) + + Returns: + dict with: + - distances: 2D list of distances in meters + - durations: 2D list of durations in seconds + """ + if destinations is None: + destinations = origins + + # OSRM supports table API + return self._get_distance_matrix_osrm(origins, destinations) + + @api.model + def _get_distance_matrix_osrm(self, origins, destinations): + """Get distance matrix using OSRM Table API.""" + base_url = self._get_osrm_url() + + # Combine all coordinates + all_coords = origins + destinations + coords = ";".join([f"{wp[1]},{wp[0]}" for wp in all_coords]) + + # Build source and destination indices + source_indices = list(range(len(origins))) + dest_indices = list(range(len(origins), len(all_coords))) + + url = f"{base_url}/table/v1/driving/{coords}" + + params = { + "sources": ";".join(map(str, source_indices)), + "destinations": ";".join(map(str, dest_indices)), + "annotations": "distance,duration", + } + + try: + response = requests.get(url, params=params, timeout=60) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("OSRM table failed: %s", data.get("message")) + return None + + return { + "distances": data.get("distances", []), + "durations": data.get("durations", []), + "provider": "osrm", + } + + except requests.RequestException as e: + _logger.warning("OSRM table request failed: %s", e) + return None diff --git a/web_leaflet_routing/pyproject.toml b/web_leaflet_routing/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/web_leaflet_routing/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_leaflet_routing/readme/CONTRIBUTORS.md b/web_leaflet_routing/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..930c23505 --- /dev/null +++ b/web_leaflet_routing/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* Luis Felipe Mileo diff --git a/web_leaflet_routing/readme/DESCRIPTION.md b/web_leaflet_routing/readme/DESCRIPTION.md new file mode 100644 index 000000000..58b01c941 --- /dev/null +++ b/web_leaflet_routing/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module provides routing services for Leaflet map views. + +Features: +- OSRM routing for real road-based routes +- MapBox routing support (premium) +- Geocoding services (Nominatim/MapBox) +- Distance matrix calculation +- Route optimization + +The module adds a mixin that can be used by other modules to integrate +routing functionality into their map views. diff --git a/web_leaflet_routing/security/ir.model.access.csv b/web_leaflet_routing/security/ir.model.access.csv new file mode 100644 index 000000000..c431c8349 --- /dev/null +++ b/web_leaflet_routing/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_leaflet_routing_mixin,leaflet.routing.mixin,model_leaflet_routing_mixin,base.group_user,1,0,0,0 diff --git a/web_leaflet_routing/static/description/index.html b/web_leaflet_routing/static/description/index.html new file mode 100644 index 000000000..a877dce83 --- /dev/null +++ b/web_leaflet_routing/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Leaflet Routing + + + +
+

Leaflet Routing

+ + +

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

This module provides routing services for Leaflet map views.

+

Features:

+
    +
  • OSRM routing for real road-based routes
  • +
  • MapBox routing support (premium)
  • +
  • Geocoding services (Nominatim/MapBox)
  • +
  • Distance matrix calculation
  • +
  • Route optimization
  • +
+

The module adds a mixin that can be used by other modules to integrate +routing functionality into their map views.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • KMEE
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

miloefb

+

This module is part of the OCA/geospatial project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_leaflet_routing/static/src/components/routing_renderer.esm.js b/web_leaflet_routing/static/src/components/routing_renderer.esm.js new file mode 100644 index 000000000..fc36fe36c --- /dev/null +++ b/web_leaflet_routing/static/src/components/routing_renderer.esm.js @@ -0,0 +1,234 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +/* global L, console */ + +import {RoutingService} from "../routing_service.esm"; + +/** + * RoutingRenderer provides generic route visualization functionality. + * Can be used standalone or integrated with LeafletMapRenderer subclasses. + * + * Features: + * - OSRM/MapBox routing via RoutingService + * - Grouping support for multiple routes + * - Fallback to dashed polylines when routing fails + * - Configurable colors and options + */ +export class RoutingRenderer { + /** + * Create a new RoutingRenderer. + * @param {L.Map} map - The Leaflet map instance + * @param {RoutingService} routingService - Optional routing service instance + */ + constructor(map, routingService = null) { + this.map = map; + this.routingService = routingService || new RoutingService(); + this.routeLayerGroup = L.layerGroup(); + this.routeLayerGroup.addTo(this.map); + } + + /** + * Render routes on the map based on records. + * @param {Array} records - Array of record objects with coordinates + * @param {Object} config - Configuration options + * @param {String} config.groupBy - Field name to group records by + * @param {String} config.sequenceField - Field name for sequencing (default: "sequence") + * @param {String} config.latitudeField - Field name for latitude (default: "latitude") + * @param {String} config.longitudeField - Field name for longitude (default: "longitude") + * @param {Boolean} config.useRealRouting - Use OSRM routing (default: true) + * @param {Array} config.colors - Array of colors for routes + * @param {Boolean} config.skipUnassigned - Skip unassigned groups (default: false) + * @param {String} config.unassignedGroupName - Name for unassigned group (default: "Unassigned") + * @param {Function} config.validateCoordinates - Custom coordinate validator + */ + async renderRoutes(records, config = {}) { + const { + groupBy = null, + sequenceField = "sequence", + latitudeField = "latitude", + longitudeField = "longitude", + useRealRouting = true, + colors = ["#007bff", "#28a745", "#dc3545", "#ffc107", "#17a2b8", "#6f42c1"], + skipUnassigned = false, + unassignedGroupName = "Unassigned", + validateCoordinates = this._defaultValidateCoordinates.bind(this), + } = config; + + this.routeLayerGroup.clearLayers(); + + // Group records + const groups = this._groupRecords(records, { + groupBy, + latitudeField, + longitudeField, + sequenceField, + unassignedGroupName, + validateCoordinates, + }); + + let colorIndex = 0; + for (const groupKey of Object.keys(groups)) { + const group = groups[groupKey]; + + // Skip unassigned groups if configured + if (skipUnassigned && group.isUnassigned) { + continue; + } + + // Sort records by sequence + const sorted = [...group.records].sort( + (a, b) => (a.sequence || 0) - (b.sequence || 0) + ); + + const waypoints = sorted.map((r) => [r.lat, r.lng]); + + if (waypoints.length < 2) { + continue; + } + + const color = colors[colorIndex % colors.length]; + await this._drawRoute(waypoints, color, useRealRouting); + colorIndex++; + } + } + + /** + * Draw a single route on the map. + * @param {Array} waypoints - Array of [lat, lng] pairs + * @param {String} color - Route color + * @param {Boolean} useRealRouting - Use OSRM routing + */ + async _drawRoute(waypoints, color, useRealRouting) { + if (useRealRouting) { + try { + const route = await this.routingService.getRoute(waypoints); + if (route && route.geometry) { + const polyline = L.polyline(route.geometry, { + color, + weight: 4, + opacity: 0.8, + }); + + // Add tooltip with distance and duration + const distKm = (route.distance / 1000).toFixed(1); + const durMin = Math.round(route.duration / 60); + polyline.bindTooltip(`${distKm} km \u2022 ${durMin} min`, { + sticky: true, + }); + + this.routeLayerGroup.addLayer(polyline); + return; + } + } catch (e) { + console.warn("OSRM routing failed, using fallback", e); + } + } + + // Fallback: dashed polyline + const polyline = L.polyline(waypoints, { + color, + weight: 3, + opacity: 0.7, + dashArray: "10, 10", + }); + this.routeLayerGroup.addLayer(polyline); + } + + /** + * Group records for routing. + * @param {Array} records - Array of record objects + * @param {Object} options - Grouping options + * @returns {Object} Groups object keyed by group name + */ + _groupRecords(records, options) { + const { + groupBy, + latitudeField, + longitudeField, + sequenceField, + unassignedGroupName, + validateCoordinates, + } = options; + + const groups = {}; + + for (const record of records) { + const lat = record[latitudeField]; + const lng = record[longitudeField]; + + if (!validateCoordinates(lat, lng)) { + continue; + } + + let groupKey = unassignedGroupName; + let isUnassigned = true; + + if (groupBy && record[groupBy]) { + const val = record[groupBy]; + // Handle Many2one fields (array with [id, name]) + groupKey = Array.isArray(val) ? String(val[0]) : String(val); + isUnassigned = false; + } + + if (!groups[groupKey]) { + groups[groupKey] = { + records: [], + isUnassigned, + }; + } + + groups[groupKey].records.push({ + lat, + lng, + sequence: record[sequenceField] || 0, + record, + }); + } + + return groups; + } + + /** + * Default coordinate validator. + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Boolean} + */ + _defaultValidateCoordinates(lat, lng) { + try { + const latNum = parseFloat(lat); + const lngNum = parseFloat(lng); + return ( + !isNaN(latNum) && + !isNaN(lngNum) && + latNum >= -90 && + latNum <= 90 && + lngNum >= -180 && + lngNum <= 180 + ); + } catch { + return false; + } + } + + /** + * Clear all routes from the map. + */ + clearRoutes() { + this.routeLayerGroup.clearLayers(); + } + + /** + * Remove the route layer group from the map. + */ + destroy() { + if (this.map && this.routeLayerGroup) { + this.map.removeLayer(this.routeLayerGroup); + } + } +} diff --git a/web_leaflet_routing/static/src/geocoding_service.esm.js b/web_leaflet_routing/static/src/geocoding_service.esm.js new file mode 100644 index 000000000..f4cca8764 --- /dev/null +++ b/web_leaflet_routing/static/src/geocoding_service.esm.js @@ -0,0 +1,440 @@ +/** @odoo-module **/ + +/* global console, fetch, URLSearchParams, setTimeout */ + +import {session} from "@web/session"; + +/** + * GeocodingService provides geocoding functionality using Nominatim (OSM) or MapBox. + * Default provider is Nominatim (free, but requires rate limiting of 1 req/sec). + */ +export class GeocodingService { + constructor() { + this.nominatimUrl = + session["leaflet.nominatim_url"] || "https://nominatim.openstreetmap.org"; + this.mapboxToken = session["leaflet.mapbox_token"] || ""; + this.provider = session["leaflet.geocoding_provider"] || "nominatim"; + this.throttleMs = session["leaflet.geocoding_throttle_ms"] || 1000; + + this._lastRequestTime = 0; + this._requestQueue = []; + this._processing = false; + } + + /** + * Throttle requests to respect API rate limits. + * @returns {Promise} + */ + async _throttle() { + const now = Date.now(); + const elapsed = now - this._lastRequestTime; + + if (elapsed < this.throttleMs) { + await new Promise((resolve) => + setTimeout(resolve, this.throttleMs - elapsed) + ); + } + + this._lastRequestTime = Date.now(); + } + + /** + * Geocode an address to coordinates. + * @param {String} address - Address to geocode + * @param {String} countryCode - Optional 2-letter ISO country code + * @returns {Promise} Object with lat, lng, display_name or null + */ + async geocode(address, countryCode = null) { + if (!address || address.trim() === "") { + return null; + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._geocodeMapBox(address, countryCode); + if (result) { + return result; + } + if (this.provider === "mapbox") { + console.warn("MapBox geocoding failed, no fallback"); + return null; + } + } + + // Fallback to Nominatim + return this._geocodeNominatim(address, countryCode); + } + + /** + * Geocode using Nominatim (OSM). + * @param {String} address + * @param {String} countryCode + * @returns {Promise} + */ + async _geocodeNominatim(address, countryCode = null) { + await this._throttle(); + + const params = new URLSearchParams({ + q: address, + format: "json", + limit: "1", + addressdetails: "1", + }); + + if (countryCode) { + params.append("countrycodes", countryCode.toLowerCase()); + } + + const url = `${this.nominatimUrl}/search?${params}`; + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Odoo-Leaflet-Map/1.0", + }, + }); + + const data = await response.json(); + + if (data && data.length > 0) { + const result = data[0]; + return { + lat: parseFloat(result.lat), + lng: parseFloat(result.lon), + displayName: result.display_name, + address: result.address, + provider: "nominatim", + }; + } + + return null; + } catch (error) { + console.error("Nominatim geocoding failed:", error); + return null; + } + } + + /** + * Geocode using MapBox Geocoding API. + * @param {String} address + * @param {String} countryCode + * @returns {Promise} + */ + async _geocodeMapBox(address, countryCode = null) { + if (!this.mapboxToken) { + return null; + } + + const encodedAddress = encodeURIComponent(address); + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + limit: "1", + }); + + if (countryCode) { + params.append("country", countryCode.toLowerCase()); + } + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + const features = data.features || []; + if (features.length > 0) { + const result = features[0]; + const [lng, lat] = result.geometry.coordinates; + return { + lat, + lng, + displayName: result.place_name, + address: result.context || {}, + provider: "mapbox", + }; + } + + return null; + } catch (error) { + console.error("MapBox geocoding failed:", error); + return null; + } + } + + /** + * Reverse geocode coordinates to an address. + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Promise} + */ + async reverseGeocode(lat, lng) { + // Validate coordinates + if (!this._validateCoordinates(lat, lng)) { + return null; + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._reverseGeocodeMapBox(lat, lng); + if (result) { + return result; + } + } + + // Fallback to Nominatim + return this._reverseGeocodeNominatim(lat, lng); + } + + /** + * Reverse geocode using Nominatim. + * @param {Number} lat + * @param {Number} lng + * @returns {Promise} + */ + async _reverseGeocodeNominatim(lat, lng) { + await this._throttle(); + + const params = new URLSearchParams({ + lat: lat.toString(), + lon: lng.toString(), + format: "json", + addressdetails: "1", + }); + + const url = `${this.nominatimUrl}/reverse?${params}`; + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Odoo-Leaflet-Map/1.0", + }, + }); + + const data = await response.json(); + + if (data && data.address) { + return { + address: data.address, + displayName: data.display_name, + provider: "nominatim", + }; + } + + return null; + } catch (error) { + console.error("Nominatim reverse geocoding failed:", error); + return null; + } + } + + /** + * Reverse geocode using MapBox. + * @param {Number} lat + * @param {Number} lng + * @returns {Promise} + */ + async _reverseGeocodeMapBox(lat, lng) { + if (!this.mapboxToken) { + return null; + } + + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + limit: "1", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + const features = data.features || []; + if (features.length > 0) { + const result = features[0]; + return { + address: result.context || {}, + displayName: result.place_name, + provider: "mapbox", + }; + } + + return null; + } catch (error) { + console.error("MapBox reverse geocoding failed:", error); + return null; + } + } + + /** + * Batch geocode multiple addresses. + * @param {Array} addresses + * @param {String} countryCode + * @returns {Promise>} + */ + async batchGeocode(addresses, countryCode = null) { + const results = []; + + for (const address of addresses) { + const result = await this.geocode(address, countryCode); + results.push(result); + } + + return results; + } + + /** + * Validate coordinates. + * @param {Number} lat + * @param {Number} lng + * @returns {Boolean} + */ + _validateCoordinates(lat, lng) { + return ( + typeof lat === "number" && + typeof lng === "number" && + !isNaN(lat) && + !isNaN(lng) && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 + ); + } + + /** + * Search for places/addresses with autocomplete. + * @param {String} query + * @param {Object} options - bbox, countryCode, limit + * @returns {Promise} + */ + async autocomplete(query, options = {}) { + if (!query || query.length < 3) { + return []; + } + + // MapBox has better autocomplete, try it first + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const results = await this._autocompleteMapBox(query, options); + if (results && results.length > 0) { + return results; + } + } + + // Fallback to Nominatim search + return this._autocompleteNominatim(query, options); + } + + /** + * Autocomplete using Nominatim. + * @param {String} query + * @param {Object} options + * @returns {Promise} + */ + async _autocompleteNominatim(query, options = {}) { + await this._throttle(); + + const params = new URLSearchParams({ + q: query, + format: "json", + limit: (options.limit || 5).toString(), + addressdetails: "1", + }); + + if (options.countryCode) { + params.append("countrycodes", options.countryCode.toLowerCase()); + } + + if (options.bbox) { + const [minLng, minLat, maxLng, maxLat] = options.bbox; + params.append("viewbox", `${minLng},${maxLat},${maxLng},${minLat}`); + params.append("bounded", "1"); + } + + const url = `${this.nominatimUrl}/search?${params}`; + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Odoo-Leaflet-Map/1.0", + }, + }); + + const data = await response.json(); + + return (data || []).map((item) => ({ + lat: parseFloat(item.lat), + lng: parseFloat(item.lon), + displayName: item.display_name, + address: item.address, + type: item.type, + provider: "nominatim", + })); + } catch (error) { + console.error("Nominatim autocomplete failed:", error); + return []; + } + } + + /** + * Autocomplete using MapBox. + * @param {String} query + * @param {Object} options + * @returns {Promise} + */ + async _autocompleteMapBox(query, options = {}) { + if (!this.mapboxToken) { + return []; + } + + const encodedQuery = encodeURIComponent(query); + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + limit: (options.limit || 5).toString(), + autocomplete: "true", + }); + + if (options.countryCode) { + params.append("country", options.countryCode.toLowerCase()); + } + + if (options.bbox) { + const [minLng, minLat, maxLng, maxLat] = options.bbox; + params.append("bbox", `${minLng},${minLat},${maxLng},${maxLat}`); + } + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + return (data.features || []).map((item) => { + const [lng, lat] = item.geometry.coordinates; + return { + lat, + lng, + displayName: item.place_name, + address: item.context || {}, + type: item.place_type?.[0] || "place", + provider: "mapbox", + }; + }); + } catch (error) { + console.error("MapBox autocomplete failed:", error); + return []; + } + } +} + +// Export singleton instance +export const geocodingService = new GeocodingService(); diff --git a/web_leaflet_routing/static/src/routing_service.esm.js b/web_leaflet_routing/static/src/routing_service.esm.js new file mode 100644 index 000000000..9162dfb69 --- /dev/null +++ b/web_leaflet_routing/static/src/routing_service.esm.js @@ -0,0 +1,364 @@ +/** @odoo-module **/ + +/* global console, fetch, URLSearchParams */ + +import {session} from "@web/session"; + +/** + * RoutingService provides routing functionality using OSRM or MapBox. + * Default provider is OSRM (free, no API key required). + */ +export class RoutingService { + constructor() { + this.osrmUrl = session["leaflet.osrm_url"] || "https://router.project-osrm.org"; + this.mapboxToken = session["leaflet.mapbox_token"] || ""; + this.provider = session["leaflet.routing_provider"] || "osrm"; + this.maxWaypoints = session["leaflet.max_waypoints"] || 25; + } + + /** + * Get a route between waypoints. + * @param {Array} waypoints - Array of [lat, lng] coordinate pairs + * @param {String} profile - Routing profile (driving, walking, cycling) + * @returns {Promise} Route object or null if failed + */ + async getRoute(waypoints, profile = "driving") { + if (waypoints.length < 2) { + return null; + } + + let routeWaypoints = waypoints; + if (waypoints.length > this.maxWaypoints) { + console.warn( + `Too many waypoints (${waypoints.length}), truncating to ${this.maxWaypoints}` + ); + routeWaypoints = waypoints.slice(0, this.maxWaypoints); + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._getRouteMapBox(routeWaypoints, profile); + if (result) { + return result; + } + if (this.provider === "mapbox") { + console.warn("MapBox routing failed, no fallback"); + return null; + } + } + + // Fallback to OSRM + return this._getRouteOSRM(routeWaypoints, profile); + } + + /** + * Get route using OSRM. + * @param {Array} waypoints + * @param {String} profile + * @returns {Promise} + */ + async _getRouteOSRM(waypoints, profile = "driving") { + // OSRM expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `${this.osrmUrl}/route/v1/${profile}/${coords}`; + + const params = new URLSearchParams({ + overview: "full", + geometries: "geojson", + steps: "true", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("OSRM routing failed:", data.message); + return null; + } + + const route = data.routes[0]; + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = route.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: route.distance, + duration: route.duration, + legs: route.legs.map((leg) => ({ + distance: leg.distance, + duration: leg.duration, + steps: (leg.steps || []).map((step) => ({ + distance: step.distance, + duration: step.duration, + instruction: step.maneuver?.instruction || "", + name: step.name || "", + })), + })), + provider: "osrm", + }; + } catch (error) { + console.error("OSRM routing request failed:", error); + return null; + } + } + + /** + * Get route using MapBox Directions API. + * @param {Array} waypoints + * @param {String} profile + * @returns {Promise} + */ + async _getRouteMapBox(waypoints, profile = "driving") { + if (!this.mapboxToken) { + return null; + } + + // Map profile names + const profileMap = { + driving: "driving", + walking: "walking", + cycling: "cycling", + }; + const mapboxProfile = profileMap[profile] || "driving"; + + // MapBox expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `https://api.mapbox.com/directions/v5/mapbox/${mapboxProfile}/${coords}`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + overview: "full", + geometries: "geojson", + steps: "true", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("MapBox routing failed:", data.message); + return null; + } + + const route = data.routes[0]; + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = route.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: route.distance, + duration: route.duration, + legs: route.legs.map((leg) => ({ + distance: leg.distance, + duration: leg.duration, + steps: (leg.steps || []).map((step) => ({ + distance: step.distance, + duration: step.duration, + instruction: step.maneuver?.instruction || "", + name: step.name || "", + })), + })), + provider: "mapbox", + }; + } catch (error) { + console.error("MapBox routing request failed:", error); + return null; + } + } + + /** + * Get optimized route (TSP) visiting all waypoints. + * @param {Array} waypoints - Array of [lat, lng] coordinate pairs + * @param {String} profile - Routing profile + * @param {Boolean} roundtrip - Return to starting point + * @returns {Promise} + */ + async getOptimizedRoute(waypoints, profile = "driving", roundtrip = false) { + if (waypoints.length < 2) { + return null; + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._getOptimizedRouteMapBox( + waypoints, + profile, + roundtrip + ); + if (result) { + return result; + } + } + + // Fallback to OSRM trip endpoint + return this._getOptimizedRouteOSRM(waypoints, profile, roundtrip); + } + + /** + * Get optimized route using OSRM Trip API. + * @param {Array} waypoints + * @param {String} profile + * @param {Boolean} roundtrip + * @returns {Promise} + */ + async _getOptimizedRouteOSRM(waypoints, profile = "driving", roundtrip = false) { + // OSRM expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `${this.osrmUrl}/trip/v1/${profile}/${coords}`; + + const params = new URLSearchParams({ + overview: "full", + geometries: "geojson", + steps: "true", + roundtrip: roundtrip ? "true" : "false", + source: "first", + destination: "last", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("OSRM trip failed:", data.message); + return null; + } + + const trip = data.trips[0]; + + // Get waypoint ordering + const waypointOrder = data.waypoints.map((wp) => wp.waypoint_index); + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = trip.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: trip.distance, + duration: trip.duration, + waypointOrder, + optimizedWaypoints: waypointOrder.map((i) => waypoints[i]), + provider: "osrm", + }; + } catch (error) { + console.error("OSRM trip request failed:", error); + return null; + } + } + + /** + * Get optimized route using MapBox Optimization API. + * @param {Array} waypoints + * @param {String} profile + * @param {Boolean} roundtrip + * @returns {Promise} + */ + async _getOptimizedRouteMapBox(waypoints, profile = "driving", roundtrip = false) { + if (!this.mapboxToken) { + return null; + } + + const profileMap = { + driving: "driving", + walking: "walking", + cycling: "cycling", + }; + const mapboxProfile = profileMap[profile] || "driving"; + + // MapBox expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/${mapboxProfile}/${coords}`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + overview: "full", + geometries: "geojson", + steps: "true", + roundtrip: roundtrip ? "true" : "false", + source: "first", + destination: "last", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("MapBox optimization failed:", data.message); + return null; + } + + const trip = data.trips[0]; + + // Get waypoint ordering + const waypointOrder = data.waypoints.map((wp) => wp.waypoint_index); + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = trip.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: trip.distance, + duration: trip.duration, + waypointOrder, + optimizedWaypoints: waypointOrder.map((i) => waypoints[i]), + provider: "mapbox", + }; + } catch (error) { + console.error("MapBox optimization request failed:", error); + return null; + } + } + + /** + * Format distance for display. + * @param {Number} meters + * @returns {String} + */ + formatDistance(meters) { + if (meters >= 1000) { + return `${(meters / 1000).toFixed(1)} km`; + } + return `${Math.round(meters)} m`; + } + + /** + * Format duration for display. + * @param {Number} seconds + * @returns {String} + */ + formatDuration(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}min`; + } + return `${minutes} min`; + } +} + +// Export singleton instance +export const routingService = new RoutingService(); From 67e1528cabdc43917d05231435494eea60db107d Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 20:22:38 -0300 Subject: [PATCH 05/16] [FIX] web_view_leaflet_map: clear group field when moving to unassigned When dragging a record to the unassigned group (targetGroupId = null), the group field was not being updated because of a condition that checked `targetGroupId !== null`. Now when moving to unassigned group, the group field is set to `false` which properly clears the Many2one field in Odoo. --- .../static/src/leaflet_map_view/leaflet_map_model.esm.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js index 2d3c74cb9..155d6ff1e 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js @@ -195,14 +195,16 @@ export class LeafletMapModel { } // Update group field if moving between groups - if (this.archInfo.groupField && targetGroupId !== null) { + if (this.archInfo.groupField) { const currentGroupValue = record[this.archInfo.groupField]; const currentGroupId = Array.isArray(currentGroupValue) ? currentGroupValue[0] : currentGroupValue; if (currentGroupId !== targetGroupId) { - updates[this.archInfo.groupField] = targetGroupId; + // Use false to clear Many2one field when moving to unassigned group + updates[this.archInfo.groupField] = + targetGroupId === null ? false : targetGroupId; } } From 1457970372821d67db0006d16af31a1032e5b18b Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 20:29:04 -0300 Subject: [PATCH 06/16] [FIX] web_view_leaflet_map: fix dragging to unassigned group The drag-and-drop logic had a bug where moving an item to the unassigned group (null groupId) was being overridden by the fallback logic that kept the source group. Now the logic correctly handles the case: - If parent element exists, use its groupId (null is valid) - If no parent, fall back to source group --- .../pin-list/draggable_pin_list.esm.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js index 87efd022f..0a3510b2c 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js @@ -5,7 +5,7 @@ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). */ -/* global console, document, window */ +/* global document, window */ import {useRef} from "@odoo/owl"; import {useService} from "@web/core/utils/hooks"; @@ -98,15 +98,11 @@ export class DraggablePinList extends PinList { */ async _handleDrop(element, parent, previous) { const recordId = parseInt(element.dataset.id, 10); - let targetGroupId = parent ? this._getGroupId(parent) : this._sourceGroupId; - - // Fallback to source group if target group ID couldn't be determined - if (targetGroupId === null && this._sourceGroupId !== null) { - console.warn( - "DraggablePinList: Could not determine target group, using source group" - ); - targetGroupId = this._sourceGroupId; - } + + // Determine target group ID + // If parent is provided, use its group ID (null for unassigned group is valid) + // If parent is null/undefined, keep the source group + const targetGroupId = parent ? this._getGroupId(parent) : this._sourceGroupId; // Find reference record (previous element after drop) let previousRecordId = null; From ce5f7de3c092dbd09d080855d2b3d6b00245e428 Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 21:18:58 -0300 Subject: [PATCH 07/16] [IMP] web_view_leaflet_map: remove duplicate count from header The located count was shown twice: in the header badge and in the stats line below search. Keep only the stats line which also shows "not located" count when applicable. --- .../static/src/components/pin-list/draggable_pin_list.xml | 3 --- .../static/src/components/pin-list/pin_list.xml | 3 --- 2 files changed, 6 deletions(-) diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml index 905ee6be4..e392ae4a0 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml @@ -28,9 +28,6 @@ This is a generic component for any model that supports resequencing. - - -
diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml index 2345c709a..98ee8aaaf 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml @@ -20,9 +20,6 @@ - - -
From 9ffad6d05e4ddb5d7bed01e131a6901cca0fdc2d Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Mon, 2 Feb 2026 22:00:47 -0300 Subject: [PATCH 08/16] [IMP] web_view_leaflet_map: add tooltip for truncated addresses Add t-att-title attribute to address div so the full address is shown on hover when the text is truncated. --- .../static/src/components/pin-list/draggable_pin_list.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml index e392ae4a0..94595d3cc 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml @@ -161,6 +161,7 @@ This is a generic component for any model that supports resequencing.
Date: Mon, 2 Feb 2026 23:29:55 -0300 Subject: [PATCH 09/16] [IMP] web_view_leaflet_map: add standard Odoo search bar and remove sidebar search This commit integrates the leaflet_map view with Odoo's standard search infrastructure (SearchBar, CogMenu, filters, groupBy, favorites). Changes: - Model now extends core Model class with setup() and load() methods - Controller uses useModelWithSampleData, useSetupAction, useSearchBarToggler - View definition returns modelParams and includes groupBy in searchMenuTypes - Template adds slots for SearchBar, CogMenu and toggler components - ArchParser accepts fields parameter for field definitions - Renderer extracts config from model.metaData.archInfo - Removed redundant search from PinList and DraggablePinList sidebars The search is now handled by Odoo's standard search bar in the control panel. --- .../pin-list/draggable_pin_list.xml | 25 -- .../src/components/pin-list/pin_list.esm.js | 36 +-- .../src/components/pin-list/pin_list.xml | 25 -- .../leaflet_map_arch_parser.esm.js | 23 +- .../leaflet_map_controller.esm.js | 124 ++------- .../leaflet_map_view/leaflet_map_model.esm.js | 259 +++++++++++------- .../leaflet_map_renderer.esm.js | 18 +- .../leaflet_map_view/leaflet_map_view.esm.js | 70 ++++- .../src/leaflet_map_view/leaflet_map_view.xml | 50 +++- 9 files changed, 313 insertions(+), 317 deletions(-) diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml index 94595d3cc..d9c1ee499 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml @@ -33,31 +33,6 @@ This is a generic component for any model that supports resequencing. - - -
diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js index 341d96d1e..1e7e71a62 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js @@ -39,22 +39,6 @@ export class PinList extends Component { this.state = useState({ collapsed: false, collapsedGroups: {}, - searchQuery: "", - }); - } - - /** - * Get filtered records based on search query - */ - get filteredRecords() { - if (!this.state.searchQuery) { - return this.props.records; - } - const query = this.state.searchQuery.toLowerCase(); - return this.props.records.filter((record) => { - const title = this.getRecordTitle(record).toLowerCase(); - const address = this.getRecordAddress(record).toLowerCase(); - return title.includes(query) || address.includes(query); }); } @@ -63,7 +47,7 @@ export class PinList extends Component { * Records without a groupBy value are placed in the unassigned group. */ get groupedRecords() { - const records = this.filteredRecords; + const records = this.props.records; const UNASSIGNED_GROUP_NAME = this.props.unassignedGroupName; // Orange color for unassigned group const UNASSIGNED_COLOR = "#fd7e14"; @@ -111,7 +95,7 @@ export class PinList extends Component { * Get total count of located records */ get locatedCount() { - return this.filteredRecords.filter( + return this.props.records.filter( (r) => r[this.props.fieldLatitude] && r[this.props.fieldLongitude] && @@ -126,7 +110,7 @@ export class PinList extends Component { * Get count of records without valid coordinates */ get unlocatedCount() { - return this.filteredRecords.length - this.locatedCount; + return this.props.records.length - this.locatedCount; } /** @@ -240,20 +224,6 @@ export class PinList extends Component { } } - /** - * Update search query - */ - onSearchInput(ev) { - this.state.searchQuery = ev.target.value; - } - - /** - * Clear search - */ - clearSearch() { - this.state.searchQuery = ""; - } - /** * Generate Google Maps navigation URL */ diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml index 98ee8aaaf..efd536d7a 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml @@ -25,31 +25,6 @@ - - -
diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js index 0d33502c7..af34465a0 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js @@ -14,11 +14,13 @@ import {visitXML} from "@web/core/utils/xml"; export class LeafletMapArchParser { /** * Parse the arch XML and extract view configuration. + * Follows the standard Odoo pattern with (arch, fields) signature. * * @param {Element} arch - The XML arch element + * @param {Object} fields - Field definitions from the model (optional) * @returns {Object} Parsed arch information */ - parse(arch) { + parse(arch, fields = {}) { const archInfo = { // Required fields that are always loaded fieldNames: ["id", "display_name"], @@ -26,6 +28,8 @@ export class LeafletMapArchParser { fieldNamesMarkerPopup: [], // Field metadata from arch fieldNodes: {}, + // Store fields reference for validation + fields, }; visitXML(arch, (node) => { @@ -34,7 +38,7 @@ export class LeafletMapArchParser { } if (node.tagName === "field") { - this._parseFieldNode(node, archInfo); + this._parseFieldNode(node, archInfo, fields); } }); @@ -105,7 +109,7 @@ export class LeafletMapArchParser { archInfo.fieldNames.push(archInfo.groupBy); } - // Drag-and-drop configuration (NEW) + // Drag-and-drop configuration archInfo.draggable = getAttr("draggable") === "1"; archInfo.groupField = getAttr("group_field"); archInfo.defaultOrder = getAttr("default_order"); @@ -131,22 +135,29 @@ export class LeafletMapArchParser { * * @param {Element} node - The field XML element * @param {Object} archInfo - The arch info object to populate + * @param {Object} fields - Field definitions from the model */ - _parseFieldNode(node, archInfo) { + _parseFieldNode(node, archInfo, fields) { const fieldName = node.getAttribute("name"); if (!fieldName) { return; } archInfo.fieldNames.push(fieldName); + + // Get field info from model definition if available + const fieldDef = fields[fieldName] || {}; + archInfo.fieldNodes[fieldName] = { name: fieldName, - string: node.getAttribute("string"), + string: node.getAttribute("string") || fieldDef.string || fieldName, invisible: node.getAttribute("invisible") === "1", + type: fieldDef.type, }; + archInfo.fieldNamesMarkerPopup.push({ fieldName, - string: node.getAttribute("string"), + string: node.getAttribute("string") || fieldDef.string || fieldName, }); } } diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js index 0922d7221..9cf883e6c 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js @@ -5,118 +5,48 @@ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). */ -import {Component, onWillStart, useState, useSubEnv} from "@odoo/owl"; +import {Component, useRef} from "@odoo/owl"; import {useService} from "@web/core/utils/hooks"; +import {useModelWithSampleData} from "@web/model/model"; +import {standardViewProps} from "@web/views/standard_view_props"; +import {useSetupAction} from "@web/search/action_hook"; import {Layout} from "@web/search/layout"; - -import {LeafletMapModel} from "./leaflet_map_model.esm"; -import {LeafletMapRenderer} from "./leaflet_map_renderer.esm"; +import {SearchBar} from "@web/search/search_bar/search_bar"; +import {useSearchBarToggler} from "@web/search/search_bar/search_bar_toggler"; +import {CogMenu} from "@web/search/cog_menu/cog_menu"; /** * LeafletMapController is the main controller for the leaflet map view. * It manages the model lifecycle and coordinates between the search panel - * and the renderer. + * and the renderer. Follows the standard Odoo view controller pattern. */ export class LeafletMapController extends Component { static template = "web_view_leaflet_map.LeafletMapController"; - static components = {Layout, LeafletMapRenderer}; + static components = {Layout, SearchBar, CogMenu}; static props = { - resModel: {type: String}, - arch: {type: Object, optional: true}, - archInfo: {type: Object}, - domain: {type: Array, optional: true}, - context: {type: Object, optional: true}, - fields: {type: Object, optional: true}, - limit: {type: Number, optional: true}, - display: {type: Object, optional: true}, - // Model and Renderer classes to use (allows overriding) - Model: {type: Function, optional: true}, - Renderer: {type: Function, optional: true}, - // Standard view controller props passed by Odoo framework (WithSearch) - // Using wildcard to accept all standard props without explicit declaration - "*": true, - }; - - static defaultProps = { - domain: [], - context: {}, - fields: {}, + ...standardViewProps, + Model: Function, + modelParams: Object, + Renderer: Function, + buttonTemplate: {type: String, optional: true}, }; setup() { - this.orm = useService("orm"); this.action = useService("action"); this.notification = useService("notification"); - // State for reactive updates - // dataVersion is incremented after each data reload to force re-render - this.state = useState({ - loading: true, - dataVersion: 0, - }); - - // Set up sub-environment for child components - useSubEnv({ - config: { - ...this.env.config, - }, - }); - - // Create model instance - const ModelClass = this.props.Model || LeafletMapModel; - this.model = new ModelClass( - this.env, - { - resModel: this.props.resModel, - archInfo: this.props.archInfo, - fields: this.props.fields, - context: this.props.context, - }, - {orm: this.orm} - ); + // Use the standard model hook that integrates with WithSearch + this.model = useModelWithSampleData(this.props.Model, this.props.modelParams); - // Initial data load - onWillStart(async () => { - await this.loadData(); + // Setup action hook for state management + useSetupAction({ + rootRef: useRef("root"), + getLocalState: () => ({metaData: this.model.metaData}), }); - } - - /** - * Get the Renderer component class to use. - */ - get RendererComponent() { - return this.props.Renderer || LeafletMapRenderer; - } - /** - * Load data from the model. - */ - async loadData() { - this.state.loading = true; - try { - await this.model.load({ - domain: this.props.domain, - limit: this.props.limit, - context: this.props.context, - }); - } finally { - this.state.loading = false; - } - } - - /** - * Reload data (called after resequencing or domain changes). - */ - async reloadData() { - this.state.loading = true; - try { - await this.model.reload(); - } finally { - this.state.loading = false; - // Increment dataVersion to force re-render of child components - this.state.dataVersion++; - } + // Setup search bar toggler for mobile responsiveness + this.searchBarToggler = useSearchBarToggler(); } /** @@ -134,9 +64,7 @@ export class LeafletMapController extends Component { previousRecordId ); - if (result.success) { - await this.reloadData(); - } else { + if (!result.success) { this.notification.add(result.error || "Failed to reorder item", { type: "danger", }); @@ -155,14 +83,8 @@ export class LeafletMapController extends Component { */ get rendererProps() { return { - resModel: this.props.resModel, - archInfo: this.props.archInfo, - fields: this.props.fields, - context: this.props.context, model: this.model, onResequence: this.onResequence.bind(this), - // DataVersion triggers re-render when data changes - dataVersion: this.state.dataVersion, }; } } diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js index 155d6ff1e..b9f075df4 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js @@ -1,124 +1,211 @@ /** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ +import {Model} from "@web/model/model"; import {KeepLast} from "@web/core/utils/concurrency"; /** * LeafletMapModel handles data loading and manipulation for the leaflet map view. + * Extends the core Model class to integrate with Odoo's search infrastructure. */ -export class LeafletMapModel { +export class LeafletMapModel extends Model { /** - * @param {Object} env - The OWL environment - * @param {Object} params - Model parameters - * @param {Object} services - Available services (orm, etc.) + * Setup the model with initial parameters. + * Called by the Model base class during construction. + * + * @param {Object} params - Model parameters from view props */ - constructor(env, params, services) { - this.env = env; - this.orm = services.orm; + setup(params) { this.keepLast = new KeepLast(); - // Model configuration from params - this.resModel = params.resModel; - this.archInfo = params.archInfo; - this.fields = params.fields || {}; - this.context = params.context || {}; + // Store metadata for state restoration + this.metaData = { + ...params, + }; // Data state this.data = { records: [], recordGroups: [], count: 0, - loading: false, + numberOfLocatedRecords: 0, + isGrouped: false, + groupByKey: false, }; - - // Current load parameters (for reload) - this._loadParams = null; } /** - * Load records from the server. + * Load records based on search parameters. + * Called by WithSearch when domain/groupBy/context changes. * - * @param {Object} params - Load parameters - * @param {Array} params.domain - Domain filter - * @param {Number} params.limit - Record limit - * @param {Number} params.offset - Record offset - * @param {Object} params.context - Additional context - * @returns {Promise} The loaded data + * @param {Object} searchParams - Search parameters from WithSearch + * @param {Array} searchParams.domain - Domain filter + * @param {Array} searchParams.groupBy - GroupBy fields + * @param {Object} searchParams.context - Additional context + * @returns {Promise} */ - async load(params) { - this._loadParams = params; - const {domain = [], limit, offset = 0, context = {}} = params; - - const fields = this._getFieldsToLoad(); - const recordLimit = limit || this.archInfo.limit || 500; - - this.data.loading = true; + async load(searchParams) { + const {domain = [], groupBy = [], context = {}} = searchParams; + + // Merge search params into metadata + const metaData = { + ...this.metaData, + domain, + groupBy: groupBy.length > 0 ? groupBy[0] : this.metaData.archInfo?.groupBy, + context: {...this.metaData.context, ...context}, + }; - try { - const records = await this.keepLast.add( - this.orm.searchRead(this.resModel, domain, fields, { - limit: recordLimit, - offset, - context: {...this.context, ...context}, - }) - ); + // Fetch data with the merged parameters + this.data = await this.keepLast.add(this._fetchData(metaData)); - this.data.records = records; - this.data.count = records.length; + // Update metadata after successful load + this.metaData = metaData; - // Group records if groupBy is configured - if (this.archInfo.groupBy) { - this.data.recordGroups = this._groupRecords(records); - } else { - this.data.recordGroups = [{name: null, id: null, records}]; - } + this.notify(); + } - return this.data; - } finally { - this.data.loading = false; - } + /** + * Check if the model has data to display. + * + * @returns {Boolean} + */ + hasData() { + return this.data.records.length > 0; } /** - * Reload data with current parameters. + * Fetch data from the server. * - * @returns {Promise} The reloaded data + * @param {Object} metaData - Metadata with fetch parameters + * @returns {Promise} The fetched data */ - async reload() { - if (this._loadParams) { - return this.load(this._loadParams); + async _fetchData(metaData) { + const { + resModel, + domain = [], + context = {}, + archInfo = {}, + limit, + offset = 0, + } = metaData; + + const fields = this._getFieldsToLoad(metaData); + const recordLimit = limit || archInfo.limit || 500; + + const records = await this.orm.searchRead(resModel, domain, fields, { + limit: recordLimit, + offset, + context, + }); + + // Determine groupBy - from search params or archInfo + const groupByField = metaData.groupBy || archInfo.groupBy; + + // Calculate located records + const locatedRecords = this._filterLocatedRecords(records, archInfo); + + // Group records if groupBy is configured + let recordGroups = []; + let isGrouped = false; + let groupByKey = false; + + if (groupByField) { + recordGroups = this._groupRecords(records, groupByField); + isGrouped = true; + groupByKey = groupByField; + } else { + recordGroups = [{name: null, id: null, records}]; } - return this.data; + + return { + records, + recordGroups, + count: records.length, + numberOfLocatedRecords: locatedRecords.length, + isGrouped, + groupByKey, + }; } /** * Get the list of fields to load based on archInfo. * + * @param {Object} metaData - Metadata containing archInfo * @returns {Array} Field names to load */ - _getFieldsToLoad() { - const fields = new Set(this.archInfo.fieldNames || []); + _getFieldsToLoad(metaData) { + const archInfo = metaData.archInfo || {}; + const fields = new Set(archInfo.fieldNames || []); // Ensure essential fields are always loaded fields.add("id"); fields.add("display_name"); // Add sequence field if available (for ordering) - if (this.fields.sequence) { + if (metaData.fields?.sequence) { fields.add("sequence"); } return Array.from(fields); } + /** + * Filter records that have valid coordinates. + * + * @param {Array} records - All records + * @param {Object} archInfo - Arch info with coordinate field names + * @returns {Array} Records with valid coordinates + */ + _filterLocatedRecords(records, archInfo) { + const {fieldLatitude, fieldLongitude} = archInfo; + if (!fieldLatitude || !fieldLongitude) { + return records; + } + + return records.filter((r) => { + const lat = r[fieldLatitude]; + const lng = r[fieldLongitude]; + return this._validateCoordinates(lat, lng); + }); + } + + /** + * Validate that coordinates are within valid ranges. + * + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Boolean} + */ + _validateCoordinates(lat, lng) { + try { + const parsedLat = parseFloat(lat); + const parsedLng = parseFloat(lng); + return ( + !isNaN(parsedLat) && + !isNaN(parsedLng) && + parsedLat >= -90 && + parsedLat <= 90 && + parsedLng >= -180 && + parsedLng <= 180 + ); + } catch { + return false; + } + } + /** * Group records by the groupBy field. * * @param {Array} records - Records to group + * @param {String} groupBy - Field name to group by * @returns {Array} Grouped records */ - _groupRecords(records) { + _groupRecords(records, groupBy) { const groups = {}; - const groupBy = this.archInfo.groupBy; for (const record of records) { const groupValue = record[groupBy]; @@ -155,7 +242,9 @@ export class LeafletMapModel { * @returns {Promise} Result of the operation */ async resequence(recordId, targetGroupId, previousRecordId) { - if (!this.archInfo.defaultOrder) { + const archInfo = this.metaData.archInfo || {}; + + if (!archInfo.defaultOrder) { return {success: false, error: "Resequencing not configured"}; } @@ -165,7 +254,7 @@ export class LeafletMapModel { } const updates = {}; - const sequenceField = this.archInfo.defaultOrder; + const sequenceField = archInfo.defaultOrder; // Calculate new sequence value if (previousRecordId) { @@ -178,8 +267,8 @@ export class LeafletMapModel { } else { // Insert at beginning - find minimum sequence in target group const targetRecords = this.data.records.filter((r) => { - if (!this.archInfo.groupField) return true; - const groupValue = r[this.archInfo.groupField]; + if (!archInfo.groupField) return true; + const groupValue = r[archInfo.groupField]; const groupId = Array.isArray(groupValue) ? groupValue[0] : groupValue; return groupId === targetGroupId; }); @@ -195,21 +284,21 @@ export class LeafletMapModel { } // Update group field if moving between groups - if (this.archInfo.groupField) { - const currentGroupValue = record[this.archInfo.groupField]; + if (archInfo.groupField) { + const currentGroupValue = record[archInfo.groupField]; const currentGroupId = Array.isArray(currentGroupValue) ? currentGroupValue[0] : currentGroupValue; if (currentGroupId !== targetGroupId) { // Use false to clear Many2one field when moving to unassigned group - updates[this.archInfo.groupField] = + updates[archInfo.groupField] = targetGroupId === null ? false : targetGroupId; } } try { - await this.orm.write(this.resModel, [recordId], updates); + await this.orm.write(this.metaData.resModel, [recordId], updates); return {success: true}; } catch (error) { return {success: false, error: error.message}; @@ -222,7 +311,8 @@ export class LeafletMapModel { * @returns {Array} Records with valid lat/lng */ getLocatedRecords() { - const {fieldLatitude, fieldLongitude} = this.archInfo; + const archInfo = this.metaData.archInfo || {}; + const {fieldLatitude, fieldLongitude} = archInfo; return this.data.records.filter((r) => { const lat = r[fieldLatitude]; const lng = r[fieldLongitude]; @@ -236,35 +326,12 @@ export class LeafletMapModel { * @returns {Array} Records without valid lat/lng */ getUnlocatedRecords() { - const {fieldLatitude, fieldLongitude} = this.archInfo; + const archInfo = this.metaData.archInfo || {}; + const {fieldLatitude, fieldLongitude} = archInfo; return this.data.records.filter((r) => { const lat = r[fieldLatitude]; const lng = r[fieldLongitude]; return !this._validateCoordinates(lat, lng); }); } - - /** - * Validate that coordinates are within valid ranges. - * - * @param {Number} lat - Latitude - * @param {Number} lng - Longitude - * @returns {Boolean} - */ - _validateCoordinates(lat, lng) { - try { - const parsedLat = parseFloat(lat); - const parsedLng = parseFloat(lng); - return ( - !isNaN(parsedLat) && - !isNaN(parsedLng) && - parsedLat >= -90 && - parsedLat <= 90 && - parsedLng >= -180 && - parsedLng <= 180 - ); - } catch { - return false; - } - } } diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js index c66b695a0..46b4940ee 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js @@ -43,14 +43,8 @@ export class LeafletMapRenderer extends Component { static components = {PinList}; static props = { - resModel: {type: String}, - archInfo: {type: Object}, - fields: {type: Object, optional: true}, - context: {type: Object, optional: true}, model: {type: Object}, onResequence: {type: Function, optional: true}, - // DataVersion changes when data is reloaded, triggering re-render - dataVersion: {type: Number, optional: true}, // Accept additional props from extending modules for extensibility "*": true, }; @@ -67,9 +61,11 @@ export class LeafletMapRenderer extends Component { this.leafletTileUrl = session["leaflet.tile_url"]; this.leafletCopyright = session["leaflet.copyright"]; - // Extract configuration from archInfo - const archInfo = this.props.archInfo; - this.resModel = this.props.resModel; + // Extract configuration from model.metaData.archInfo + const metaData = this.props.model.metaData || {}; + const archInfo = metaData.archInfo || {}; + + this.resModel = metaData.resModel; this.fieldLatitude = archInfo.fieldLatitude; this.fieldLongitude = archInfo.fieldLongitude; this.fieldTitle = archInfo.fieldTitle; @@ -136,14 +132,14 @@ export class LeafletMapRenderer extends Component { * Get records from the model. */ get records() { - return this.props.model.data.records || []; + return this.props.model.data?.records || []; } /** * Get loading state from the model. */ get loading() { - return this.props.model.data.loading; + return this.props.model.data?.loading || false; } /** diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js index 6cfe486c4..a97bb2d62 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js @@ -1,4 +1,9 @@ /** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ import {registry} from "@web/core/registry"; @@ -12,6 +17,7 @@ import {LeafletMapRenderer} from "./leaflet_map_renderer.esm"; * - Separate ArchParser, Model, Controller, Renderer classes * - Extensible via spread syntax for custom views * - Uses js_class attribute to select custom view implementations + * - Integrates with Odoo's search infrastructure (SearchBar, filters, groupBy, favorites) */ export const leafletMapView = { type: "leaflet_map", @@ -25,27 +31,64 @@ export const leafletMapView = { Model: LeafletMapModel, ArchParser: LeafletMapArchParser, - // Search menu configuration - searchMenuTypes: ["filter", "favorite"], + // Search menu configuration - enables filter, groupBy, and favorites + searchMenuTypes: ["filter", "groupBy", "favorite"], + + // Button template for control panel (empty by default) + buttonTemplate: "web_view_leaflet_map.LeafletMapView.Buttons", /** * Transform generic props into view-specific props. + * Follows the pattern from graphView in Odoo core. * * @param {Object} genericProps - Props from the view registry * @param {Object} view - The view definition * @returns {Object} Transformed props for the controller */ props(genericProps, view) { - const {arch, fields} = genericProps; + let modelParams = null; + + // Check if we have state from a previous session (for state restoration) + if (genericProps.state?.metaData) { + modelParams = genericProps.state.metaData; + } else { + const {arch, resModel, fields} = genericProps; + + // Parse the arch using the ArchParser + const parser = new (view.ArchParser || LeafletMapArchParser)(); + const archInfo = parser.parse(arch, fields); - // Parse the arch using the ArchParser - const archParser = new (view.ArchParser || LeafletMapArchParser)(); - const archInfo = archParser.parse(arch); + // Check if a custom js_class is specified + if (archInfo.jsClass) { + const customView = registry + .category("views") + .get(archInfo.jsClass, null); + if (customView) { + // Re-parse with custom parser if available + const customParser = new (customView.ArchParser || + LeafletMapArchParser)(); + const customArchInfo = customParser.parse(arch, fields); + Object.assign(archInfo, customArchInfo); + } + } + + modelParams = { + archInfo, + fields, + fieldNames: archInfo.fieldNames || [], + limit: archInfo.limit || 500, + offset: 0, + resModel, + context: genericProps.context || {}, + }; + } - // Check if a custom js_class is specified + // Get the view definition (may be overridden by js_class) let viewDefinition = view; - if (archInfo.jsClass) { - const customView = registry.category("views").get(archInfo.jsClass, null); + if (modelParams.archInfo?.jsClass) { + const customView = registry + .category("views") + .get(modelParams.archInfo.jsClass, null); if (customView) { viewDefinition = customView; } @@ -53,16 +96,17 @@ export const leafletMapView = { return { ...genericProps, - archInfo, - fields, - // Allow overriding Model and Renderer via js_class + modelParams, Model: viewDefinition.Model || LeafletMapModel, Renderer: viewDefinition.Renderer || LeafletMapRenderer, + buttonTemplate: + viewDefinition.buttonTemplate || + "web_view_leaflet_map.LeafletMapView.Buttons", }; }, }; -// Register the view without force:true +// Register the view registry.category("views").add("leaflet_map", leafletMapView); // Export components for extension diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml index 81e7b42bb..ac930781c 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml @@ -6,17 +6,53 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). --> + + + - - -
- - - +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js deleted file mode 100644 index 28035db33..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js +++ /dev/null @@ -1,24 +0,0 @@ -import {Component, useRef} from "@odoo/owl"; -import {Layout} from "@web/search/layout"; -import {MapRenderer} from "./leaflet_map_renderer.esm"; -import {executeButtonCallback} from "@web/views/view_button/view_button_hook"; - -/** - * Controller class for the Map view, setting up the environment configuration. - */ -export class MapController extends Component { - static template = "web_view_leaflet_map.MapView"; - static components = {Layout, MapRenderer}; - - setup() { - this.rootRef = useRef("root"); - } - - async onClickCreate() { - return executeButtonCallback(this.rootRef.el, () => this.createRecord()); - } - - async createRecord() { - await this.props.createRecord(); - } -} diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml deleted file mode 100644 index ad032dd7b..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - -
- - - - - - - -
-
- -
diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js deleted file mode 100644 index 35127a885..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js +++ /dev/null @@ -1,275 +0,0 @@ -import {session} from "@web/session"; -import {useService} from "@web/core/utils/hooks"; - -/* global L, console, document */ - -const {Component, onWillStart, onMounted, onPatched, useRef} = owl; - -export class MapRenderer extends Component { - static template = "web_view_leaflet_map.MapRenderer"; - static components = {}; - - /** - * Initializes the MapRenderer component, setting up services, references, and configuration. - */ - // eslint-disable-next-line complexity - setup() { - this.orm = useService("orm"); - this.action = useService("action"); - this.mapRef = useRef("mapContainer"); - this.leafletTileUrl = session["leaflet.tile_url"]; - this.leafletCopyright = session["leaflet.copyright"]; - - const archAttrs = this.props.archInfo.arch.attributes; - - this.resModel = this.props.resModel; - this.defaultZoom = parseInt(archAttrs.default_zoom, 10) || 7; - this.maxZoom = parseInt(archAttrs.max_zoom, 10) || 19; - this.zoomSnap = parseInt(archAttrs.zoom_snap, 10) || 1; - - this.fieldLatitude = archAttrs.field_latitude?.value; - this.fieldLongitude = archAttrs.field_longitude?.value; - this.fieldTitle = archAttrs.field_title?.value; - this.fieldAddress = archAttrs.field_address?.value; - this.fieldMarkerIconImage = archAttrs.field_marker_icon_image?.value; - - this.markerIconSizeX = parseInt(archAttrs.marker_icon_size_x?.value, 10) || 64; - this.markerIconSizeY = parseInt(archAttrs.marker_icon_size_y?.value, 10) || 64; - this.markerPopupAnchorX = - parseInt(archAttrs.marker_popup_anchor_x?.value, 10) || 0; - this.markerPopupAnchorY = - parseInt(archAttrs.marker_popup_anchor_y?.value, 10) || -32; - - this.leafletMap = null; - this.mainLayer = null; - - onWillStart(async () => { - await this.initDefaultPosition(); - await this.loadRecords(); - }); - - onMounted(() => { - this.initMap(); - this.renderMarkers(); - }); - - onPatched(() => { - if (this.leafletMap) { - this.renderMarkers(); - } - }); - } - - /** - * Loads records from the server based on the provided domain and fields. - * @returns {Promise} - */ - async loadRecords() { - const fields = this.getFields(); - - try { - // Cargar registros usando searchRead - const records = await this.orm.searchRead( - this.resModel, - this.props.domain || [], - fields, - { - limit: this.props.limit || 80, - context: this.props.context || {}, - } - ); - this.records = records; - } catch (error) { - console.error("Error loading records:", error); - this.records = []; - } - } - - /** - * Gathers the required fields for the map view. - * @returns {any[]} - */ - getFields() { - const fields = new Set(); - - // Required fields - fields.add("id"); - fields.add("display_name"); - fields.add("date_localization"); - - // Optional fields based on arch attributes - if (this.fieldLatitude) fields.add(this.fieldLatitude); - if (this.fieldLongitude) fields.add(this.fieldLongitude); - if (this.fieldTitle) fields.add(this.fieldTitle); - if (this.fieldAddress) fields.add(this.fieldAddress); - if (this.fieldMarkerIconImage) fields.add(this.fieldMarkerIconImage); - - return Array.from(fields); - } - - /** - * Initializes the default position of the map by calling the server method. - * @returns {Promise} - */ - async initDefaultPosition() { - const result = await this.orm.call( - "res.users", - "get_default_leaflet_position", - [this.props.resModel] - ); - this.defaultLatLng = L.latLng(result.lat, result.lng); - } - - /** - * Initializes the Leaflet map in the container. - */ - initMap() { - const mapDiv = this.mapRef.el; - if (!mapDiv) { - console.error("Map container not found"); - return; - } - - this.leafletMap = L.map(mapDiv, { - zoomSnap: this.zoomSnap, - }).setView(this.defaultLatLng, this.defaultZoom); - - L.tileLayer(this.leafletTileUrl, { - maxZoom: this.maxZoom, - attribution: this.leafletCopyright, - }).addTo(this.leafletMap); - } - - /** - * Renders the markers on the map based on the loaded records. - */ - renderMarkers() { - if (!this.leafletMap) { - console.warn("Map not initialized yet"); - return; - } - - if (this.mainLayer) { - this.leafletMap.removeLayer(this.mainLayer); - } - - this.mainLayer = L.markerClusterGroup(); - for (const record of this.records) { - const marker = this.prepareMarker(record); - if (marker) { - this.mainLayer.addLayer(marker); - } - } - const bounds = this.mainLayer.getBounds(); - if (bounds.isValid()) { - // Adapt the map's position based on the map's points - this.leafletMap.fitBounds(bounds.pad(0.1)); - } - - this.leafletMap.addLayer(this.mainLayer); - } - - /** - * Prepares a Leaflet marker for the given record. - * @param {Object} record - The record object containing marker data - * @returns {*} - */ - prepareMarker(record) { - const lat = record[this.fieldLatitude]; - const lng = record[this.fieldLongitude]; - let marker = null; - if (!lat || !lng) { - console.debug(`Record ${record.id} has no coordinates`); - return; - } - - const latlng = L.latLng(lat, lng); - if (latlng.lat !== 0 && latlng.lng !== 0) { - const markerOptions = this.prepareMarkerOptions(record); - - marker = L.marker(latlng, markerOptions); - const popup = L.popup().setContent(this.preparePopUpData(record)); - - marker.bindPopup(popup).on("popupopen", () => { - const selector = document.querySelector(".o_map_selector"); - if (selector) { - selector.addEventListener("click", (ev) => { - ev.preventDefault(); - this.onClickLeafletPopup(record); - }); - } - }); - - return marker; - } - } - - /** - * Prepares the Leaflet icon for the marker using the image field. - * @param {Object} record - The record object containing marker data - * @returns {*} - */ - prepareMarkerIcon(record) { - const lastUpdate = record.date_localization || new Date().toISOString(); - const unique = lastUpdate.replace(/[^0-9]/g, ""); - const iconUrl = `/web/image?model=${this.resModel}&id=${record.id}&field=${this.fieldMarkerIconImage}&unique=${unique}`; - - return L.icon({ - iconUrl: iconUrl, - className: "leaflet_marker_icon", - iconSize: [this.markerIconSizeX, this.markerIconSizeY], - popupAnchor: [this.markerPopupAnchorX, this.markerPopupAnchorY], - }); - } - - /** - * Prepares the options for the leaflet marker. - * @param {Object} record - The record object containing marker data - * @returns {{riseOnHover: Boolean, alt: (*|string), title: (*|string)}} - */ - prepareMarkerOptions(record) { - const title = record[this.fieldTitle] || ""; - const result = { - title: title, - alt: title, - riseOnHover: true, - }; - - if (this.fieldMarkerIconImage) { - result.icon = this.prepareMarkerIcon(record); - } - - return result; - } - - /** - * Prepares the HTML content for the leaflet popup. - * @param {Object} record - The record object containing marker data - * @returns {String} - */ - preparePopUpData(record) { - const title = record[this.fieldTitle] || ""; - const address = record[this.fieldAddress] || ""; - - return ` -
- ${title}
- ${address ? ` - ${address}` : ""} -
- `; - } - - /** - * Handles click on the leaflet popup to open the record form view. - * @param {Object} record - The record object containing marker data - */ - onClickLeafletPopup(record) { - this.action.doAction({ - type: "ir.actions.act_window", - res_model: this.resModel, - res_id: record.id, - views: [[false, "form"]], - target: "current", - }); - } -} diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml deleted file mode 100644 index d6ed3ecf9..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - -
-
-
- - - diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js deleted file mode 100644 index 801e69b11..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js +++ /dev/null @@ -1,53 +0,0 @@ -import {MapController} from "./leaflet_map_controller.esm"; -import {MapRenderer} from "./leaflet_map_renderer.esm"; -import {registry} from "@web/core/registry"; - -/* global DOMParser */ - -/** - * Helper function that normalize the architecture input to ensure it is an HTMLElement. - * @param arch - * @returns {HTMLElement|*} - */ -function normalizeArch(arch) { - if (arch && typeof arch !== "string") return arch; - const xml = String(arch || ""); - const doc = new DOMParser().parseFromString(xml, "text/xml"); - return doc.documentElement; -} - -/** - * Definition of the map view for Odoo, including its properties and components. - * @type {{ - * type: string, - * display_name: string, - * icon: string, - * multiRecord: boolean, - * Controller: MapController, - * Renderer: MapRenderer, - * searchMenuTypes: string[], - * props: (function(*, *): *&{archInfo: {arch: *}, Renderer: MapRenderer}) - * }} - */ -export const leafletMapView = { - type: "leaflet_map", - display_name: "Map", - icon: "fa fa-map-o", - multiRecord: true, - Controller: MapController, - Renderer: MapRenderer, - searchMenuTypes: ["filter", "favorite"], - - props: (genericProps) => { - const archEl = normalizeArch(genericProps.arch); - return { - ...genericProps, - Renderer: MapRenderer, - archInfo: { - arch: archEl, - }, - }; - }, -}; - -registry.category("views").add("leaflet_map", leafletMapView); From 9f2bdd7161bdd7e9ffa107e5a0dd08863556efcd Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Tue, 3 Feb 2026 07:22:20 -0300 Subject: [PATCH 14/16] [FIX] web_view_leaflet_map: fix navigate button text color in map popup Fix navigate button in map marker popup to always show white text on blue background. The Leaflet popup was overriding Bootstrap button styles causing blue text on blue background. Reverts changes to sidebar navigate buttons which were already working correctly. --- .../src/views/leaflet_map/leaflet_map_renderer.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css index 0b1230d86..a8d36ffb4 100644 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css +++ b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css @@ -69,6 +69,18 @@ padding-top: 8px; } +.o_map_popup .o_popup_actions .btn-primary { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.o_map_popup .o_popup_actions .btn-primary:hover { + color: #fff; + background-color: #0056b3; + border-color: #004085; +} + /* Marker icon styling */ .leaflet_marker_icon { background-color: white; From 78d581d36fc803e332b5db21508b2dc5ee5cca50 Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Fri, 6 Feb 2026 10:48:05 -0300 Subject: [PATCH 15/16] [FIX] web_view_leaflet_map: reload data after resequence and fix toggleGroup - Reload data from server after successful drag-and-drop resequence to keep local state in sync - Extract actual error message from RPCError.data.message instead of showing generic "Odoo Server Error" - Fix toggleGroup for default-collapsed groups: use isGroupCollapsed() instead of direct state lookup which returned undefined for groups not yet toggled --- .../src/components/pin-list/pin_list.esm.js | 2 +- .../leaflet_map_view/leaflet_map_model.esm.js | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js index 1e7e71a62..f493aecec 100644 --- a/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js @@ -189,7 +189,7 @@ export class PinList extends Component { * Toggle group collapse state */ toggleGroup(groupName) { - this.state.collapsedGroups[groupName] = !this.state.collapsedGroups[groupName]; + this.state.collapsedGroups[groupName] = !this.isGroupCollapsed(groupName); } /** diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js index b9f075df4..1d3e893cb 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js @@ -299,12 +299,27 @@ export class LeafletMapModel extends Model { try { await this.orm.write(this.metaData.resModel, [recordId], updates); + // Reload data to reflect server-side changes + this.data = await this._fetchData(this.metaData); + this.notify(); return {success: true}; } catch (error) { - return {success: false, error: error.message}; + return {success: false, error: this._extractErrorMessage(error)}; } } + /** + * Extract a user-friendly error message from an RPC error. + * Odoo RPCError stores the actual message in error.data.message, + * while error.message is the generic "Odoo Server Error". + * + * @param {Error} error - The caught error + * @returns {String} User-friendly error message + */ + _extractErrorMessage(error) { + return error.data?.message || error.message || String(error); + } + /** * Get records with valid coordinates. * From 7b65c472630be08af1c48693943436102d2672fa Mon Sep 17 00:00:00 2001 From: Luis Felipe Mileo Date: Fri, 6 Feb 2026 10:50:16 -0300 Subject: [PATCH 16/16] [IMP] web_leaflet_routing, web_view_leaflet_map: sort routes by stop type and fix coordinate validation - Sort route waypoints by stop_type_order then sequence so origin is always first and destination always last - Accept 0.0 as valid coordinate (equator/prime meridian) instead of rejecting falsy values --- .../src/components/routing_renderer.esm.js | 63 ++++++++++++++++++- .../leaflet_map_renderer.esm.js | 8 ++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/web_leaflet_routing/static/src/components/routing_renderer.esm.js b/web_leaflet_routing/static/src/components/routing_renderer.esm.js index fc36fe36c..c99ef0af2 100644 --- a/web_leaflet_routing/static/src/components/routing_renderer.esm.js +++ b/web_leaflet_routing/static/src/components/routing_renderer.esm.js @@ -38,6 +38,8 @@ export class RoutingRenderer { * @param {Object} config - Configuration options * @param {String} config.groupBy - Field name to group records by * @param {String} config.sequenceField - Field name for sequencing (default: "sequence") + * @param {String} config.stopTypeField - Field name for stop type (default: null) + * @param {String} config.stopTypeOrderField - Field name for stop type order (default: null) * @param {String} config.latitudeField - Field name for latitude (default: "latitude") * @param {String} config.longitudeField - Field name for longitude (default: "longitude") * @param {Boolean} config.useRealRouting - Use OSRM routing (default: true) @@ -50,6 +52,8 @@ export class RoutingRenderer { const { groupBy = null, sequenceField = "sequence", + stopTypeField = null, + stopTypeOrderField = "stop_type_order", latitudeField = "latitude", longitudeField = "longitude", useRealRouting = true, @@ -67,6 +71,8 @@ export class RoutingRenderer { latitudeField, longitudeField, sequenceField, + stopTypeField, + stopTypeOrderField, unassignedGroupName, validateCoordinates, }); @@ -80,9 +86,24 @@ export class RoutingRenderer { continue; } - // Sort records by sequence - const sorted = [...group.records].sort( - (a, b) => (a.sequence || 0) - (b.sequence || 0) + // Sort records by stop_type_order (0=origin, 1=delivery, 2=destination) then by sequence + const sorted = [...group.records].sort((a, b) => { + // Primary sort by stopTypeOrder (already computed as number in _groupRecords) + if (a.stopTypeOrder !== b.stopTypeOrder) { + return a.stopTypeOrder - b.stopTypeOrder; + } + // Secondary sort by sequence + return (a.sequence || 0) - (b.sequence || 0); + }); + + // Debug logging for route order + console.debug( + `[RoutingRenderer] Group ${groupKey} route order (${sorted.length} stops):`, + sorted.map((r) => ({ + stopType: r.stopType, + stopTypeOrder: r.stopTypeOrder, + sequence: r.sequence, + })) ); const waypoints = sorted.map((r) => [r.lat, r.lng]); @@ -151,6 +172,8 @@ export class RoutingRenderer { latitudeField, longitudeField, sequenceField, + stopTypeField, + stopTypeOrderField, unassignedGroupName, validateCoordinates, } = options; @@ -182,10 +205,44 @@ export class RoutingRenderer { }; } + // Extract stop type order - handle 0 as valid value + // First try the explicit stopTypeOrderField + let stopTypeOrderValue = 1; // Default to delivery + if ( + stopTypeOrderField && + record[stopTypeOrderField] !== undefined && + record[stopTypeOrderField] !== null + ) { + stopTypeOrderValue = Number(record[stopTypeOrderField]); + if (isNaN(stopTypeOrderValue)) { + stopTypeOrderValue = 1; + } + } else if (stopTypeField) { + // Fallback: derive from stop_type field + const stopType = record[stopTypeField]; + if (stopType === "origin") { + stopTypeOrderValue = 0; + } else if (stopType === "destination") { + stopTypeOrderValue = 2; + } + } + + const stopTypeValue = stopTypeField ? record[stopTypeField] : null; + + // Debug: log raw field values + console.debug(`[RoutingRenderer] Record ${record.id}:`, { + stopType: stopTypeValue, + rawStopTypeOrder: record[stopTypeOrderField], + computedStopTypeOrder: stopTypeOrderValue, + sequence: record[sequenceField], + }); + groups[groupKey].records.push({ lat, lng, sequence: record[sequenceField] || 0, + stopType: stopTypeValue, + stopTypeOrder: stopTypeOrderValue, record, }); } diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js index 4ae730fa1..aee53ca28 100644 --- a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js @@ -144,12 +144,17 @@ export class LeafletMapRenderer extends Component { /** * Validates that coordinates are within valid ranges. + * Accepts 0.0 as valid (equator/prime meridian), rejects null/undefined. * @param {Number} lat - Latitude * @param {Number} lng - Longitude * @returns {Boolean} */ validateCoordinates(lat, lng) { try { + // Reject null, undefined, empty string + if (lat === null || lat === undefined || lat === "") return false; + if (lng === null || lng === undefined || lng === "") return false; + const parsedLat = parseFloat(lat); const parsedLng = parseFloat(lng); return ( @@ -342,7 +347,8 @@ export class LeafletMapRenderer extends Component { const lat = record[this.fieldLatitude]; const lng = record[this.fieldLongitude]; - if (!lat || !lng || !this.validateCoordinates(lat, lng)) { + // Use validateCoordinates for proper check (0.0 is valid) + if (!this.validateCoordinates(lat, lng)) { return null; }