Replace zone preview with JS Mapbox widget and simplify zone UI
This commit is contained in:
@@ -17,6 +17,13 @@
|
|||||||
"views/repair_fsm_zone_action_main.xml",
|
"views/repair_fsm_zone_action_main.xml",
|
||||||
"views/menu.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,
|
"installable": True,
|
||||||
"application": True,
|
"application": True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,5 @@
|
|||||||
<field name="polygon_geojson"><![CDATA[
|
<field name="polygon_geojson"><![CDATA[
|
||||||
{"type":"Polygon","coordinates":[[[-84.55,33.60],[-84.20,33.60],[-84.20,33.90],[-84.55,33.90],[-84.55,33.60]]]}
|
{"type":"Polygon","coordinates":[[[-84.55,33.60],[-84.20,33.60],[-84.20,33.90],[-84.55,33.90],[-84.55,33.60]]]}
|
||||||
]]></field>
|
]]></field>
|
||||||
<field name="state">active</field>
|
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from odoo import api, fields, models
|
from odoo import api, fields, models
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
@@ -14,40 +13,25 @@ class RepairFsmZone(models.Model):
|
|||||||
name = fields.Char(required=True, tracking=True)
|
name = fields.Char(required=True, tracking=True)
|
||||||
polygon_geojson = fields.Text(
|
polygon_geojson = fields.Text(
|
||||||
string="Polygon (GeoJSON)",
|
string="Polygon (GeoJSON)",
|
||||||
|
required=True,
|
||||||
tracking=True,
|
tracking=True,
|
||||||
help="GeoJSON Polygon geometry. Coordinates order: [longitude, latitude].",
|
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(
|
state = fields.Selection(
|
||||||
selection=[
|
selection=[
|
||||||
("draft", "Draft"),
|
|
||||||
("active", "Active"),
|
("active", "Active"),
|
||||||
("archived", "Archived"),
|
("archived", "Archived"),
|
||||||
],
|
],
|
||||||
default="draft",
|
default="active",
|
||||||
tracking=True,
|
tracking=True,
|
||||||
group_expand="_group_expand_states",
|
|
||||||
)
|
)
|
||||||
|
mapbox_token = fields.Char(compute="_compute_mapbox_token")
|
||||||
|
|
||||||
def _group_expand_states(self, states, domain, order):
|
@api.depends_context("uid")
|
||||||
return [key for key, _label in self._fields["state"].selection]
|
def _compute_mapbox_token(self):
|
||||||
|
token = self.env["ir.config_parameter"].sudo().get_param("dsrpt_repair_config.mapbox_token") or ""
|
||||||
def action_set_active(self):
|
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.polygon_geojson:
|
rec.mapbox_token = token
|
||||||
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"})
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _point_in_polygon(longitude, latitude, points):
|
def _point_in_polygon(longitude, latitude, points):
|
||||||
@@ -108,51 +92,7 @@ class RepairFsmZone(models.Model):
|
|||||||
points = self._extract_polygon_points()
|
points = self._extract_polygon_points()
|
||||||
return self._point_in_polygon(float(longitude), float(latitude), 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 = "<div>No polygon yet.</div>"
|
|
||||||
continue
|
|
||||||
if not token:
|
|
||||||
rec.polygon_map_preview = "<div>Set Mapbox token in system parameter dsrpt_repair_config.mapbox_token.</div>"
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = json.loads(rec.polygon_geojson)
|
|
||||||
except Exception:
|
|
||||||
rec.polygon_map_preview = "<div>Invalid polygon JSON.</div>"
|
|
||||||
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'<img src="{static_url}" alt="Zone polygon map preview" '
|
|
||||||
'style="width:100%;height:auto;max-height:420px;object-fit:contain;border:1px solid #d9d9d9;border-radius:6px;"/>'
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.constrains("polygon_geojson")
|
@api.constrains("polygon_geojson")
|
||||||
def _check_polygon_geojson(self):
|
def _check_polygon_geojson(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.polygon_geojson:
|
rec._extract_polygon_points()
|
||||||
rec._extract_polygon_points()
|
|
||||||
|
|||||||
@@ -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 = `<div class="o_mapbox_polygon_placeholder">${message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="dsrpt_repair_config.MapboxPolygonPreviewField">
|
||||||
|
<div t-ref="map" class="o_mapbox_polygon_widget"/>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -5,14 +5,6 @@
|
|||||||
<field name="model">repair.fsm.zone</field>
|
<field name="model">repair.fsm.zone</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form>
|
<form>
|
||||||
<header>
|
|
||||||
<button name="action_set_active" type="object" string="Set Active" class="btn-primary" invisible="state == 'active'"/>
|
|
||||||
<button name="action_archive" type="object" string="Archive" invisible="state == 'archived'"/>
|
|
||||||
<button name="action_reset_draft" type="object" string="Reset to Draft" invisible="state == 'draft'"/>
|
|
||||||
<button name="action_open_polygon_in_map" type="object" string="Open Polygon Map" invisible="not polygon_geojson"/>
|
|
||||||
<button name="action_open_point_picker_map" type="object" string="Open OSM"/>
|
|
||||||
<field name="state" widget="statusbar" statusbar_visible="draft,active,archived" options="{'clickable': '1'}"/>
|
|
||||||
</header>
|
|
||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
@@ -21,10 +13,10 @@
|
|||||||
<field name="polygon_geojson" widget="text" placeholder='{"type":"Polygon","coordinates":[[[-84.55,33.60],[-84.20,33.60],[-84.20,33.90],[-84.55,33.90],[-84.55,33.60]]]}'/>
|
<field name="polygon_geojson" widget="text" placeholder='{"type":"Polygon","coordinates":[[[-84.55,33.60],[-84.20,33.60],[-84.20,33.90],[-84.55,33.90],[-84.55,33.60]]]}'/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="polygon_map_preview" widget="html" readonly="1" nolabel="1"/>
|
<field name="mapbox_token" invisible="1"/>
|
||||||
|
<field name="polygon_geojson" widget="mapbox_polygon_preview" nolabel="1"/>
|
||||||
</group>
|
</group>
|
||||||
</sheet>
|
</sheet>
|
||||||
<chatter/>
|
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
<field name="name">repair.fsm.zone.kanban</field>
|
<field name="name">repair.fsm.zone.kanban</field>
|
||||||
<field name="model">repair.fsm.zone</field>
|
<field name="model">repair.fsm.zone</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<kanban default_group_by="state">
|
<kanban>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="state"/>
|
|
||||||
<templates>
|
<templates>
|
||||||
<t t-name="kanban-box">
|
<t t-name="kanban-box">
|
||||||
<div class="oe_kanban_global_click">
|
<div class="oe_kanban_global_click">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list>
|
<list>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="state" widget="badge" optional="show"/>
|
|
||||||
<field name="polygon_geojson" optional="hide"/>
|
<field name="polygon_geojson" optional="hide"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
Reference in New Issue
Block a user