From 111afdd88585b2fd6a03607f29b37e33198301e6 Mon Sep 17 00:00:00 2001
From: Ruslan Bakiev <572431+veikab@users.noreply.github.com>
Date: Fri, 13 Feb 2026 18:01:40 +0700
Subject: [PATCH] Replace zone preview with JS Mapbox widget and simplify zone
UI
---
.../dsrpt_repair_config/__manifest__.py | 7 +
.../data/repair_fsm_zone_data_atlanta.xml | 1 -
.../dsrpt_repair_config/models/fsm_zone.py | 76 +-----
.../src/js/mapbox_polygon_preview_field.js | 228 ++++++++++++++++++
.../scss/mapbox_polygon_preview_field.scss | 20 ++
.../src/xml/mapbox_polygon_preview_field.xml | 6 +
.../views/repair_fsm_zone_view_form.xml | 12 +-
.../views/repair_fsm_zone_view_kanban.xml | 3 +-
.../views/repair_fsm_zone_view_list.xml | 1 -
9 files changed, 272 insertions(+), 82 deletions(-)
create mode 100644 odoo/addons/dsrpt_repair_config/static/src/js/mapbox_polygon_preview_field.js
create mode 100644 odoo/addons/dsrpt_repair_config/static/src/scss/mapbox_polygon_preview_field.scss
create mode 100644 odoo/addons/dsrpt_repair_config/static/src/xml/mapbox_polygon_preview_field.xml
diff --git a/odoo/addons/dsrpt_repair_config/__manifest__.py b/odoo/addons/dsrpt_repair_config/__manifest__.py
index c0a8f64..9502f24 100644
--- a/odoo/addons/dsrpt_repair_config/__manifest__.py
+++ b/odoo/addons/dsrpt_repair_config/__manifest__.py
@@ -17,6 +17,13 @@
"views/repair_fsm_zone_action_main.xml",
"views/menu.xml",
],
+ "assets": {
+ "web.assets_backend": [
+ "dsrpt_repair_config/static/src/xml/mapbox_polygon_preview_field.xml",
+ "dsrpt_repair_config/static/src/js/mapbox_polygon_preview_field.js",
+ "dsrpt_repair_config/static/src/scss/mapbox_polygon_preview_field.scss",
+ ],
+ },
"installable": True,
"application": True,
}
diff --git a/odoo/addons/dsrpt_repair_config/data/repair_fsm_zone_data_atlanta.xml b/odoo/addons/dsrpt_repair_config/data/repair_fsm_zone_data_atlanta.xml
index 10fe55f..1e0bd4b 100644
--- a/odoo/addons/dsrpt_repair_config/data/repair_fsm_zone_data_atlanta.xml
+++ b/odoo/addons/dsrpt_repair_config/data/repair_fsm_zone_data_atlanta.xml
@@ -5,6 +5,5 @@
- active
diff --git a/odoo/addons/dsrpt_repair_config/models/fsm_zone.py b/odoo/addons/dsrpt_repair_config/models/fsm_zone.py
index 6ed4a01..9871f77 100644
--- a/odoo/addons/dsrpt_repair_config/models/fsm_zone.py
+++ b/odoo/addons/dsrpt_repair_config/models/fsm_zone.py
@@ -1,5 +1,4 @@
import json
-from urllib.parse import quote
from odoo import api, fields, models
from odoo.exceptions import ValidationError
@@ -14,40 +13,25 @@ class RepairFsmZone(models.Model):
name = fields.Char(required=True, tracking=True)
polygon_geojson = fields.Text(
string="Polygon (GeoJSON)",
+ required=True,
tracking=True,
help="GeoJSON Polygon geometry. Coordinates order: [longitude, latitude].",
)
- polygon_map_preview = fields.Html(
- string="Polygon Map Preview",
- compute="_compute_polygon_map_preview",
- sanitize=False,
- )
state = fields.Selection(
selection=[
- ("draft", "Draft"),
("active", "Active"),
("archived", "Archived"),
],
- default="draft",
+ default="active",
tracking=True,
- group_expand="_group_expand_states",
)
+ mapbox_token = fields.Char(compute="_compute_mapbox_token")
- def _group_expand_states(self, states, domain, order):
- return [key for key, _label in self._fields["state"].selection]
-
- def action_set_active(self):
+ @api.depends_context("uid")
+ def _compute_mapbox_token(self):
+ token = self.env["ir.config_parameter"].sudo().get_param("dsrpt_repair_config.mapbox_token") or ""
for rec in self:
- if not rec.polygon_geojson:
- raise ValidationError("Polygon is required before activating the FSM Zone.")
- rec._extract_polygon_points()
- self.write({"state": "active"})
-
- def action_archive(self):
- self.write({"state": "archived"})
-
- def action_reset_draft(self):
- self.write({"state": "draft"})
+ rec.mapbox_token = token
@staticmethod
def _point_in_polygon(longitude, latitude, points):
@@ -108,51 +92,7 @@ class RepairFsmZone(models.Model):
points = self._extract_polygon_points()
return self._point_in_polygon(float(longitude), float(latitude), points)
- def action_open_polygon_in_map(self):
- self.ensure_one()
- encoded = quote(self.polygon_geojson or '{"type":"Polygon","coordinates":[[]]}')
- return {
- "type": "ir.actions.act_url",
- "url": f"https://geojson.io/#data=data:application/json,{encoded}",
- "target": "new",
- }
-
- def action_open_point_picker_map(self):
- return {
- "type": "ir.actions.act_url",
- "url": "https://www.openstreetmap.org",
- "target": "new",
- }
-
- @api.depends("polygon_geojson")
- def _compute_polygon_map_preview(self):
- token = self.env["ir.config_parameter"].sudo().get_param("dsrpt_repair_config.mapbox_token")
- for rec in self:
- if not rec.polygon_geojson:
- rec.polygon_map_preview = "
No polygon yet.
"
- continue
- if not token:
- rec.polygon_map_preview = "Set Mapbox token in system parameter dsrpt_repair_config.mapbox_token.
"
- continue
- try:
- data = json.loads(rec.polygon_geojson)
- except Exception:
- rec.polygon_map_preview = "Invalid polygon JSON.
"
- continue
- feature = {"type": "Feature", "geometry": data, "properties": {"name": rec.name or "Zone"}}
- overlay = quote(json.dumps(feature, separators=(",", ":")))
- token_encoded = quote(token)
- static_url = (
- "https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/"
- f"geojson({overlay})/auto/1100x420?padding=40&access_token={token_encoded}"
- )
- rec.polygon_map_preview = (
- f'
'
- )
-
@api.constrains("polygon_geojson")
def _check_polygon_geojson(self):
for rec in self:
- if rec.polygon_geojson:
- rec._extract_polygon_points()
+ rec._extract_polygon_points()
diff --git a/odoo/addons/dsrpt_repair_config/static/src/js/mapbox_polygon_preview_field.js b/odoo/addons/dsrpt_repair_config/static/src/js/mapbox_polygon_preview_field.js
new file mode 100644
index 0000000..45a66c0
--- /dev/null
+++ b/odoo/addons/dsrpt_repair_config/static/src/js/mapbox_polygon_preview_field.js
@@ -0,0 +1,228 @@
+/** @odoo-module **/
+
+import { Component, onMounted, onWillUnmount, onWillUpdateProps, useRef } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { standardFieldProps } from "@web/views/fields/standard_field_props";
+
+const MAPBOX_JS = "https://api.mapbox.com/mapbox-gl-js/v3.5.1/mapbox-gl.js";
+const MAPBOX_CSS = "https://api.mapbox.com/mapbox-gl-js/v3.5.1/mapbox-gl.css";
+
+let mapboxAssetsPromise;
+
+function loadMapboxAssets() {
+ if (mapboxAssetsPromise) {
+ return mapboxAssetsPromise;
+ }
+ mapboxAssetsPromise = new Promise((resolve, reject) => {
+ const finish = () => {
+ if (window.mapboxgl) {
+ resolve();
+ } else {
+ reject(new Error("Mapbox GL failed to load."));
+ }
+ };
+ if (!document.querySelector(`link[href="${MAPBOX_CSS}"]`)) {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = MAPBOX_CSS;
+ document.head.appendChild(link);
+ }
+ if (window.mapboxgl) {
+ finish();
+ return;
+ }
+ const existing = document.querySelector(`script[src="${MAPBOX_JS}"]`);
+ if (existing) {
+ existing.addEventListener("load", finish, { once: true });
+ existing.addEventListener("error", () => reject(new Error("Cannot load Mapbox JS.")), { once: true });
+ return;
+ }
+ const script = document.createElement("script");
+ script.src = MAPBOX_JS;
+ script.async = true;
+ script.onload = finish;
+ script.onerror = () => reject(new Error("Cannot load Mapbox JS."));
+ document.head.appendChild(script);
+ });
+ return mapboxAssetsPromise;
+}
+
+function parsePolygonGeometry(rawValue) {
+ if (!rawValue) {
+ return null;
+ }
+ const parsed = JSON.parse(rawValue);
+ if (parsed?.type === "Feature") {
+ return parsed.geometry;
+ }
+ if (parsed?.type === "Polygon") {
+ return parsed;
+ }
+ if (Array.isArray(parsed)) {
+ return { type: "Polygon", coordinates: [parsed] };
+ }
+ throw new Error("Polygon GeoJSON is invalid.");
+}
+
+function getBoundsForPolygon(geometry) {
+ if (!geometry || geometry.type !== "Polygon" || !Array.isArray(geometry.coordinates) || !Array.isArray(geometry.coordinates[0])) {
+ return null;
+ }
+ const ring = geometry.coordinates[0];
+ if (!ring.length) {
+ return null;
+ }
+ let minLng = ring[0][0];
+ let maxLng = ring[0][0];
+ let minLat = ring[0][1];
+ let maxLat = ring[0][1];
+ for (const point of ring) {
+ const lng = Number(point[0]);
+ const lat = Number(point[1]);
+ if (Number.isNaN(lng) || Number.isNaN(lat)) {
+ continue;
+ }
+ minLng = Math.min(minLng, lng);
+ maxLng = Math.max(maxLng, lng);
+ minLat = Math.min(minLat, lat);
+ maxLat = Math.max(maxLat, lat);
+ }
+ return [[minLng, minLat], [maxLng, maxLat]];
+}
+
+export class MapboxPolygonPreviewField extends Component {
+ static template = "dsrpt_repair_config.MapboxPolygonPreviewField";
+ static props = { ...standardFieldProps };
+ static supportedTypes = ["text"];
+
+ setup() {
+ this.mapRef = useRef("map");
+ this.map = null;
+ this.lastRenderKey = null;
+
+ onMounted(() => this.renderPreview(this.props));
+ onWillUpdateProps((nextProps) => this.renderPreview(nextProps));
+ onWillUnmount(() => this.destroyMap());
+ }
+
+ destroyMap() {
+ if (this.map) {
+ this.map.remove();
+ this.map = null;
+ }
+ }
+
+ showMessage(container, message) {
+ this.destroyMap();
+ container.innerHTML = `${message}
`;
+ }
+
+ async renderPreview(props) {
+ const container = this.mapRef.el;
+ if (!container) {
+ return;
+ }
+ const rawValue = props.record.data[props.name] || "";
+ const token = props.record.data.mapbox_token || "";
+ const renderKey = `${token}::${rawValue}`;
+ if (renderKey === this.lastRenderKey) {
+ return;
+ }
+ this.lastRenderKey = renderKey;
+
+ if (!token) {
+ this.showMessage(container, "Mapbox token is missing.");
+ return;
+ }
+
+ let geometry;
+ try {
+ geometry = parsePolygonGeometry(rawValue);
+ } catch {
+ this.showMessage(container, "Invalid polygon JSON.");
+ return;
+ }
+ if (!geometry) {
+ this.showMessage(container, "Add polygon GeoJSON to render map.");
+ return;
+ }
+
+ try {
+ await loadMapboxAssets();
+ } catch {
+ this.showMessage(container, "Cannot load Mapbox assets.");
+ return;
+ }
+
+ window.mapboxgl.accessToken = token;
+ container.innerHTML = "";
+ if (!this.map) {
+ this.map = new window.mapboxgl.Map({
+ container,
+ style: "mapbox://styles/mapbox/streets-v12",
+ center: [-84.39, 33.75],
+ zoom: 8,
+ });
+ this.map.addControl(new window.mapboxgl.NavigationControl({ showCompass: false }), "top-right");
+ this.map.on("load", () => this.drawPolygon(geometry));
+ return;
+ }
+ if (this.map.loaded()) {
+ this.drawPolygon(geometry);
+ } else {
+ this.map.once("load", () => this.drawPolygon(geometry));
+ }
+ }
+
+ drawPolygon(geometry) {
+ const sourceId = "zonePolygonSource";
+ const fillLayerId = "zonePolygonFill";
+ const lineLayerId = "zonePolygonLine";
+ const feature = {
+ type: "Feature",
+ properties: {},
+ geometry,
+ };
+
+ if (this.map.getLayer(fillLayerId)) {
+ this.map.removeLayer(fillLayerId);
+ }
+ if (this.map.getLayer(lineLayerId)) {
+ this.map.removeLayer(lineLayerId);
+ }
+ if (this.map.getSource(sourceId)) {
+ this.map.removeSource(sourceId);
+ }
+
+ this.map.addSource(sourceId, { type: "geojson", data: feature });
+ this.map.addLayer({
+ id: fillLayerId,
+ type: "fill",
+ source: sourceId,
+ paint: {
+ "fill-color": "#1D4ED8",
+ "fill-opacity": 0.24,
+ },
+ });
+ this.map.addLayer({
+ id: lineLayerId,
+ type: "line",
+ source: sourceId,
+ paint: {
+ "line-color": "#1E40AF",
+ "line-width": 3,
+ },
+ });
+
+ const bounds = getBoundsForPolygon(geometry);
+ if (bounds) {
+ this.map.fitBounds(bounds, { padding: 32, duration: 0 });
+ }
+ }
+}
+
+registry.category("fields").add("mapbox_polygon_preview", {
+ component: MapboxPolygonPreviewField,
+ displayName: "Mapbox Polygon Preview",
+});
+
diff --git a/odoo/addons/dsrpt_repair_config/static/src/scss/mapbox_polygon_preview_field.scss b/odoo/addons/dsrpt_repair_config/static/src/scss/mapbox_polygon_preview_field.scss
new file mode 100644
index 0000000..c453ec2
--- /dev/null
+++ b/odoo/addons/dsrpt_repair_config/static/src/scss/mapbox_polygon_preview_field.scss
@@ -0,0 +1,20 @@
+.o_mapbox_polygon_widget {
+ width: 100%;
+ height: 420px;
+ min-height: 420px;
+ border: 1px solid #d7dde5;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #f5f7fa;
+}
+
+.o_mapbox_polygon_placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 16px;
+ color: #5f6b7a;
+ text-align: center;
+ font-size: 14px;
+}
diff --git a/odoo/addons/dsrpt_repair_config/static/src/xml/mapbox_polygon_preview_field.xml b/odoo/addons/dsrpt_repair_config/static/src/xml/mapbox_polygon_preview_field.xml
new file mode 100644
index 0000000..d959ee3
--- /dev/null
+++ b/odoo/addons/dsrpt_repair_config/static/src/xml/mapbox_polygon_preview_field.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_form.xml b/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_form.xml
index 451596d..1c118c9 100644
--- a/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_form.xml
+++ b/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_form.xml
@@ -5,14 +5,6 @@
repair.fsm.zone
diff --git a/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_kanban.xml b/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_kanban.xml
index a00d26e..d213ff8 100644
--- a/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_kanban.xml
+++ b/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_kanban.xml
@@ -4,9 +4,8 @@
repair.fsm.zone.kanban
repair.fsm.zone
-
+
-
diff --git a/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_list.xml b/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_list.xml
index eacc2a0..b9b76d1 100644
--- a/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_list.xml
+++ b/odoo/addons/dsrpt_repair_config/views/repair_fsm_zone_view_list.xml
@@ -6,7 +6,6 @@
-