Merge pull request #69 from expsa/khazraji_base

Khazraji base
This commit is contained in:
mohammed-alkhazrji 2025-12-26 01:29:22 +03:00 committed by GitHub
commit d4ea266ffd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 18791 additions and 3395 deletions

View File

@ -27,7 +27,7 @@ export class KpiFormulaField extends Component {
onWillStart(async () => {
try {
// Load draggabilly library
await loadJS("kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js");
await loadJS("/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js");
await this.loadFormulaData();
} catch (error) {
console.error("Error loading KPI formula:", error);
@ -51,12 +51,14 @@ export class KpiFormulaField extends Component {
async loadFormulaData() {
try {
const fieldValue = this.props.record.data[this.props.name] || "";
if (this.props.readonly) {
// Load formula parts for readonly display
this.state.formulaParts = await this.orm.call(
"kpi.item",
"action_render_formula",
[this.props.value || ""]
[fieldValue]
);
} else {
// Load variables for edit mode
@ -65,13 +67,14 @@ export class KpiFormulaField extends Component {
this.state.variables = await this.orm.call(
"kpi.item",
"action_return_measures",
[[recordId], this.props.value || ""]
[[recordId], fieldValue]
);
}
}
this.state.isLoaded = true;
} catch (error) {
console.error("Error loading formula data:", error);
this.state.isLoaded = true; // Set to true even on error
}
}
@ -128,7 +131,8 @@ export class KpiFormulaField extends Component {
}
});
this.props.update(formula.trim());
// Update using Odoo 18 method
this.props.record.update({ [this.props.name]: formula.trim() });
}
onSearch(ev) {
@ -176,5 +180,7 @@ export class KpiFormulaField extends Component {
}
}
// Register the field widget
registry.category("fields").add("kpi_formula", KpiFormulaField);
// ✅ الطريقة الصحيحة للتسجيل في Odoo 18
registry.category("fields").add("kpi_formula", {
component: KpiFormulaField,
});

View File

@ -2,14 +2,14 @@
<templates>
<t t-name="kpi_scorecard.KpiFormulaField">
<div class="o_field_kpi_formula" t-ref="formula">
<!-- Readonly Mode -->
<t t-if="props.readonly">
<div class="formula-readonly">
<t t-if="state.formulaParts and state.formulaParts.length">
<t t-foreach="state.formulaParts" t-as="part" t-key="part_index">
<span t-att-class="'formula-part ' + (part.type || '').toLowerCase()"
t-att-title="part.name">
<t t-esc="part.name"/>
</span>
t-att-title="part.name"
t-esc="part.name"/>
</t>
</t>
<t t-else="">
@ -17,51 +17,74 @@
</t>
</div>
</t>
<!-- Edit Mode -->
<t t-else="">
<div class="formula-editor" t-if="state.isLoaded">
<!-- Search Input -->
<div class="formula-search mb-2">
<input type="text"
class="kpi-search-input form-control"
placeholder="Search..."
t-on-keyup="onSearch"/>
t-on-input="onSearch"/>
</div>
<div class="row">
<!-- Left Column: Variables and Operators -->
<div class="col-md-6">
<!-- Variables Section -->
<div class="formula-variables" t-if="state.variables">
<t t-foreach="state.variables.sections || []" t-as="section" t-key="section_index">
<div class="variable-section mb-3">
<h6 t-esc="section.name" class="text-primary"/>
<div class="variable-items">
<t t-foreach="section.items || []" t-as="item" t-key="item_index">
<div class="formula-item draggable btn btn-sm btn-outline-secondary m-1"
t-att-data-formula="item.formula"
t-att-data-part-id="item.id">
<span t-esc="item.name"/>
<button class="btn btn-sm btn-danger ms-1"
t-on-click="() => onDeletePart(item.id)"
type="button">
<i class="fa fa-times"/>
</button>
</div>
</t>
<t t-if="state.variables.sections">
<t t-foreach="state.variables.sections" t-as="section" t-key="section_index">
<div class="variable-section mb-3">
<h6 t-esc="section.name" class="text-primary"/>
<div class="variable-items">
<t t-if="section.items">
<t t-foreach="section.items" t-as="item" t-key="item_index">
<div class="formula-item draggable btn btn-sm btn-outline-secondary m-1"
t-att-data-formula="item.formula"
t-att-data-part-id="item.id">
<span t-esc="item.name"/>
<button class="btn btn-sm btn-danger ms-1"
t-on-click.stop="(ev) => this.onDeletePart(item.id)"
type="button">
<i class="fa fa-times"/>
</button>
</div>
</t>
</t>
</div>
</div>
</div>
</t>
</t>
</div>
<!-- Operators Section -->
<div class="formula-operators mb-3">
<h6 class="text-primary">Operators</h6>
<button t-foreach="['+', '-', '*', '/', '(', ')']"
t-as="op"
t-key="op"
class="btn btn-secondary btn-sm m-1"
t-on-click="() => onAddOperator(op)"
type="button">
<t t-esc="op"/>
</button>
<div class="d-flex flex-wrap gap-1">
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onAddOperator('+')"
type="button">+</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onAddOperator('-')"
type="button">-</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onAddOperator('*')"
type="button">*</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onAddOperator('/')"
type="button">/</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onAddOperator('(')"
type="button">(</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onAddOperator(')')"
type="button">)</button>
</div>
</div>
<!-- Number Input Section -->
<div class="formula-number mb-3">
<h6 class="text-primary">Number</h6>
<input type="number"
@ -71,6 +94,7 @@
</div>
</div>
<!-- Right Column: Formula Builder -->
<div class="col-md-6">
<h6 class="text-primary">Formula Builder</h6>
<div class="formula-drop-zone border rounded p-3 mb-3"
@ -82,16 +106,117 @@
<label class="form-label">Current Formula:</label>
<input type="text"
class="form-control"
t-att-value="props.value || ''"
t-att-value="props.record.data[props.name] || ''"
readonly="readonly"/>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div t-else="" class="text-center text-muted p-3">
<i class="fa fa-spinner fa-spin me-2"/>
Loading formula editor...
</div>
</t>
</div>
</t>
</templates>
</templates>
<!--<?xml version="1.0" encoding="UTF-8"?>-->
<!--<templates>-->
<!-- <t t-name="kpi_scorecard.KpiFormulaField">-->
<!-- <div class="o_field_kpi_formula" t-ref="formula">-->
<!-- <t t-if="props.readonly">-->
<!-- <div class="formula-readonly">-->
<!-- <t t-if="state.formulaParts and state.formulaParts.length">-->
<!-- <t t-foreach="state.formulaParts" t-as="part" t-key="part_index">-->
<!-- <span t-att-class="'formula-part ' + (part.type || '').toLowerCase()"-->
<!-- t-att-title="part.name">-->
<!-- <t t-esc="part.name"/>-->
<!-- </span>-->
<!-- </t>-->
<!-- </t>-->
<!-- <t t-else="">-->
<!-- <span class="text-muted">No formula defined</span>-->
<!-- </t>-->
<!-- </div>-->
<!-- </t>-->
<!-- <t t-else="">-->
<!-- <div class="formula-editor" t-if="state.isLoaded">-->
<!-- <div class="formula-search mb-2">-->
<!-- <input type="text"-->
<!-- class="kpi-search-input form-control"-->
<!-- placeholder="Search..."-->
<!-- t-on-keyup="onSearch"/>-->
<!-- </div>-->
<!-- <div class="row">-->
<!-- <div class="col-md-6">-->
<!-- <div class="formula-variables" t-if="state.variables">-->
<!-- <t t-foreach="state.variables.sections || []" t-as="section" t-key="section_index">-->
<!-- <div class="variable-section mb-3">-->
<!-- <h6 t-esc="section.name" class="text-primary"/>-->
<!-- <div class="variable-items">-->
<!-- <t t-foreach="section.items || []" t-as="item" t-key="item_index">-->
<!-- <div class="formula-item draggable btn btn-sm btn-outline-secondary m-1"-->
<!-- t-att-data-formula="item.formula"-->
<!-- t-att-data-part-id="item.id">-->
<!-- <span t-esc="item.name"/>-->
<!-- <button class="btn btn-sm btn-danger ms-1"-->
<!-- t-on-click="() => onDeletePart(item.id)"-->
<!-- type="button">-->
<!-- <i class="fa fa-times"/>-->
<!-- </button>-->
<!-- </div>-->
<!-- </t>-->
<!-- </div>-->
<!-- </div>-->
<!-- </t>-->
<!-- </div>-->
<!-- <div class="formula-operators mb-3">-->
<!-- <h6 class="text-primary">Operators</h6>-->
<!-- <button t-foreach="['+', '-', '*', '/', '(', ')']"-->
<!-- t-as="op"-->
<!-- t-key="op"-->
<!-- class="btn btn-secondary btn-sm m-1"-->
<!-- t-on-click="() => onAddOperator(op)"-->
<!-- type="button">-->
<!-- <t t-esc="op"/>-->
<!-- </button>-->
<!-- </div>-->
<!-- <div class="formula-number mb-3">-->
<!-- <h6 class="text-primary">Number</h6>-->
<!-- <input type="number"-->
<!-- class="kpi-number-input form-control"-->
<!-- placeholder="Enter number..."-->
<!-- t-on-change="onChangeNumber"/>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="col-md-6">-->
<!-- <h6 class="text-primary">Formula Builder</h6>-->
<!-- <div class="formula-drop-zone border rounded p-3 mb-3"-->
<!-- style="min-height: 100px; background-color: #f8f9fa;">-->
<!-- <small class="text-muted">Drag formula parts here</small>-->
<!-- </div>-->
<!-- <div class="current-formula">-->
<!-- <label class="form-label">Current Formula:</label>-->
<!-- <input type="text"-->
<!-- class="form-control"-->
<!-- t-att-value="props.value || ''"-->
<!-- readonly="readonly"/>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div t-else="" class="text-center text-muted p-3">-->
<!-- Loading formula editor...-->
<!-- </div>-->
<!-- </t>-->
<!-- </div>-->
<!-- </t>-->
<!--</templates>-->

View File

@ -14,22 +14,21 @@
"views/res_config_settings.xml",
"views/res_partner_views.xml",
],
'qweb':[
"static/src/xml/map.xml"
],
# 'qweb':[
# "static/src/xml/map.xml"
# ],
'assets': {
'web.assets_backend': [
'odex30_web_map/static/src/js/map_controller.js',
'odex30_web_map/static/src/js/map_model.js',
'odex30_web_map/static/src/js/map_renderer.js',
'odex30_web_map/static/src/js/map_view.js',
'odex30_web_map/static/src/scss/map_view.scss',
'odex30_web_map/static/lib/leaflet/leaflet.js',
'odex30_web_map/static/lib/leaflet/leaflet.css',
'web.assets_backend_lazy': [
'web_map/static/src/**/*',
],
'web.assets_unit_tests': [
'web_map/static/lib/**/*',
'web_map/static/tests/**/*',
],
'web.qunit_suite_tests': [
'/odex30_web_map/static/tests/map_view_tests.js'
]
'web_map/static/lib/**/*',
],
},
'auto_install': True,

View File

@ -8,3 +8,6 @@ class View(models.Model):
_inherit = 'ir.ui.view'
type = fields.Selection(selection_add=[('map', "Map")])
def _get_view_info(self):
return {'map': {'icon': 'fa fa-map-marker'}} | super()._get_view_info()

View File

@ -6,38 +6,75 @@ from odoo.exceptions import UserError
import requests
from odoo.http import request
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
map_box_token = fields.Char(config_parameter='odex30_web_map.token_map_box',string = 'Token Map Box', help='Necessary for some functionalities in the map view', copy=True, default='', store=True)
map_box_token = fields.Char(config_parameter='web_map.token_map_box',string = 'Token Map Box', help='Necessary for some functionalities in the map view', copy=True, default='', store=True)
def check_token_validity(self):
url = 'https://api.mapbox.com/directions/v5/mapbox/driving/-73.989%2C40.733%3B-74%2C40.733?access_token='+self.map_box_token+'&steps=true&geometries=geojson'
environ = request.httprequest.headers.environ
# if self.map_box_token != '':
# try:
# headers = {
# 'referer': environ.get('HTTP_REFERER')
# }
# result = requests.get(url=url, headers=headers)
# error_code = result.status_code
# if(result.status_code != 200):
# self.map_box_token = ''
# if error_code == 401:
# raise UserError(_('The token input is not valid'))
# elif error_code == 403:
# raise UserError(_('This referer is not authorized'))
# elif error_code == 500:
# raise UserError(_('The MapBox server is unreachable'))
# except Exception:
# raise
@api.onchange('map_box_token')
def _onchange_map_box_token(self):
if not self.map_box_token:
return
map_box_token = self.env['ir.config_parameter'].get_param('web_map.token_map_box')
if self.map_box_token == map_box_token:
return
@api.model
def set_values(self):
# check validity of mapbox token only when it is being changed
if self.map_box_token:
map_box_token = self.env['ir.config_parameter'].get_param('odex30_web_map.token_map_box')
if map_box_token != self.map_box_token:
self.check_token_validity()
super(ResConfigSettings, self).set_values()
url = 'https://api.mapbox.com/directions/v5/mapbox/driving/-73.989%2C40.733%3B-74%2C40.733'
headers = {
'referer': request.httprequest.headers.environ.get('HTTP_REFERER'),
}
params = {
'access_token': self.map_box_token,
'steps': 'true',
'geometries': 'geojson',
}
try:
result = requests.head(url=url, headers=headers, params=params, timeout=5)
error_code = result.status_code
except requests.exceptions.RequestException:
error_code = 500
if error_code == 200:
return
self.map_box_token = ''
if error_code == 401:
return {'warning': {'message': _('The token input is not valid')}}
elif error_code == 403:
return {'warning': {'message': _('This referer is not authorized')}}
elif error_code == 500:
return {'warning': {'message': _('The MapBox server is unreachable')}}
# class ResConfigSettings(models.TransientModel):
# _inherit = 'res.config.settings'
#
# map_box_token = fields.Char(config_parameter='odex30_web_map.token_map_box',string = 'Token Map Box', help='Necessary for some functionalities in the map view', copy=True, default='', store=True)
#
# # def check_token_validity(self):
# url = 'https://api.mapbox.com/directions/v5/mapbox/driving/-73.989%2C40.733%3B-74%2C40.733?access_token='+self.map_box_token+'&steps=true&geometries=geojson'
# environ = request.httprequest.headers.environ
# # if self.map_box_token != '':
# # try:
# # headers = {
# # 'referer': environ.get('HTTP_REFERER')
# # }
# # result = requests.get(url=url, headers=headers)
# # error_code = result.status_code
# # if(result.status_code != 200):
# # self.map_box_token = ''
# # if error_code == 401:
# # raise UserError(_('The token input is not valid'))
# # elif error_code == 403:
# # raise UserError(_('This referer is not authorized'))
# # elif error_code == 500:
# # raise UserError(_('The MapBox server is unreachable'))
# # except Exception:
# # raise
# @api.model
# def set_values(self):
# # check validity of mapbox token only when it is being changed
# if self.map_box_token:
# map_box_token = self.env['ir.config_parameter'].get_param('odex30_web_map.token_map_box')
# if map_box_token != self.map_box_token:
# self.check_token_validity()
# super(ResConfigSettings, self).set_values()

View File

@ -7,11 +7,31 @@ from odoo import api, fields, models
class ResPartner(models.Model):
_name = 'res.partner'
_inherit = 'res.partner'
contact_address_complete = fields.Char(compute='_compute_complete_address', store=True)
def write(self, vals):
# Reset latitude/longitude in case we modify the address without
# updating the related geolocation fields
if any(field in vals for field in ['street', 'zip', 'city', 'state_id', 'country_id']) \
and not all('partner_%s' % field in vals for field in ['latitude', 'longitude']):
vals.update({
'partner_latitude': False,
'partner_longitude': False,
})
return super().write(vals)
@api.model
def _address_fields(self):
return super()._address_fields() + ['partner_latitude', 'partner_longitude']
@api.model
def _formatting_address_fields(self):
"""Returns the list of address fields usable to format addresses."""
result = super()._formatting_address_fields()
return [item for item in result if item not in ['partner_latitude', 'partner_longitude']]
@api.model
def update_latitude_longitude(self, partners):
partners_data = defaultdict(list)
@ -34,12 +54,14 @@ class ResPartner(models.Model):
self.partner_latitude = False
self.partner_longitude = False
@api.depends('street', 'zip', 'city', 'country_id')
@api.depends('street', 'street2', 'zip', 'city', 'country_id')
def _compute_complete_address(self):
for record in self:
record.contact_address_complete = ''
if record.street:
record.contact_address_complete += record.street + ', '
if record.street2:
record.contact_address_complete += record.street2 + ', '
if record.zip:
record.contact_address_complete += record.zip + ' '
if record.city:
@ -49,3 +71,48 @@ class ResPartner(models.Model):
if record.country_id:
record.contact_address_complete += record.country_id.name
record.contact_address_complete = record.contact_address_complete.strip().strip(',')
#
# class ResPartner(models.Model):
# _name = 'res.partner'
# _inherit = 'res.partner'
#
# contact_address_complete = fields.Char(compute='_compute_complete_address', store=True)
#
# @api.model
# def update_latitude_longitude(self, partners):
# partners_data = defaultdict(list)
#
# for partner in partners:
# if 'id' in partner and 'partner_latitude' in partner and 'partner_longitude' in partner:
# partners_data[(partner['partner_latitude'], partner['partner_longitude'])].append(partner['id'])
#
# for values, partner_ids in partners_data.items():
# # NOTE this should be done in sudo to avoid crashing as soon as the view is used
# self.browse(partner_ids).sudo().write({
# 'partner_latitude': values[0],
# 'partner_longitude': values[1],
# })
#
# return {}
#
# @api.onchange('street', 'zip', 'city', 'state_id', 'country_id')
# def _delete_coordinates(self):
# self.partner_latitude = False
# self.partner_longitude = False
#
# @api.depends('street', 'zip', 'city', 'country_id')
# def _compute_complete_address(self):
# for record in self:
# record.contact_address_complete = ''
# if record.street:
# record.contact_address_complete += record.street + ', '
# if record.zip:
# record.contact_address_complete += record.zip + ' '
# if record.city:
# record.contact_address_complete += record.city + ', '
# if record.state_id:
# record.contact_address_complete += record.state_id.name + ', '
# if record.country_id:
# record.contact_address_complete += record.country_id.name
# record.contact_address_complete = record.contact_address_complete.strip().strip(',')

View File

@ -25,6 +25,10 @@
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
@ -41,7 +45,10 @@
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg,
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
@ -49,8 +56,15 @@
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
@ -162,9 +176,6 @@
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-tile {
will-change: opacity;
}
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
@ -179,9 +190,10 @@
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
svg.leaflet-zoom-animated {
will-change: transform;
}
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
@ -237,7 +249,8 @@
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive {
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
@ -246,13 +259,7 @@
.leaflet-container {
background: #ddd;
outline: 0;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-container a.leaflet-active {
outline: 2px solid orange;
outline-offset: 1px;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
@ -262,7 +269,10 @@
/* general typography */
.leaflet-container {
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
@ -272,8 +282,7 @@
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a,
.leaflet-bar a:hover {
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
@ -290,7 +299,8 @@
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover {
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
@ -380,6 +390,8 @@
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
@ -388,7 +400,7 @@
}
/* Default icon URLs */
.leaflet-default-icon-path {
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
@ -397,23 +409,27 @@
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover {
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-scale {
font-size: 11px;
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
@ -426,14 +442,11 @@
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: #fff;
background: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
@ -469,17 +482,22 @@
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 19px;
line-height: 1.4;
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 18px 0;
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
@ -490,6 +508,7 @@
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
@ -506,28 +525,25 @@
position: absolute;
top: 0;
right: 0;
padding: 4px 4px 0 0;
border: none;
text-align: center;
width: 18px;
height: 14px;
font: 16px/14px Tahoma, Verdana, sans-serif;
color: #c3c3c3;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
font-weight: bold;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover {
color: #999;
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
border-bottom: 1px solid #ddd;
border-top: 1px solid #ddd;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
zoom: 1;
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
@ -536,9 +552,6 @@
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-popup-tip-container {
margin-top: -1px;
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
@ -573,7 +586,7 @@
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-clickable {
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
@ -633,3 +646,13 @@
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,131 +0,0 @@
odoo.define('odex30_web_map.MapController', function (require) {
"use strict";
const AbstractController = require('web.AbstractController');
const core = require('web.core');
const qweb = core.qweb;
const MapController = AbstractController.extend({
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
'pin_clicked': '_onPinClick',
'get_itinerary_clicked': '_onGetItineraryClicked',
'open_clicked': '_onOpenClicked',
'pager_changed': '_onPagerChanged',
'coordinate_fetched': '_onCoordinateFetched',
}),
/**
* @constructor
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.actionName = params.actionName;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
* @param {jQuery} [$node]
*/
renderButtons: function ($node) {
let url = 'https://www.google.com/maps/dir/?api=1';
if (this.model.data.records.length) {
const coordinates = this.model.data.records
.filter(record => record.partner && record.partner.partner_latitude && record.partner.partner_longitude)
.map(record => record.partner.partner_latitude + ',' + record.partner.partner_longitude);
url += `&waypoints=${_.uniq(coordinates).join('|')}`;
}
this.$buttons = $(qweb.render("MapView.buttons"), { widget: this });
this.$buttons.find('a').attr('href', url);
if ($node) {
this.$buttons.appendTo($node);
}
},
/**
* @override
*/
update: async function () {
await this._super(...arguments);
this._updatePaging();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Return the params (currentMinimum, limit and size) to pass to the pager,
* according to the current state.
*
* @private
* @returns {Object}
*/
_getPagingInfo: function () {
const state = this.model.get();
return {
currentMinimum: state.offset + 1,
limit: state.limit,
size: state.count,
};
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {OdooEvent} ev
*/
_onCoordinateFetched: function (ev) {
ev.stopPropagation();
this.update({}, { reload: false });
},
/**
* Redirects to google maps with all the records' coordinates.
*
* @private
* @param {MouseEvent} ev
*/
_onGetItineraryClicked: function (ev) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${ev.data.lat},${ev.data.lon}`);
},
/**
* Redirects to views when clicked on open button in marker popup.
*
* @private
* @param {MouseEvent} ev
*/
_onOpenClicked: function (ev) {
if (ev.data.ids.length > 1) {
this.do_action({
type: 'ir.actions.act_window',
name: this.actionName,
views: [[false, 'list'], [false, 'form']],
res_model: this.modelName,
domain: [['id', 'in', ev.data.ids]],
});
} else {
this.trigger_up('switch_view', {
view_type: 'form',
res_id: ev.data.ids[0],
mode: 'readonly',
model: this.modelName
});
}
},
/**
* @private
* @param {OdooEvent} ev
*/
async _onPagerChanged(ev) {
const { currentMinimum, limit } = ev.data;
await this.reload({ limit, offset: currentMinimum - 1 });
},
});
return MapController;
});

View File

@ -1,505 +0,0 @@
odoo.define('odex30_web_map.MapModel', function (require) {
"use strict";
const AbstractModel = require('web.AbstractModel');
const session = require('web.session');
const core = require('web.core');
const _t = core._t;
const MapModel = AbstractModel.extend({
// Used in _openStreetMapAPIAsync to add delay between coordinates fetches
// We need this delay to not get banned from OSM.
COORDINATE_FETCH_DELAY: 1000,
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this.data = {};
this.data.mapBoxToken = session.map_box_token;
},
__get: function () {
return this.data;
},
__load: function (params) {
this.data.count = 0;
this.data.offset = 0;
this.data.limit = params.limit;
this.partnerToCache = [];
this.partnerIds = [];
this.resPartnerField = params.resPartnerField;
this.model = params.modelName;
this.context = params.context;
this.fields = params.fieldNames;
this.fieldsInfo = params.fieldsInfo;
this.domain = params.domain;
this.params = params;
this.orderBy = params.orderBy;
this.routing = params.routing;
this.numberOfLocatedRecords = 0;
this.coordinateFetchingTimeoutHandle = undefined;
this.data.shouldUpdatePosition = true;
this.data.fetchingCoordinates = false;
this.data.groupBy = params.groupedBy.length ? params.groupedBy[0] : false;
return this._fetchData();
},
__reload: function (handle, params) {
const options = params || {};
this.partnerToCache = [];
this.partnerIds = [];
this.numberOfLocatedRecords = 0;
this.data.shouldUpdatePosition = true;
this.data.fetchingCoordinates = false;
if (this.coordinateFetchingTimeoutHandle !== undefined) {
clearInterval(this.coordinateFetchingTimeoutHandle);
this.coordinateFetchingTimeoutHandle = undefined;
}
if (options.domain !== undefined) {
this.domain = options.domain;
}
if (options.limit !== undefined) {
this.data.limit = options.limit;
}
if (options.offset !== undefined) {
this.data.offset = options.offset;
}
if (options.groupBy !== undefined && options.groupBy[0] !== this.data.groupBy) {
this.data.groupBy = options.groupBy.length ? options.groupBy[0] : false;
}
return this._fetchData();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Adds the corresponding partner to a record.
*
* @private
*/
_addPartnerToRecord: function () {
this.data.records.forEach((record) => {
this.data.partners.forEach((partner) => {
let recordPartnerId;
if (this.model === "res.partner" && this.resPartnerField === "id") {
recordPartnerId = record.id;
} else {
recordPartnerId = record[this.resPartnerField][0];
}
if (recordPartnerId == partner.id) {
record.partner = partner;
this.numberOfLocatedRecords++;
}
});
});
},
/**
* The partner's coordinates should be between -90 <= latitude <= 90 and -180 <= longitude <= 180.
*
* @private
* @param {Object} partner
* @param {float} partner.partner_latitude latitude of the partner
* @param {float} partner.partner_longitude longitude of the partner
* @returns {boolean}
*/
_checkCoordinatesValidity: function (partner) {
if (partner.partner_latitude && partner.partner_longitude &&
partner.partner_latitude >= -90 && partner.partner_latitude <= 90 &&
partner.partner_longitude >= -180 && partner.partner_longitude <= 180) {
return true;
}
return false;
},
/**
* This function convert the addresses to coordinates using the mapbox API.
*
* @private
* @param {Object} record this object contains the record fetched from the database.
* @returns {Promise<result>} result.query contains the query the the api received
* result.features contains results in descendant order of relevance
*/
_fetchCoordinatesFromAddressMB: function (record) {
const address = encodeURIComponent(record.contact_address_complete);
const token = this.data.mapBoxToken;
const encodedUrl = `https://api.mapbox.com/geocoding/v5/mapbox.places/${address}.json?access_token=${token}&cachebuster=1552314159970&autocomplete=true`;
return new Promise((resolve, reject) => {
$.get(encodedUrl).then(resolve).catch(reject);
});
},
/**
* This function convert the addresses to coordinates using the openStreetMap api.
*
* @private
* @param {Object} record this object contains the record fetched from the database.
* @returns {Promise<result>} result is an array that contains the result in descendant order of relevance
* result[i].lat is the latitude of the converted address
* result[i].lon is the longitude of the converted address
* result[i].importance is a float that the relevance of the result the closer the float is to one the best it is.
*/
_fetchCoordinatesFromAddressOSM: function (record) {
const address = encodeURIComponent(record.contact_address_complete.replace('/', ' '));
const encodedUrl = `https://nominatim.openstreetmap.org/search/${address}?format=jsonv2`;
return new Promise(function (resolve, reject) {
$.get(encodedUrl).then(resolve).catch(reject);
});
},
/**
* Handles the case of an empty map.
* Handles the case where the model is res_partner.
* Fetches the records according to the model given in the arch.
* If the records has no partner_id field it is sliced from the array.
*
* @private
* @return {Promise}
*/
_fetchData: async function () {
//case of empty map
if (!this.resPartnerField) {
this.data.recordGroups = [];
this.data.records = [];
this.data.routeInfo = { routes: [] };
return;
}
const results = await this._fetchRecordData();
this.data.records = results.records;
this.data.count = results.length;
if (this.data.groupBy) {
this.data.recordGroups = this._getRecordGroups();
} else {
this.data.recordGroups = {};
}
this.partnerIds = [];
if (this.model === "res.partner" && this.resPartnerField === "id") {
this.data.records.forEach((record) => {
this.partnerIds.push(record.id);
record.partner_id = [record.id];
});
} else {
this._fillPartnerIds(this.data.records);
}
this.partnerIds = _.uniq(this.partnerIds);
return this._partnerFetching(this.partnerIds);
},
/**
* Fetch the records for a given model.
*
* @private
* @returns {Promise<results>}
*/
_fetchRecordData: function () {
return this._rpc({
route: '/web/dataset/search_read',
model: this.model,
context: this.context,
fields: this.data.groupBy ?
this.fields.concat(this.data.groupBy.split(':')[0]) :
this.fields,
domain: this.domain,
orderBy: this.orderBy,
limit: this.data.limit,
offset: this.data.offset
});
},
/**
* @private
* @returns {Object} the fetched records grouped by the groupBy field.
*/
_getRecordGroups: function () {
const [fieldName, subGroup] = this.data.groupBy.split(':');
const dateGroupFormats = {
year: 'YYYY',
quarter: '[Q]Q YYYY',
month: 'MMMM YYYY',
week: '[W]WW YYYY',
day: 'DD MMM YYYY',
};
const groups = {};
for (const record of this.data.records) {
const value = record[fieldName];
let id, name;
if (['date', 'datetime'].includes(this.fieldsInfo[fieldName].type)) {
const date = moment(value);
id = name = date.format(dateGroupFormats[subGroup]);
} else {
id = Array.isArray(value) ? value[0] : value;
name = Array.isArray(value) ? value[1] : value;
}
if (!groups[id]) {
groups[id] = {
name,
records: [],
};
}
groups[id].records.push(record);
}
return groups;
},
/**
* @private
* @param {Number[]} ids contains the ids from the partners
* @returns {Promise}
*/
_fetchRecordsPartner: function (ids) {
return this._rpc({
model: 'res.partner',
method: 'search_read',
fields: ['contact_address_complete', 'partner_latitude', 'partner_longitude'],
domain: [['contact_address_complete', '!=', 'False'], ['id', 'in', ids]],
});
},
/**
* Fetch the route from the mapbox api.
*
* @private
* @returns {Promise<results>}
* results.geometry.legs[i] contains one leg (i.e: the trip between two markers).
* results.geometry.legs[i].steps contains the sets of coordinates to follow to reach a point from an other.
* results.geometry.legs[i].distance: the distance in meters to reach the destination
* results.geometry.legs[i].duration the duration of the leg
* results.geometry.coordinates contains the sets of coordinates to go from the first to the last marker without the notion of waypoint
*/
_fetchRoute: function () {
const coordinatesParam = this.data.records
.filter(record => record.partner.partner_latitude && record.partner.partner_longitude)
.map(record => record.partner.partner_longitude + ',' + record.partner.partner_latitude);
const address = encodeURIComponent(coordinatesParam.join(';'));
const token = this.data.mapBoxToken;
const encodedUrl = `https://api.mapbox.com/directions/v5/mapbox/driving/${address}?access_token=${token}&steps=true&geometries=geojson`;
return new Promise(function (resolve, reject) {
$.get(encodedUrl).then(resolve).catch(reject);
});
},
/**
* @private
* @param {Object[]} records the records that are going to be filtered
* @returns {Object[]} Array of records that contains a partner_id
*/
_fillPartnerIds: function (records) {
return records.forEach(record => {
if (record[this.resPartnerField]) {
this.partnerIds.push(record[this.resPartnerField][0]);
}
});
},
/**
* Converts a MapBox error message into a custom translatable one.
*
* @private
* @param {string} message
*/
_getErrorMessage: function (message) {
const ERROR_MESSAGES = {
'Too many coordinates; maximum number of coordinates is 25': _t("Too many routing points (maximum 25)"),
'Route exceeds maximum distance limitation': _t("Some routing points are too far apart"),
'Too Many Requests': _t("Too many requests, try again in a few minutes"),
};
return ERROR_MESSAGES[message];
},
/**
* Handles the case where the selected api is MapBox.
* Iterates on all the partners and fetches their coordinates when they're not set.
*
* @private
* @return {Promise<routeResult> | Promise<>} if there's more than 2 located records and the routing option is activated it returns a promise that fetches the route
* resultResult is an object that contains the computed route
* or if either of these conditions are not respected it returns an empty promise
*/
_maxBoxAPI: function () {
const promises = [];
this.data.partners.forEach(partner => {
if (partner.contact_address_complete && (!partner.partner_latitude || !partner.partner_longitude)) {
promises.push(this._fetchCoordinatesFromAddressMB(partner).then(coordinates => {
if (coordinates.features.length) {
partner.partner_longitude = coordinates.features[0].geometry.coordinates[0];
partner.partner_latitude = coordinates.features[0].geometry.coordinates[1];
this.partnerToCache.push(partner);
}
}));
} else if (!this._checkCoordinatesValidity(partner)) {
partner.partner_latitude = undefined;
partner.partner_longitude = undefined;
}
});
return Promise.all(promises).then(() => {
this.data.routeInfo = { routes: [] };
if (this.numberOfLocatedRecords > 1 && this.routing && !this.data.groupBy) {
return this._fetchRoute().then(routeResult => {
if (routeResult.routes) {
this.data.routeInfo = routeResult;
} else {
this.data.routingError = this._getErrorMessage(routeResult.message);
}
});
} else {
return Promise.resolve();
}
});
},
/**
* Handles the displaying of error message according to the error.
*
* @private
* @param {Object} err contains the error returned by the requests
* @param {Number} err.status contains the status_code of the failed http request
*/
_mapBoxErrorHandling: function (err) {
switch (err.status) {
case 401:
this.do_warn(
_t('Token invalid'),
_t('The view has switched to another provider but functionalities will be limited')
);
break;
case 403:
this.do_warn(
_t('Unauthorized connection'),
_t('The view has switched to another provider but functionalities will be limited')
);
break;
case 422: // Max. addresses reached
case 429: // Max. requests reached
this.data.routingError = this._getErrorMessage(err.responseJSON.message);
break;
case 500:
this.do_warn(
_t('MapBox servers unreachable'),
_t('The view has switched to another provider but functionalities will be limited')
);
}
},
/**
* Notifies the fetched coordinates to server and controller.
*
* @private
*/
_notifyFetchedCoordinate: function () {
this._writeCoordinatesUsers();
this.data.shouldUpdatePosition = false;
this.trigger_up('coordinate_fetched');
},
/**
* Calls (without awaiting) _openStreetMapAPIAsync with a delay of 1000ms
* to not get banned from openstreetmap's server.
*
* Tests should patch this function to wait for coords to be fetched.
*
* @see _openStreetMapAPIAsync
* @private
* @return {Promise}
*/
_openStreetMapAPI: function () {
this._openStreetMapAPIAsync();
return Promise.resolve();
},
/**
* Handles the case where the selected api is open street map.
* Iterates on all the partners and fetches their coordinates when they're not set.
*
* @private
* @returns {Promise}
*/
_openStreetMapAPIAsync: function () {
// Group partners by address to reduce address list
const addressPartnerMap = new Map();
for (const partner of this.data.partners) {
if (partner.contact_address_complete && (!partner.partner_latitude || !partner.partner_longitude)) {
if (!addressPartnerMap.has(partner.contact_address_complete)) {
addressPartnerMap.set(partner.contact_address_complete, []);
}
addressPartnerMap.get(partner.contact_address_complete).push(partner);
partner.fetchingCoordinate = true;
} else if (!this._checkCoordinatesValidity(partner)) {
partner.partner_latitude = undefined;
partner.partner_longitude = undefined;
}
}
// `fetchingCoordinates` is used to display the "fetching banner"
// We need to check if there are coordinates to fetch before reload the
// view to prevent flickering
this.data.fetchingCoordinates = addressPartnerMap.size > 0;
const fetch = async () => {
const partnersList = Array.from(addressPartnerMap.values());
for (let i = 0; i < partnersList.length; i++) {
const partners = partnersList[i];
try {
const coordinates = await this._fetchCoordinatesFromAddressOSM(partners[0]);
if (coordinates.length) {
for (const partner of partners) {
partner.partner_longitude = coordinates[0].lon;
partner.partner_latitude = coordinates[0].lat;
this.partnerToCache.push(partner);
}
}
} finally {
for (const partner of partners) {
partner.fetchingCoordinate = false;
}
this.data.fetchingCoordinates = (i < partnersList.length - 1);
this._notifyFetchedCoordinate();
await new Promise((resolve) => {
this.coordinateFetchingTimeoutHandle =
setTimeout(resolve, this.COORDINATE_FETCH_DELAY);
});
}
}
}
return fetch();
},
/**
* Fetches the partner which ids are contained in the the array partnerids
* if the token is set it uses the mapBoxApi to fetch address and route
* if not is uses the openstreetmap api to fetch the address.
*
* @private
* @param {number[]} partnerIds this array contains the ids from the partner that are linked to records
* @returns {Promise}
*/
_partnerFetching: async function (partnerIds) {
this.data.partners = partnerIds.length ? await this._fetchRecordsPartner(partnerIds) : [];
this._addPartnerToRecord();
if (this.data.mapBoxToken) {
return this._maxBoxAPI()
.then(() => {
this._writeCoordinatesUsers();
}).catch((err) => {
this._mapBoxErrorHandling(err);
this.data.mapBoxToken = '';
return this._openStreetMapAPI();
});
} else {
return this._openStreetMapAPI().then(() => {
this._writeCoordinatesUsers();
});
}
},
/**
* Writes partner_longitude and partner_latitude of the res.partner model.
*
* @private
* @return {Promise}
*/
_writeCoordinatesUsers: function () {
if (this.partnerToCache.length) {
this._rpc({
model: 'res.partner',
method: 'update_latitude_longitude',
context: self.context,
args: [this.partnerToCache]
});
this.partnerToCache = [];
}
},
});
return MapModel;
});

View File

@ -1,463 +0,0 @@
odoo.define('odex30_web_map.MapRenderer', function (require) {
"use strict";
const AbstractRendererOwl = require('web.AbstractRendererOwl');
const { useRef, useState } = owl.hooks;
const apiTilesRouteWithToken =
'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}';
const apiTilesRouteWithoutToken = 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png';
const colors = [
'#F06050',
'#6CC1ED',
'#F7CD1F',
'#814968',
'#30C381',
'#D6145F',
'#475577',
'#F4A460',
'#EB7E7F',
'#2C8397',
];
const mapTileAttribution = `
© <a href="https://www.mapbox.com/about/maps/">Mapbox</a>
© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>
<strong>
<a href="https://www.mapbox.com/map-feedback/" target="_blank">
Improve this map
</a>
</strong>`;
class MapRenderer extends AbstractRendererOwl {
/**
* @constructor
*/
constructor() {
super(...arguments);
this.leafletMap = null;
this.markers = [];
this.polylines = [];
this.mapContainerRef = useRef('mapContainer');
this.state = useState({
closedGroupIds: [],
});
}
/**
* Load marker icons.
*
* @override
*/
async willStart() {
const p = { method: 'GET' };
[this._pinCircleSVG, this._pinNoCircleSVG] = await Promise.all([
this.env.services.httpRequest('odex25_web_map/static/img/pin-circle.svg', p, 'text'),
this.env.services.httpRequest('odex25_web_map/static/img/pin-no-circle.svg', p, 'text'),
]);
return super.willStart(...arguments);
}
/**
* Initialize and mount map.
*
* @override
*/
mounted() {
this.leafletMap = L.map(this.mapContainerRef.el, {
maxBounds: [L.latLng(180, -180), L.latLng(-180, 180)],
});
L.tileLayer(this.apiTilesRoute, {
attribution: mapTileAttribution,
tileSize: 512,
zoomOffset: -1,
minZoom: 2,
maxZoom: 19,
id: 'mapbox/streets-v11',
accessToken: this.props.mapBoxToken,
}).addTo(this.leafletMap);
this._updateMap();
super.mounted(...arguments);
}
/**
* Update position in the map, markers and routes.
*
* @override
*/
patched() {
this._updateMap();
super.patched(...arguments);
}
/**
* Update group opened/closed state.
*
* @override
*/
willUpdateProps(nextProps) {
if (this.props.groupBy !== nextProps.groupBy) {
this.state.closedGroupIds = [];
}
return super.willUpdateProps(...arguments);
}
/**
* Remove map and the listeners on its markers and routes.
*
* @override
*/
willUnmount() {
for (const marker of this.markers) {
marker.off('click');
}
for (const polyline of this.polylines) {
polyline.off('click');
}
this.leafletMap.remove();
super.willUnmount(...arguments);
}
//----------------------------------------------------------------------
// Getters
//----------------------------------------------------------------------
/**
* Return the route to the tiles api with or without access token.
*
* @returns {string}
*/
get apiTilesRoute() {
return this.props.mapBoxToken ? apiTilesRouteWithToken : apiTilesRouteWithoutToken;
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* If there's located records, adds the corresponding marker on the map.
* Binds events to the created markers.
*
* @private
*/
_addMarkers() {
this._removeMarkers();
const markersInfo = {};
let records = this.props.records;
if (this.props.groupBy) {
records = Object.entries(this.props.recordGroups)
.filter(([key]) => !this.state.closedGroupIds.includes(key))
.flatMap(([, value]) => value.records);
}
for (const record of records) {
const partner = record.partner;
if (partner && partner.partner_latitude && partner.partner_longitude) {
const key = `${partner.partner_latitude}-${partner.partner_longitude}`;
if (key in markersInfo) {
markersInfo[key].record = record;
markersInfo[key].ids.push(record.id);
} else {
markersInfo[key] = { record: record, ids: [record.id] };
}
}
}
for (const markerInfo of Object.values(markersInfo)) {
const params = {
count: markerInfo.ids.length,
isMulti: markerInfo.ids.length > 1,
number: this.props.records.indexOf(markerInfo.record) + 1,
numbering: this.props.numbering,
pinSVG: (this.props.numbering ? this._pinNoCircleSVG : this._pinCircleSVG),
};
if (this.props.groupBy) {
const group = Object.entries(this.props.recordGroups)
.find(([, value]) => value.records.includes(markerInfo.record));
params.color = this._getGroupColor(group[0]);
}
// Icon creation
const iconInfo = {
className: 'o_map_marker',
html: this.env.qweb.renderToString('odex25_web_map.marker', params),
};
// Attach marker with icon and popup
const marker = L.marker([
markerInfo.record.partner.partner_latitude,
markerInfo.record.partner.partner_longitude
], { icon: L.divIcon(iconInfo) });
marker.addTo(this.leafletMap);
marker.on('click', () => {
this._createMarkerPopup(markerInfo);
});
this.markers.push(marker);
}
}
/**
* If there are computed routes, create polylines and add them to the map.
* each element of this.props.routeInfo[0].legs array represent the route between
* two waypoints thus each of these must be a polyline.
*
* @private
*/
_addRoutes() {
this._removeRoutes();
if (!this.props.mapBoxToken || !this.props.routeInfo.routes.length) {
return;
}
for (const leg of this.props.routeInfo.routes[0].legs) {
const latLngs = [];
for (const step of leg.steps) {
for (const coordinate of step.geometry.coordinates) {
latLngs.push(L.latLng(coordinate[1], coordinate[0]));
}
}
const polyline = L.polyline(latLngs, {
color: 'blue',
weight: 5,
opacity: 0.3,
}).addTo(this.leafletMap);
const polylines = this.polylines;
polyline.on('click', function () {
for (const polyline of polylines) {
polyline.setStyle({ color: 'blue', opacity: 0.3 });
}
this.setStyle({ color: 'darkblue', opacity: 1.0 });
});
this.polylines.push(polyline);
}
}
/**
* Create a popup for the specified marker.
*
* @private
* @param {Object} markerInfo
*/
_createMarkerPopup(markerInfo) {
const popupFields = this._getMarkerPopupFields(markerInfo);
const partner = markerInfo.record.partner;
const popupHtml = this.env.qweb.renderToString('odex25_web_map.markerPopup', {
fields: popupFields,
hasFormView: this.props.hasFormView,
url: `https://www.google.com/maps/dir/?api=1&destination=${partner.partner_latitude},${partner.partner_longitude}`,
});
const popup = L.popup({ offset: [0, -30] })
.setLatLng([partner.partner_latitude, partner.partner_longitude])
.setContent(popupHtml)
.openOn(this.leafletMap);
const openBtn = popup.getElement().querySelector('button.o_open');
if (openBtn) {
openBtn.onclick = () => {
this.trigger('open_clicked', { ids: markerInfo.ids });
};
}
return popup;
}
/**
* @private
* @param {Number} groupId
*/
_getGroupColor(groupId) {
const index = Object.keys(this.props.recordGroups).indexOf(groupId);
return colors[index % colors.length];
}
/**
* Creates an array of latLng objects if there is located records.
*
* @private
* @returns {latLngBounds|boolean} objects containing the coordinates that
* allows all the records to be shown on the map or returns false
* if the records does not contain any located record.
*/
_getLatLng() {
const tabLatLng = [];
for (const record of this.props.records) {
const partner = record.partner;
if (partner && partner.partner_latitude && partner.partner_longitude) {
tabLatLng.push(L.latLng(partner.partner_latitude, partner.partner_longitude));
}
}
if (!tabLatLng.length) {
return false;
}
return L.latLngBounds(tabLatLng);
}
/**
* Get the fields' name and value to display in the popup.
*
* @private
* @param {Object} markerInfo
* @returns {Object} value contains the value of the field and string
* contains the value of the xml's string attribute
*/
_getMarkerPopupFields(markerInfo) {
const record = markerInfo.record;
const fieldsView = [];
// Only display address in multi coordinates marker popup
if (markerInfo.ids.length > 1) {
if (!this.props.hideAddress) {
fieldsView.push({
value: record.partner.contact_address_complete,
string: this.env._t("Address"),
});
}
return fieldsView;
}
if (!this.props.hideName) {
fieldsView.push({
value: record.display_name,
string: this.env._t("Name"),
});
}
if (!this.props.hideAddress) {
fieldsView.push({
value: record.partner.contact_address_complete,
string: this.env._t("Address"),
});
}
for (const field of this.props.fieldNamesMarkerPopup) {
if (record[field.fieldName]) {
const fieldName = record[field.fieldName] instanceof Array ?
record[field.fieldName][1] :
record[field.fieldName];
fieldsView.push({
value: fieldName,
string: field.string,
});
}
}
return fieldsView;
}
/**
* Remove the markers from the map and empty the markers array.
*
* @private
*/
_removeMarkers() {
for (const marker of this.markers) {
this.leafletMap.removeLayer(marker);
}
this.markers = [];
}
/**
* Remove the routes from the map and empty the the polyline array.
*
* @private
*/
_removeRoutes() {
for (const polyline of this.polylines) {
this.leafletMap.removeLayer(polyline);
}
this.polylines = [];
}
/**
* Update position in the map, markers and routes.
*
* @private
*/
_updateMap() {
if (this.props.shouldUpdatePosition) {
const initialCoord = this._getLatLng();
if (initialCoord) {
this.leafletMap.flyToBounds(initialCoord, { animate: false });
} else {
this.leafletMap.fitWorld();
}
this.leafletMap.closePopup();
}
this._addMarkers();
this._addRoutes();
}
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* Center the map on a certain pin and open the popup linked to it.
*
* @private
* @param {Object} record
*/
_centerAndOpenPin(record) {
const popup = this._createMarkerPopup({
record: record,
ids: [record.id],
});
const px = this.leafletMap.project([record.partner.partner_latitude, record.partner.partner_longitude]);
const popupHeight = popup.getElement().offsetHeight;
px.y -= popupHeight / 2;
const latlng = this.leafletMap.unproject(px);
this.leafletMap.panTo(latlng, { animate: true });
}
/**
* @private
* @param {Number} id
*/
_toggleGroup(id) {
if (this.state.closedGroupIds.includes(id)) {
const index = this.state.closedGroupIds.indexOf(id);
this.state.closedGroupIds.splice(index, 1);
} else {
this.state.closedGroupIds.push(id);
}
}
}
MapRenderer.props = {
arch: Object,
count: Number,
defaultOrder: {
type: String,
optional: true,
},
fetchingCoordinates: Boolean,
fieldNamesMarkerPopup: {
type: Array,
element: {
type: Object,
shape: {
fieldName: String,
string: String,
},
},
},
groupBy: [String, Boolean],
hasFormView: Boolean,
hideAddress: Boolean,
hideName: Boolean,
isEmbedded: Boolean,
limit: Number,
mapBoxToken: { type: [Boolean, String], optional: 1 },
noContentHelp: {
type: String,
optional: true,
},
numbering: Boolean,
hideTitle: Boolean,
panelTitle: String,
offset: Number,
partners: { type: [Array, Boolean], optional: 1 },
recordGroups: Object,
records: Array,
routeInfo: {
type: Object,
optional: true,
},
routing: Boolean,
routingError: {
type: String,
optional: true,
},
shouldUpdatePosition: Boolean,
};
MapRenderer.template = 'odex25_web_map.MapRenderer';
return MapRenderer;
});

View File

@ -1,87 +0,0 @@
odoo.define('odex30_web_map.MapView', function (require) {
"use strict";
const MapModel = require('odex30_web_map.MapModel');
const MapController = require('odex30_web_map.MapController');
const MapRenderer = require('odex30_web_map.MapRenderer');
const AbstractView = require('web.AbstractView');
const RendererWrapper = require('web.RendererWrapper');
const utils = require('web.utils');
const viewRegistry = require('web.view_registry');
const _t = require('web.core')._t;
const MapView = AbstractView.extend({
jsLibs: [
'/odex30_web_map/static/lib/leaflet/leaflet.js',
],
config: _.extend({}, AbstractView.prototype.config, {
Model: MapModel,
Controller: MapController,
Renderer: MapRenderer,
}),
icon: 'fa-map-marker',
display_name: 'Map',
viewType: 'map',
mobile_friendly: true,
searchMenuTypes: ['filter', 'groupBy', 'favorite'],
init: function (viewInfo, params) {
this._super.apply(this, arguments);
const fieldNames = [];
const fieldNamesMarkerPopup = [];
this.loadParams.resPartnerField = this.arch.attrs.res_partner;
fieldNames.push(this.arch.attrs.res_partner);
fieldNames.push('display_name');
if (this.arch.attrs.default_order) {
this.loadParams.orderBy = [{ name: this.arch.attrs.default_order || 'display_name', asc: true }];
}
const routing = ["true", "True", "1"].includes(this.arch.attrs.routing);
this.loadParams.limit = this.arch.attrs.limit ?
parseInt(this.arch.attrs.limit, 10) :
params.limit || 80;
this.loadParams.routing = routing;
this.rendererParams.routing = routing;
this.rendererParams.numbering = this.arch.attrs.routing ? true : false;
this.rendererParams.defaultOrder = this.arch.attrs.default_order;
this.rendererParams.panelTitle = this.arch.attrs.panel_title || params.displayName || _t('Items');
this.rendererParams.hideTitle = utils.toBoolElse(this.arch.attrs.hide_title || '', false);
const hideName = utils.toBoolElse(this.arch.attrs.hide_name || '', false);
this.rendererParams.hideName = hideName;
if (!hideName) {
fieldNames.push('display_name');
}
this.rendererParams.hideAddress = utils.toBoolElse(this.arch.attrs.hide_address || '', false);
this.arch.children.forEach(node => {
if (node.tag === 'field') {
fieldNames.push(node.attrs.name);
fieldNamesMarkerPopup.push({ fieldName: node.attrs.name, string: node.attrs.string });
}
});
this.loadParams.fieldsInfo = this.fields;
this.loadParams.fieldNames = _.uniq(fieldNames);
this.rendererParams.fieldNamesMarkerPopup = fieldNamesMarkerPopup;
this.rendererParams.hasFormView = params.actionViews.some(view => view.type === "form");
this.controllerParams.actionName = params.action ? params.action.name : _t("Untitled");
},
/**
* @override
*/
getRenderer(parent, state) {
state = Object.assign({}, state, this.rendererParams);
return new RendererWrapper(null, this.config.Renderer, state);
},
});
viewRegistry.add('map', MapView);
return MapView;
});

View File

@ -0,0 +1,69 @@
import { unique } from "@web/core/utils/arrays";
import { exprToBoolean } from "@web/core/utils/strings";
import { visitXML } from "@web/core/utils/xml";
import { stringToOrderBy } from "@web/search/utils/order_by";
export class MapArchParser {
parse(arch) {
const archInfo = {
fieldNames: [],
fieldNamesMarkerPopup: [],
};
visitXML(arch, (node) => {
switch (node.tagName) {
case "map":
this.visitMap(node, archInfo);
break;
case "field":
this.visitField(node, archInfo);
break;
}
});
archInfo.fieldNames = unique(archInfo.fieldNames);
archInfo.fieldNamesMarkerPopup = unique(archInfo.fieldNamesMarkerPopup);
return archInfo;
}
visitMap(node, archInfo) {
archInfo.resPartnerField = node.getAttribute("res_partner");
archInfo.fieldNames.push(archInfo.resPartnerField);
if (node.hasAttribute("limit")) {
archInfo.limit = parseInt(node.getAttribute("limit"), 10);
}
if (node.hasAttribute("panel_title")) {
archInfo.panelTitle = node.getAttribute("panel_title");
}
if (node.hasAttribute("routing")) {
archInfo.routing = exprToBoolean(node.getAttribute("routing"));
}
if (node.hasAttribute("hide_title")) {
archInfo.hideTitle = exprToBoolean(node.getAttribute("hide_title"));
}
if (node.hasAttribute("hide_address")) {
archInfo.hideAddress = exprToBoolean(node.getAttribute("hide_address"));
}
if (node.hasAttribute("hide_name")) {
archInfo.hideName = exprToBoolean(node.getAttribute("hide_name"));
}
if (!archInfo.hideName) {
archInfo.fieldNames.push("display_name");
}
if (node.hasAttribute("default_order")) {
archInfo.defaultOrder = stringToOrderBy(node.getAttribute("default_order") || null);
}
if (node.hasAttribute("allow_resequence")) {
archInfo.allowResequence = exprToBoolean(node.getAttribute("allow_resequence"));
}
}
visitField(node, params) {
params.fieldNames.push(node.getAttribute("name"));
params.fieldNamesMarkerPopup.push({
fieldName: node.getAttribute("name"),
string: node.getAttribute("string"),
});
}
}

View File

@ -0,0 +1,102 @@
import { _t } from "@web/core/l10n/translation";
import { loadJS, loadCSS } from "@web/core/assets";
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 { usePager } from "@web/search/pager_hook";
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";
import { Component, onWillUnmount, onWillStart } from "@odoo/owl";
export class MapController extends Component {
static template = "web_map.MapView";
static components = {
Layout,
SearchBar,
CogMenu,
};
static props = {
...standardViewProps,
Model: Function,
modelParams: Object,
Renderer: Function,
buttonTemplate: String,
};
setup() {
this.action = useService("action");
/** @type {typeof MapModel} */
const Model = this.props.Model;
const model = useModelWithSampleData(Model, this.props.modelParams);
this.model = model;
onWillUnmount(() => {
this.model.stopFetchingCoordinates();
});
useSetupAction({
getLocalState: () => {
return this.model.metaData;
},
});
onWillStart(() =>
Promise.all([
loadJS("/web_map/static/lib/leaflet/leaflet.js"),
loadCSS("/web_map/static/lib/leaflet/leaflet.css"),
])
);
usePager(() => {
return {
offset: this.model.metaData.offset,
limit: this.model.metaData.limit,
total: this.model.data.count,
onUpdate: ({ offset, limit }) => this.model.load({ offset, limit }),
};
});
this.searchBarToggler = useSearchBarToggler();
}
/**
* @returns {any}
*/
get rendererProps() {
return {
model: this.model,
onMarkerClick: this.openRecords.bind(this),
};
}
/**
* Redirects to views when clicked on open button in marker popup.
*
* @param {number[]} ids
*/
openRecords(ids, newWindow) {
if (ids.length > 1) {
this.action.doAction(
{
type: "ir.actions.act_window",
name: this.env.config.getDisplayName() || _t("Untitled"),
views: [
[false, "list"],
[false, "form"],
],
res_model: this.props.resModel,
domain: [["id", "in", ids]],
},
{
newWindow,
}
);
} else {
this.action.switchView("form", { resId: ids[0] }, { newWindow });
}
}
}

View File

@ -0,0 +1,7 @@
.o_map_view {
height: 100%;
.o_content {
height: 100%;
}
}

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web_map.MapView">
<div t-att-class="props.className">
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''" display="props.display">
<t t-set-slot="control-panel-additional-actions">
<CogMenu/>
</t>
<t t-set-slot="layout-buttons">
<t t-call="{{ props.buttonTemplate }}"/>
</t>
<t t-set-slot="layout-actions">
<SearchBar toggler="searchBarToggler"/>
</t>
<t t-set-slot="control-panel-navigation-additional">
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
</t>
<t t-component="props.Renderer" t-props="rendererProps" />
</Layout>
</div>
</t>
<t t-name="web_map.MapView.Buttons" >
</t>
</templates>

View File

@ -0,0 +1,684 @@
import { _t } from "@web/core/l10n/translation";
import { Model } from "@web/model/model";
import { session } from "@web/session";
import { resequence } from "@web/model/relational_model/utils";
import { browser } from "@web/core/browser/browser";
import { formatDateTime, parseDate, parseDateTime } from "@web/core/l10n/dates";
import { KeepLast } from "@web/core/utils/concurrency";
import { orderByToString } from "@web/search/utils/order_by";
const DATE_GROUP_FORMATS = {
year: "yyyy",
quarter: "'Q'q yyyy",
month: "MMMM yyyy",
week: "'W'WW yyyy",
day: "dd MMM yyyy",
};
export class MapModel extends Model {
setup(params, { notification, http }) {
this.notification = notification;
this.http = http;
this.metaData = {
...params,
mapBoxToken: session.map_box_token || "",
};
this.data = {
count: 0,
fetchingCoordinates: false,
groupByKey: false,
isGrouped: false,
numberOfLocatedRecords: 0,
partners: {},
partnerToCache: [],
recordGroups: [],
records: [],
routes: [],
routingError: null,
shouldUpdatePosition: true,
useMapBoxAPI: !!this.metaData.mapBoxToken,
};
this.coordinateFetchingTimeoutHandle = undefined;
this.shouldFetchCoordinates = false;
this.keepLast = new KeepLast();
}
/**
* @param {any} params
* @returns {Promise<void>}
*/
async load(params) {
if (this.coordinateFetchingTimeoutHandle !== undefined) {
this.stopFetchingCoordinates();
}
const metaData = {
...this.metaData,
...params,
};
// remove the properties fields from the group by
metaData.groupBy = (metaData.groupBy || []).filter((groupBy) => {
// properties fields are in the form `[propert_field_name].[property_entry_key]`
const [fieldName] = groupBy.split(".");
const field = metaData.fields[fieldName];
return field?.type !== "properties";
});
this.data = await this._fetchData(metaData);
this.metaData = metaData;
this.notify();
}
/**
* Tells the model to stop fetching coordinates.
* In OSM mode, the model starts to fetch coordinates once every second after the
* model has loaded.
* This fetching has to be done every second if we don't want to be banned from OSM.
* There are typically two cases when we need to stop fetching:
* - when component is about to be unmounted because the request is bound to
* the component and it will crash if we do so.
* - when calling the `load` method as it will start fetching new coordinates.
*/
stopFetchingCoordinates() {
browser.clearTimeout(this.coordinateFetchingTimeoutHandle);
this.coordinateFetchingTimeoutHandle = undefined;
this.shouldFetchCoordinates = false;
}
get canResequence() {
return (
this.metaData.defaultOrder &&
!this.metaData.fields[this.metaData.defaultOrder[0].name].readonly &&
this.metaData.fields[this.metaData.defaultOrder[0].name].type === "integer" &&
this.metaData.allowResequence &&
!this.metaData.groupBy?.length
);
}
/**
* Resequence the records in `this.data.records` such that the record with the id
* `movedRecordId` is moved after the record with the id `targetRecordId`
* @param {Number} movedRecordId
* @param {Number} targetRecordId
*/
async resequence(movedId, targetId) {
const fieldName = this.metaData.defaultOrder[0].name;
const asc = this.metaData.defaultOrder[0].asc;
const resequenceProm = resequence({
records: this.data.records,
resModel: this.metaData.resModel,
movedId,
targetId,
fieldName,
asc,
context: this.metaData.context,
orm: this.orm,
});
// the resequence method modifies this.data.records before the resequence backend call
// we need to notify after the synchronous record change
this.notify();
const resequencedRecords = await resequenceProm;
if (resequencedRecords.length) {
for (const resequencedRecord of resequencedRecords) {
const record = this.data.records.find((r) => r.id === resequencedRecord.id);
record[fieldName] = resequencedRecord[fieldName];
}
await this._updatePartnerCoordinate(this.metaData, this.data);
this.notify();
}
}
//----------------------------------------------------------------------
// Protected
//----------------------------------------------------------------------
/**
* Adds the corresponding partner to a record.
*
* @protected
*/
_addPartnerToRecord(metaData, data) {
for (const record of data.records) {
if (metaData.resModel === "res.partner" && metaData.resPartnerField === "id") {
record.partner = data.partners[record.id];
} else {
record.partner = data.partners[record[metaData.resPartnerField].id];
}
data.numberOfLocatedRecords++;
}
}
/**
* The partner's coordinates should be between -90 <= latitude <= 90 and -180 <= longitude <= 180.
*
* @protected
* @param {Object} partner
* @param {number} partner.partner_latitude latitude of the partner
* @param {number} partner.partner_longitude longitude of the partner
* @returns {boolean}
*/
_checkCoordinatesValidity(partner) {
if (
partner.partner_latitude &&
partner.partner_longitude &&
partner.partner_latitude >= -90 &&
partner.partner_latitude <= 90 &&
partner.partner_longitude >= -180 &&
partner.partner_longitude <= 180
) {
return true;
}
return false;
}
/**
* Handles the case of an empty map.
* Handles the case where the model is res_partner.
* Fetches the records according to the model given in the arch.
* If the records has no partner_id field it is sliced from the array.
*
* @protected
* @params {any} metaData
* @return {Promise<any>}
*/
async _fetchData(metaData) {
const data = {
count: 0,
fetchingCoordinates: false,
groupByKey: metaData.groupBy.length ? metaData.groupBy[0] : false,
isGrouped: metaData.groupBy.length > 0,
numberOfLocatedRecords: 0,
partners: {},
partnerToCache: [],
recordGroups: [],
records: [],
routes: [],
routingError: null,
shouldUpdatePosition: true,
useMapBoxAPI: !!metaData.mapBoxToken,
};
//case of empty map
if (!metaData.resPartnerField) {
data.recordGroups = [];
data.records = [];
data.routes = [];
return this.keepLast.add(Promise.resolve(data));
}
const results = await this.keepLast.add(this._fetchRecordData(metaData, data));
const datetimeFields = metaData.fieldNames.filter(
(name) => metaData.fields[name].type == "datetime"
);
for (const record of results.records) {
// convert date fields from UTC to local timezone
for (const field of datetimeFields) {
if (record[field]) {
const dateUTC = luxon.DateTime.fromFormat(
record[field],
"yyyy-MM-dd HH:mm:ss",
{ zone: "UTC" }
);
record[field] = formatDateTime(dateUTC, { format: "yyyy-MM-dd HH:mm:ss" });
}
}
}
data.records = results.records;
data.count = results.length;
if (data.isGrouped) {
data.recordGroups = await this._getRecordGroups(metaData, data);
} else {
data.recordGroups = [];
}
if (metaData.resModel === "res.partner" && metaData.resPartnerField === "id") {
for (const record of data.records) {
if (!data.partners[record.id]) {
data.partners[record.id] = { ...record };
}
}
} else {
for (const record of data.records) {
const partner = record[metaData.resPartnerField];
if (partner && !data.partners[partner.id]) {
data.partners[partner.id] = partner;
}
}
}
this._addPartnerToRecord(metaData, data);
await this._updatePartnerCoordinate(metaData, data);
return data;
}
_getRecordSpecification(metaData, data) {
const fieldNames = data.groupByKey
? metaData.fieldNames.concat(data.groupByKey.split(":")[0])
: metaData.fieldNames;
const specification = {};
const fieldsToAdd = {
contact_address_complete: {},
partner_latitude: {},
partner_longitude: {},
};
for (const fieldName of fieldNames) {
specification[fieldName] = {};
if (fieldName === "id" && metaData.resPartnerField === "id") {
Object.assign(specification, fieldsToAdd);
} else if (
["many2one", "one2many", "many2many"].includes(metaData.fields[fieldName].type)
) {
specification[fieldName].fields = { display_name: {} };
if (fieldName === metaData.resPartnerField) {
Object.assign(specification[fieldName].fields, fieldsToAdd);
}
}
}
return specification;
}
/**
* Fetch the records for a given model.
*
* @protected
* @returns {Promise}
*/
_fetchRecordData(metaData, data) {
const specification = this._getRecordSpecification(metaData, data);
return this.orm.webSearchRead(metaData.resModel, metaData.domain, {
specification,
limit: metaData.limit,
offset: metaData.offset,
order: orderByToString(metaData.defaultOrder || []),
context: metaData.context,
});
}
/**
* This function convert the addresses to coordinates using the mapbox API.
*
* @protected
* @param {Object} record this object contains the record fetched from the database.
* @returns {Promise} result.query contains the query the the api received
* result.features contains results in descendant order of relevance
*/
_fetchCoordinatesFromAddressMB(metaData, data, record) {
const address = encodeURIComponent(record.contact_address_complete);
const token = metaData.mapBoxToken;
const encodedUrl = `https://api.mapbox.com/geocoding/v5/mapbox.places/${address}.json?access_token=${token}&cachebuster=1552314159970&autocomplete=true`;
return this.http.get(encodedUrl);
}
/**
* This function convert the addresses to coordinates using the openStreetMap api.
*
* @protected
* @param {Object} record this object contains the record fetched from the database.
* @returns {Promise} result is an array that contains the result in descendant order of relevance
* result[i].lat is the latitude of the converted address
* result[i].lon is the longitude of the converted address
* result[i].importance is a number that the relevance of the result the closer the number is to one the best it is.
*/
_fetchCoordinatesFromAddressOSM(metaData, data, record) {
const address = encodeURIComponent(record.contact_address_complete.replace("/", " "));
const encodedUrl = `https://nominatim.openstreetmap.org/search?q=${address}&format=jsonv2`;
return this.http.get(encodedUrl);
}
/**
* Fetch the route from the mapbox api.
*
* @protected
* @returns {Promise}
* results.geometry.legs[i] contains one leg (i.e: the trip between two markers).
* results.geometry.legs[i].steps contains the sets of coordinates to follow to reach a point from an other.
* results.geometry.legs[i].distance: the distance in meters to reach the destination
* results.geometry.legs[i].duration the duration of the leg
* results.geometry.coordinates contains the sets of coordinates to go from the first to the last marker without the notion of waypoint
*/
_fetchRoute(metaData, data) {
const coordinatesParam = data.records
.filter(
(record) =>
record.partner &&
record.partner.partner_latitude &&
record.partner.partner_longitude
)
.map(({ partner }) => `${partner.partner_longitude},${partner.partner_latitude}`);
const address = encodeURIComponent(coordinatesParam.join(";"));
const token = metaData.mapBoxToken;
const encodedUrl = `https://api.mapbox.com/directions/v5/mapbox/driving/${address}?access_token=${token}&steps=true&geometries=geojson`;
return this.http.get(encodedUrl);
}
/**
* Converts a MapBox error message into a custom translatable one.
*
* @protected
* @param {string} message
*/
_getErrorMessage(message) {
const ERROR_MESSAGES = {
"Too many coordinates; maximum number of coordinates is 25": _t(
"Too many routing points (maximum 25)"
),
"Route exceeds maximum distance limitation": _t(
"Some routing points are too far apart"
),
"Too Many Requests": _t("Too many requests, try again in a few minutes"),
};
return ERROR_MESSAGES[message];
}
/**
* @protected
* @returns {Object} the fetched records grouped by the groupBy field.
*/
async _getRecordGroups(metaData, data) {
const [fieldName, subGroup] = data.groupByKey.split(":");
const fieldType = metaData.fields[fieldName].type;
const unsetName = metaData.fields[fieldName].falsy_value_label || _t("None");
const groups = {};
function addToGroup(id, name, record) {
if (!groups[id]) {
groups[id] = {
name,
records: [],
};
}
groups[id].records.push(record);
}
for (const record of data.records) {
const value = record[fieldName];
let id, name;
if (["one2many", "many2many"].includes(fieldType)) {
if (value.length) {
for (const r of value) {
addToGroup(r.id, r.display_name, record);
}
} else {
id = name = unsetName;
addToGroup(id, name, record);
}
} else {
if (["date", "datetime"].includes(fieldType) && value) {
const date = fieldType === "date" ? parseDate(value) : parseDateTime(value);
id = name = date.toFormat(DATE_GROUP_FORMATS[subGroup || "month"]);
} else if (fieldType === "boolean") {
id = name = value ? _t("Yes") : _t("No");
} else if (fieldType === "integer") {
id = name = value || "0";
} else if (fieldType === "selection") {
const selected = metaData.fields[fieldName].selection.find(
(o) => o[0] === value
);
id = name = selected ? selected[1] : value;
} else if (fieldType === "many2one" && value) {
id = value.id;
name = value.display_name;
} else {
id = value;
name = value;
}
if (!id && !name) {
id = name = unsetName;
}
addToGroup(id, name, record);
}
}
return groups;
}
/**
* Handles the case where the selected api is MapBox.
* Iterates on all the partners and fetches their coordinates when they're not set.
*
* @protected
* @return {Promise} if there's more than 2 located records and the routing option is activated it returns a promise that fetches the route
* resultResult is an object that contains the computed route
* or if either of these conditions are not respected it returns an empty promise
*/
_maxBoxAPI(metaData, data) {
const promises = [];
for (const partner of Object.values(data.partners)) {
if (
partner.contact_address_complete &&
(!partner.partner_latitude || !partner.partner_longitude)
) {
promises.push(
this._fetchCoordinatesFromAddressMB(metaData, data, partner).then(
(coordinates) => {
if (coordinates.features.length) {
partner.partner_longitude = parseFloat(
coordinates.features[0].geometry.coordinates[0]
);
partner.partner_latitude = parseFloat(
coordinates.features[0].geometry.coordinates[1]
);
data.partnerToCache.push(partner);
}
}
)
);
} else if (!this._checkCoordinatesValidity(partner)) {
partner.partner_latitude = undefined;
partner.partner_longitude = undefined;
}
}
return Promise.all(promises).then(() => {
data.routes = [];
if (data.numberOfLocatedRecords > 1 && metaData.routing && !data.groupByKey) {
return this._fetchRoute(metaData, data).then((routeResult) => {
if (routeResult.routes) {
data.routes = routeResult.routes;
} else {
data.routingError = this._getErrorMessage(routeResult.message);
}
});
} else {
return Promise.resolve();
}
});
}
/**
* Handles the displaying of error message according to the error.
*
* @protected
* @param {Object} err contains the error returned by the requests
* @param {number} err.status contains the status_code of the failed http request
*/
_mapBoxErrorHandling(metaData, data, err) {
switch (err.status) {
case 401:
this.notification.add(
_t(
"The view has switched to another provider but functionalities will be limited"
),
{
title: _t("Token invalid"),
type: "danger",
}
);
break;
case 403:
this.notification.add(
_t(
"The view has switched to another provider but functionalities will be limited"
),
{
title: _t("Unauthorized connection"),
type: "danger",
}
);
break;
case 422: // Max. addresses reached
case 429: // Max. requests reached
data.routingError = this._getErrorMessage(err.responseJSON.message);
break;
case 500:
this.notification.add(
_t(
"The view has switched to another provider but functionalities will be limited"
),
{
title: _t("MapBox servers unreachable"),
type: "danger",
}
);
}
}
/**
* Notifies the fetched coordinates to server and controller.
*
* @protected
*/
_notifyFetchedCoordinate(metaData, data) {
this._writeCoordinatesUsers(metaData, data);
data.shouldUpdatePosition = false;
this.notify();
}
/**
* Calls (without awaiting) _openStreetMapAPIAsync with a delay of 1000ms
* to not get banned from openstreetmap's server.
*
* Tests should patch this function to wait for coords to be fetched.
*
* @see _openStreetMapAPIAsync
* @protected
* @return {Promise}
*/
_openStreetMapAPI(metaData, data) {
this._openStreetMapAPIAsync(metaData, data);
return Promise.resolve();
}
/**
* Handles the case where the selected api is open street map.
* Iterates on all the partners and fetches their coordinates when they're not set.
*
* @protected
* @returns {Promise}
*/
_openStreetMapAPIAsync(metaData, data) {
// Group partners by address to reduce address list
const addressPartnerMap = new Map();
for (const partner of Object.values(data.partners)) {
if (
partner.contact_address_complete &&
(!partner.partner_latitude || !partner.partner_longitude)
) {
if (!addressPartnerMap.has(partner.contact_address_complete)) {
addressPartnerMap.set(partner.contact_address_complete, []);
}
addressPartnerMap.get(partner.contact_address_complete).push(partner);
partner.fetchingCoordinate = true;
} else if (!this._checkCoordinatesValidity(partner)) {
partner.partner_latitude = undefined;
partner.partner_longitude = undefined;
}
}
// `fetchingCoordinates` is used to display the "fetching banner"
// We need to check if there are coordinates to fetch before reload the
// view to prevent flickering
data.fetchingCoordinates = addressPartnerMap.size > 0;
this.shouldFetchCoordinates = true;
const fetch = async () => {
const partnersList = Array.from(addressPartnerMap.values());
for (let i = 0; i < partnersList.length; i++) {
await new Promise((resolve) => {
this.coordinateFetchingTimeoutHandle = browser.setTimeout(
resolve,
this.constructor.COORDINATE_FETCH_DELAY
);
});
if (!this.shouldFetchCoordinates) {
return;
}
const partners = partnersList[i];
try {
const coordinates = await this._fetchCoordinatesFromAddressOSM(
metaData,
data,
partners[0]
);
if (!this.shouldFetchCoordinates) {
return;
}
if (coordinates.length) {
for (const partner of partners) {
partner.partner_longitude = parseFloat(coordinates[0].lon);
partner.partner_latitude = parseFloat(coordinates[0].lat);
data.partnerToCache.push(partner);
}
}
for (const partner of partners) {
partner.fetchingCoordinate = false;
}
data.fetchingCoordinates = i < partnersList.length - 1;
this._notifyFetchedCoordinate(metaData, data);
} catch {
for (const partner of Object.values(data.partners)) {
partner.fetchingCoordinate = false;
}
data.fetchingCoordinates = false;
this.shouldFetchCoordinates = false;
this.notification.add(
_t("OpenStreetMap's request limit exceeded, try again later."),
{ type: "danger" }
);
this.notify();
}
}
};
return fetch();
}
/**
* if the token is set it uses the mapBoxApi to fetch address and route
* if not is uses the openstreetmap api to fetch the address.
*
* @protected
* @returns {Promise}
*/
async _updatePartnerCoordinate(metaData, data) {
if (data.useMapBoxAPI) {
return this.keepLast
.add(this._maxBoxAPI(metaData, data))
.then(() => {
this._writeCoordinatesUsers(metaData, data);
})
.catch((err) => {
this._mapBoxErrorHandling(metaData, data, err);
data.useMapBoxAPI = false;
return this._openStreetMapAPI(metaData, data);
});
} else {
return this._openStreetMapAPI(metaData, data).then(() => {
this._writeCoordinatesUsers(metaData, data);
});
}
}
/**
* Writes partner_longitude and partner_latitude of the res.partner model.
*
* @protected
* @return {Promise}
*/
async _writeCoordinatesUsers(metaData, data) {
const partners = data.partnerToCache;
data.partnerToCache = [];
if (partners.length) {
await this.orm.call("res.partner", "update_latitude_longitude", [partners], {
context: metaData.context,
});
}
}
}
MapModel.services = ["notification", "http"];
MapModel.COORDINATE_FETCH_DELAY = 1000;

View File

@ -0,0 +1,499 @@
import { _t } from "@web/core/l10n/translation";
/*global L*/
import { renderToString } from "@web/core/utils/render";
import { delay } from "@web/core/utils/concurrency";
import { isMacOS } from "@web/core/browser/feature_detection";
import {
Component,
onWillUnmount,
onWillUpdateProps,
useEffect,
useRef,
useState,
} from "@odoo/owl";
import { useSortable } from "@web/core/utils/sortable_owl";
const apiTilesRouteWithToken =
"https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}";
const apiTilesRouteWithoutToken = "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png";
const colors = [
"#F06050",
"#6CC1ED",
"#F7CD1F",
"#814968",
"#30C381",
"#D6145F",
"#475577",
"#F4A460",
"#EB7E7F",
"#2C8397",
];
const mapTileAttribution = `
© <a href="https://www.mapbox.com/about/maps/">Mapbox</a>
© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>
<strong>
<a href="https://www.mapbox.com/map-feedback/" target="_blank">
Improve this map
</a>
</strong>`;
export class MapRenderer extends Component {
static template = "web_map.MapRenderer";
static markerPopupTemplate = "web_map.markerPopup";
static props = {
model: Object,
onMarkerClick: Function,
};
static subTemplates = {
PinListContainer: "web_map.MapRenderer.PinListContainer",
PinList: "web_map.MapRenderer.PinList",
PinListItems: "web_map.MapRenderer.PinListItems",
RountingUnavailable: "web_map.MapRenderer.RountingUnavailable",
FetchingCoordinates: "web_map.MapRenderer.FetchingCoordinates",
NoMapToken: "web_map.MapRenderer.NoMapToken",
};
get subTemplates() {
return this.constructor.subTemplates;
}
setup() {
this.leafletMap = null;
this.markers = [];
this.polylines = [];
this.mapContainerRef = useRef("mapContainer");
this.state = useState({
closedGroupIds: [],
expendedPinList: false,
});
this.nextId = 1;
useEffect(
() => {
this.leafletMap = L.map(this.mapContainerRef.el, {
maxBounds: [L.latLng(180, -180), L.latLng(-180, 180)],
});
this.leafletMap.attributionControl.setPrefix(
'<a href="https://leafletjs.com" title="A JavaScript library for interactive maps">Leaflet</a>'
);
L.tileLayer(this.apiTilesRoute, {
attribution: mapTileAttribution,
tileSize: 512,
zoomOffset: -1,
minZoom: 2,
maxZoom: 19,
id: "mapbox/streets-v11",
accessToken: this.props.model.metaData.mapBoxToken,
}).addTo(this.leafletMap);
},
() => []
);
useEffect(() => {
this.updateMap();
});
this.pinListRef = useRef("pinList");
useSortable({
enable: () => this.props.model.canResequence,
ref: this.pinListRef,
elements: ".o-map-renderer--pin-located",
handle: ".o_row_handle",
onDrop: async (params) => {
const rowId = parseInt(params.element.dataset.id);
const previousRowId = parseInt(params.previous?.dataset?.id) || null;
await this.props.model.resequence(rowId, previousRowId);
},
});
onWillUpdateProps(this.onWillUpdateProps);
onWillUnmount(this.onWillUnmount);
}
/**
* Update group opened/closed state.
*/
async onWillUpdateProps(nextProps) {
if (this.props.model.data.groupByKey !== nextProps.model.data.groupByKey) {
this.state.closedGroupIds = [];
}
}
/**
* Remove map and the listeners on its markers and routes.
*/
onWillUnmount() {
this.removeMarkers();
this.removeRoutes();
if (this.leafletMap) {
this.leafletMap.remove();
}
}
/**
* Return the route to the tiles api with or without access token.
*
* @returns {string}
*/
get apiTilesRoute() {
return this.props.model.data.useMapBoxAPI
? apiTilesRouteWithToken
: apiTilesRouteWithoutToken;
}
/**
* If there's located records, adds the corresponding marker on the map.
* Binds events to the created markers.
*/
addMarkers() {
this.removeMarkers();
const markersInfo = {};
let records = this.props.model.data.records;
if (this.props.model.data.isGrouped) {
records = Object.entries(this.props.model.data.recordGroups)
.filter(([key]) => !this.state.closedGroupIds.includes(key))
.flatMap(([groupId, value]) => value.records.map((elem) => ({ ...elem, groupId })));
}
const pinInSamePlace = {};
for (const record of records) {
const partner = record.partner;
if (partner && partner.partner_latitude && partner.partner_longitude) {
const lat_long = `${partner.partner_latitude}-${partner.partner_longitude}`;
const group = this.props.model.data.recordGroups ? `-${record.groupId}` : "";
const key = `${lat_long}${group}`;
if (key in markersInfo) {
markersInfo[key].record = record;
markersInfo[key].relatedRecords.push(record);
markersInfo[key].ids.push(record.id);
} else {
pinInSamePlace[lat_long] = ++pinInSamePlace[lat_long] || 0;
markersInfo[key] = {
record: record,
ids: [record.id],
pinInSamePlace: pinInSamePlace[lat_long],
relatedRecords: [],
};
}
}
}
for (const markerInfo of Object.values(markersInfo)) {
const params = {
count: markerInfo.ids.length,
isMulti: markerInfo.ids.length > 1,
number: this.props.model.data.records.indexOf(markerInfo.record) + 1,
numbering: this.props.model.metaData.numbering,
};
if (this.props.model.data.isGrouped) {
const groupId = markerInfo.record.groupId;
params.color = this.getGroupColor(groupId);
params.number =
this.props.model.data.recordGroups[groupId].records.findIndex(
(record) => record.id === markerInfo.record.id
) + 1;
}
// Icon creation
const iconInfo = {
className: "o-map-renderer--marker",
html: renderToString("web_map.marker", params),
};
const offset = markerInfo.pinInSamePlace * 0.000025;
// Attach marker with icon and popup
const marker = L.marker(
[
markerInfo.record.partner.partner_latitude + offset,
markerInfo.record.partner.partner_longitude - offset,
],
{ icon: L.divIcon(iconInfo) }
);
marker.addTo(this.leafletMap);
marker.on("click", () => {
this.createMarkerPopup(markerInfo, offset);
});
this.markers.push(marker);
}
}
/**
* If there are computed routes, create polylines and add them to the map.
* each element of this.props.routeInfo[0].legs array represent the route between
* two waypoints thus each of these must be a polyline.
*/
addRoutes() {
this.removeRoutes();
if (!this.props.model.data.useMapBoxAPI || !this.props.model.data.routes.length) {
return;
}
for (const leg of this.props.model.data.routes[0].legs) {
const latLngs = [];
for (const step of leg.steps) {
for (const coordinate of step.geometry.coordinates) {
latLngs.push(L.latLng(coordinate[1], coordinate[0]));
}
}
const polyline = L.polyline(latLngs, {
color: "blue",
weight: 5,
opacity: 0.3,
}).addTo(this.leafletMap);
const polylines = this.polylines;
polyline.on("click", function () {
for (const polyline of polylines) {
polyline.setStyle({ color: "blue", opacity: 0.3 });
}
this.setStyle({ color: "darkblue", opacity: 1.0 });
});
this.polylines.push(polyline);
}
}
/**
* Create a popup for the specified marker.
*
* @param {Object} markerInfo
* @param {Number} latLongOffset
*/
createMarkerPopup(markerInfo, latLongOffset = 0) {
const popupData = this.getMarkerPopupData(markerInfo);
const partner = markerInfo.record.partner;
const encodedAddress = encodeURIComponent(partner.contact_address_complete);
const popupHtml = renderToString(this.constructor.markerPopupTemplate, {
data: popupData,
hasFormView: this.props.model.metaData.hasFormView,
url: `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}`,
});
const popup = L.popup({ offset: [0, -30] })
.setLatLng([
partner.partner_latitude + latLongOffset,
partner.partner_longitude - latLongOffset,
])
.setContent(popupHtml)
.openOn(this.leafletMap);
const openBtn = popup
.getElement()
.querySelector("button.o-map-renderer--popup-buttons-open");
if (openBtn) {
openBtn.onclick = (ev) => {
if (ev.button === 0 || ev.button === 1) {
const ctrlKey = isMacOS() ? ev.metaKey : ev.ctrlKey;
const isMiddleClick = (ctrlKey && ev.button === 0) || ev.button === 1;
this.props.onMarkerClick(markerInfo.ids, isMiddleClick);
}
};
openBtn.onauxclick = (ev) => {
if (ev.button === 0 || ev.button === 1) {
const ctrlKey = isMacOS() ? ev.metaKey : ev.ctrlKey;
const isMiddleClick = (ctrlKey && ev.button === 0) || ev.button === 1;
this.props.onMarkerClick(markerInfo.ids, isMiddleClick);
}
};
}
return popup;
}
/**
* @param {Number} groupId
*/
getGroupColor(groupId) {
const index = Object.keys(this.props.model.data.recordGroups).indexOf(groupId);
return colors[index % colors.length];
}
/**
* Creates an array of latLng objects if there is located records.
*
* @returns {latLngBounds|boolean} objects containing the coordinates that
* allows all the records to be shown on the map or returns false
* if the records does not contain any located record.
*/
getLatLng() {
const tabLatLng = [];
for (const record of this.props.model.data.records) {
const partner = record.partner;
if (partner && partner.partner_latitude && partner.partner_longitude) {
tabLatLng.push(L.latLng(partner.partner_latitude, partner.partner_longitude));
}
}
if (!tabLatLng.length) {
return false;
}
return L.latLngBounds(tabLatLng);
}
getMarkerPopupRecordData(record) {
const fieldsView = [];
if (!this.props.model.metaData.hideName) {
fieldsView.push({
id: this.nextId++,
value: record.display_name,
string: _t("Name"),
});
}
if (!this.props.model.metaData.hideAddress) {
fieldsView.push({
id: this.nextId++,
value: record.partner.contact_address_complete,
string: _t("Address"),
});
}
const fields = this.props.model.metaData.fields;
for (const field of this.props.model.metaData.fieldNamesMarkerPopup) {
if (record[field.fieldName]) {
let value = record[field.fieldName];
if (fields[field.fieldName].type === "many2one") {
value = record[field.fieldName].display_name;
} else if (["one2many", "many2many"].includes(fields[field.fieldName].type)) {
value = record[field.fieldName]
? record[field.fieldName].map((r) => r.display_name).join(", ")
: "";
}
fieldsView.push({
id: this.nextId++,
value,
string: field.string,
});
}
}
return fieldsView;
}
/**
* Get the fields' name and value to display in the popup.
*
* @param {Object} markerInfo
* @returns {Object} value contains the value of the field and string
* contains the value of the xml's string attribute
*/
getMarkerPopupData(markerInfo) {
// Only display address in multi coordinates marker popup
const record = markerInfo.record;
if (markerInfo.ids.length > 1) {
const fieldsView = [];
if (!this.props.model.metaData.hideAddress) {
fieldsView.push({
id: this.nextId++,
value: record.partner.contact_address_complete,
string: _t("Address"),
});
}
return fieldsView;
}
const fieldsView = this.getMarkerPopupRecordData(record);
return fieldsView;
}
/**
* @returns {string}
*/
get googleMapUrl() {
let url = "https://www.google.com/maps/dir/?api=1";
if (this.props.model.data.records.length) {
const allAddresses = this.props.model.data.records.filter(
({ partner }) => partner && partner.contact_address_complete
);
const uniqueAddresses = allAddresses.reduce((addrs, { partner }) => {
const addr = encodeURIComponent(partner.contact_address_complete);
if (!addrs.includes(addr)) {
addrs.push(addr);
}
return addrs;
}, []);
if (uniqueAddresses.length && this.props.model.metaData.routing) {
// When routing is enabled, make last record the destination
url += `&destination=${uniqueAddresses.pop()}`;
}
if (uniqueAddresses.length) {
url += `&waypoints=${uniqueAddresses.join("|")}`;
}
}
return url;
}
/**
* Remove the markers from the map and empty the markers array.
*/
removeMarkers() {
for (const marker of this.markers) {
marker.off("click");
this.leafletMap.removeLayer(marker);
}
this.markers = [];
}
/**
* Remove the routes from the map and empty the the polyline array.
*/
removeRoutes() {
for (const polyline of this.polylines) {
polyline.off("click");
this.leafletMap.removeLayer(polyline);
}
this.polylines = [];
}
/**
* Update position in the map, markers and routes.
*/
updateMap() {
if (this.props.model.data.shouldUpdatePosition) {
const initialCoord = this.getLatLng();
if (initialCoord) {
this.leafletMap.flyToBounds(initialCoord, { animate: false });
} else {
this.leafletMap.fitWorld();
}
this.leafletMap.closePopup();
}
this.addMarkers();
this.addRoutes();
}
/**
* Center the map on a certain pin and open the popup linked to it.
*
* @param {Object} record
*/
async centerAndOpenPin(record) {
this.state.expendedPinList = false;
// wait the next owl render to avoid marker popup create => destroy
await delay(0);
const popup = this.createMarkerPopup({
record: record,
ids: [record.id],
relatedRecords: [],
});
const px = this.leafletMap.project([
record.partner.partner_latitude,
record.partner.partner_longitude,
]);
const popupHeight = popup.getElement().offsetHeight;
px.y -= popupHeight / 2;
const latlng = this.leafletMap.unproject(px);
this.leafletMap.panTo(latlng, { animate: true });
}
/**
* @param {Number} id
*/
toggleGroup(id) {
if (this.state.closedGroupIds.includes(id)) {
const index = this.state.closedGroupIds.indexOf(id);
this.state.closedGroupIds.splice(index, 1);
} else {
this.state.closedGroupIds.push(id);
}
}
togglePinList() {
this.state.expendedPinList = !this.state.expendedPinList;
}
get expendedPinList() {
return this.env.isSmall ? this.state.expendedPinList : false;
}
get canDisplayPinList() {
return !this.env.isSmall || this.expendedPinList;
}
}

View File

@ -0,0 +1,156 @@
$map-table-row-padding: 25px;
$map-table-line-padding: 20px;
$map-number-color: white;
$map-number-font-size: 19px;
$map-marker-color: #2c8397;
.o-map-renderer {
height: 100%;
@include media-breakpoint-down(md) {
height: calc(100% - #{$o-navbar-height});
}
&--popup-table {
&-content-name {
line-height: $map-table-row-padding;
}
&-space {
padding-left: $map-table-line-padding;
}
}
&--popup-buttons-divider {
width: 5px;
}
&--pin-list {
&-container {
padding: 8px $o-horizontal-padding !important;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
@include media-breakpoint-up(md) {
border-right: $border-width solid $border-color;
}
}
&-group {
&-header {
color: $headings-color;
}
svg {
height: 1.25rem;
margin-right: 0.5rem;
}
> i {
width: 0.5rem;
}
> .o-map-renderer--pin-list-details {
margin-left: 2rem;
}
}
&-header {
color: $headings-color;
}
&-details {
a {
color: $gray-900;
}
> li {
list-style-position: inside;
&.o-map-renderer--pin-located:hover {
text-decoration: none;
background-color: $o-gray-100;
}
&:not(.o-map-renderer--pin-located) {
cursor: not-allowed;
}
}
}
}
.o_row_handle {
cursor: grab;
}
ul.o-map-renderer--pin-list-details {
list-style: none;
cursor: default;
padding-bottom: 2px;
}
&--marker {
//the height and width correspond to the height and width of the custom icon png file
height: 40px !important;
width: 30px !important;
margin-top: -40px !important;
margin-left: -15px !important;
color: $map-marker-color;
&-badge {
@include o-position-absolute($top: -8px, $right: -10px);
font-size: 12px;
}
&-number {
top: -40px;
color: $map-number-color;
font-size: $map-number-font-size;
margin-top: 10%;
}
}
&--alert {
z-index: 401; // leaflet have 400
}
.leaflet-fade-anim .leaflet-popup {
// used to disabled opening animation for the popups.
transition: none;
.leaflet-popup-content-wrapper {
border-radius: 10px;
.leaflet-popup-content {
margin: 24px 20px 20px 20px;
}
}
.leaflet-popup-close-button {
color: #666666;
}
}
.leaflet-top,
.leaflet-bottom {
// used to not overlap dropdown menu.
z-index: $zindex-dropdown - 1;
}
}
.o-sm-pin-list-container {
@include media-breakpoint-down(md) {
height: $o-navbar-height;
}
}
/* Fix opw-2124233, preventing rtlcss to reverse the map position */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
left: 0 #{"/*rtl:ignore*/"};
right: auto #{"/*rtl:ignore*/"};
}

View File

@ -0,0 +1,230 @@
<?xml version ="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_map.MapRenderer">
<t t-if="env.isSmall">
<div class="row g-0 o-sm-pin-list-container" t-att-class="{ 'h-100': expendedPinList }">
<t t-call="{{ subTemplates.PinListContainer }}"/>
</div>
</t>
<div class="o-map-renderer row g-0" t-att-class="{ 'd-none': expendedPinList }">
<t t-if="!env.isSmall">
<t t-call="{{ subTemplates.PinListContainer }}"/>
</t>
<div class="h-100 col col-md-10">
<t t-if="props.model.data.routingError">
<t t-call="{{ subTemplates.RountingUnavailable }}"/>
</t>
<t t-elif="props.model.data.fetchingCoordinates">
<t t-call="{{ subTemplates.FetchingCoordinates }}"/>
</t>
<t t-elif="props.model.metaData.routing and !props.model.data.useMapBoxAPI">
<t t-call="{{ subTemplates.NoMapToken }}"/>
</t>
<div class="o-map-renderer--container h-100" t-ref="mapContainer"/>
</div>
</div>
</t>
<t t-name="web_map.MapRenderer.FetchingCoordinates">
<div class="alert alert-info col col-md-10 px-5 mb-0 text-center position-absolute o-map-renderer--alert" role="status">
<i class="fa fa-spin fa-circle-o-notch"/> Locating new addresses...
</div>
</t>
<t t-name="web_map.MapRenderer.NoMapToken">
<div class="alert alert-info alert-dismissible col col-md-10 px-5 mb-0 text-center position-absolute o-map-renderer--alert" role="status">
To get routing on your map, you first need to set up your MapBox token. It's free.
<a href="/odoo/action-base_setup.action_general_configuration" class="ml8">
<i class="oi oi-arrow-right"/>
Set up token
</a>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</t>
<t t-name="web_map.MapRenderer.PinListContainer">
<div class="o-map-renderer--pin-list-container col-12 col-md-2 bg-view border-start cursor-default h-100">
<div class="o-map-view--buttons">
<a class="btn btn-primary" t-att-href="googleMapUrl" target="_blank" data-hotkey="m">View in Google Maps</a>
</div>
<t t-if="!props.model.metaData.hideTitle">
<header class="o-map-renderer--pin-list-header o_pin_list_header text-uppercase px-0 py-md-2 d-flex align-items-baseline"
t-on-click="togglePinList">
<i class="fa fa-list me-2 text-primary"/>
<span class="fs-6 fw-bold text-truncate" t-out="props.model.metaData.panelTitle"/>
<i t-if="env.isSmall" class="fa float-end ms-auto" t-att-class="{
'fa-caret-down': expendedPinList,
'fa-caret-left': !expendedPinList
}"/>
</header>
</t>
<t t-if="canDisplayPinList and props.model.data.isGrouped">
<t t-foreach="props.model.data.recordGroups" t-as="groupId" t-key="groupId">
<div class="o-map-renderer--pin-list-group mb-1">
<t t-set="group" t-value="props.model.data.recordGroups[groupId]"/>
<div class="o-map-renderer--pin-list-group-header d-flex align-items-baseline"
t-on-click="() => this.toggleGroup(groupId)">
<i t-attf-class="fa fa-caret-{{ state.closedGroupIds.includes(groupId) ? 'right' : 'down' }}"/>
<span class="ms-1" t-att-style="'color:' + getGroupColor(groupId)">
<t t-call="web_map.pinSVG">
<t t-set="numbering" t-value="props.model.metaData.numbering" />
</t>
</span>
<t t-if="group.name" t-esc="group.name"/>
<t t-else="">Undefined</t>
</div>
<t t-if="!state.closedGroupIds.includes(groupId)">
<t t-call="{{ subTemplates.PinList }}">
<t t-set="records" t-value="group.records"/>
</t>
</t>
</div>
</t>
</t>
<t t-elif="canDisplayPinList">
<t t-call="{{ subTemplates.PinList }}">
<t t-set="records" t-value="props.model.data.records"/>
</t>
</t>
</div>
</t>
<t t-name="web_map.MapRenderer.PinList">
<t t-tag="props.model.metaData.numbering ? 'ol' : 'ul'"
t-att-class="{'o-map-renderer--pin-located': !props.model.metaData.numbering}"
class="o-map-renderer--pin-list-details ps-0 pb-0 o-map-renderer--handle"
t-ref="pinList">
<t t-call="{{ subTemplates.PinListItems }}"/>
</t>
</t>
<t t-name="web_map.MapRenderer.PinListItems">
<t t-foreach="records" t-as="record" t-key="record.id">
<t t-set="latitude" t-value="record.partner and record.partner.partner_latitude"/>
<t t-set="longitude" t-value="record.partner and record.partner.partner_longitude"/>
<li t-att-data-id="record.id" t-if="latitude and longitude" t-on-click.prevent="() => this.centerAndOpenPin(record)" class="cursor-pointer d-flex align-items-center justify-content-between o-map-renderer--pin-located py-1">
<span class="text-truncate flex-grow-1"> <t t-if="props.model.metaData.numbering" t-esc="record_index + 1 + '.'"/> <t t-esc="record.display_name"/> </span>
<span class="o_row_handle oi oi-draggable" t-if="this.props.model.canResequence" t-on-click.stop=""/>
</li>
</t>
</t>
<t t-name="web_map.MapRenderer.RountingUnavailable">
<div class="alert alert-warning alert-dismissible col col-md-10 px-5 mb-0 text-center position-absolute o-map-renderer--alert" role="status">
<strong>Unsuccessful routing request: </strong>
<t t-esc="props.model.data.routingError"/>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</t>
<t t-name="web_map.marker">
<div t-att-style="color and ('color:' + color)">
<t t-call="web_map.pinSVG" />
<t t-if="numbering" t-call="web_map.markerNumber"/>
<t t-elif="isMulti" t-call="web_map.markerBadge"/>
</div>
</t>
<t t-name="web_map.markerBadge">
<div class="badge text-bg-danger rounded-pill o-map-renderer--marker-badge d-flex justify-content-center" t-att-style="color and `background-color: ${color} !important`">
<t t-esc="count"/>
</div>
</t>
<t t-name="web_map.markerNumber">
<p class="o-map-renderer--marker-number position-relative text-center">
<t t-esc="number"/>
<t t-if="count gt 1">
<t t-call="web_map.markerBadge"/>
</t>
</p>
</t>
<t t-name="web_map.markerPopup">
<div>
<table class="o-map-renderer--popup-table align-top">
<thead>
<tr>
<th colspan="2"></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr t-foreach="data" t-as="field" t-key="field.id">
<td class="o-map-renderer--popup-table-content-name fw-bold text-nowrap align-baseline">
<t t-esc="field.string"/>
</td>
<td class="o-map-renderer--popup-table-space"></td>
<td class="o-map-renderer--popup-table-content-value align-baseline">
<t t-esc="field.value"/>
</td>
</tr>
</tbody>
</table>
<div class="o-map-renderer--popup-buttons d-flex align-item-end justify-content-start mt8">
<t t-if="hasFormView">
<button class="btn btn-primary o-map-renderer--popup-buttons-open">
Open
</button>
</t>
<div class="o-map-renderer--popup-buttons-divider d-inline-block h-auto"/>
<a class="btn btn-primary" role="button" t-att-href="url" target="_blank">
Navigate to
</a>
</div>
</div>
</t>
<t t-name="web_map.pinSVG">
<t t-if="numbering">
<t t-call="web_map.pinNoCircleSVG" />
</t>
<t t-else="">
<t t-call="web_map.pinCircleSVG" />
</t>
</t>
<t t-name="web_map.pinCircleSVG">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 60 78.6" style="enable-background:new 0 0 60 78.6;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.3;enable-background:new;}
.st1{fill:currentColor;stroke:#1A1919;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g id="Layer_2_1_">
<g id="Layer_1-2">
<path class="st0" d="M32.5,4C17.3,4,5,16.3,5,31.5c0,18.2,23.4,44.6,24.4,45.7c1.5,1.7,4.1,1.8,5.8,0.3c0.1-0.1,0.2-0.2,0.3-0.3
c1-1.1,24.4-27.4,24.4-45.7C60,16.3,47.7,4,32.5,4z M32.5,42.4c-6.3,0-11.4-5.1-11.4-11.5s5.1-11.5,11.5-11.5S44,24.6,44,31v0
C43.9,37.3,38.8,42.4,32.5,42.4z"/>
<path class="st1" d="M28.8,1.8c-14.9,0-27,12.1-27.1,27.1c0,18.5,24.2,45.7,25.3,46.9c0.9,1,2.4,1.1,3.4,0.2
c0.1-0.1,0.1-0.1,0.2-0.2c1-1.1,25.3-28.3,25.3-46.9C55.9,13.9,43.7,1.8,28.8,1.8z M28.8,40.3c-6.3,0-11.5-5.1-11.5-11.4
s5.1-11.5,11.4-11.5s11.5,5.1,11.5,11.4v0C40.2,35.2,35.1,40.3,28.8,40.3z"/>
</g>
</g>
</g>
</svg>
</t>
<t t-name="web_map.pinNoCircleSVG">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 61 78.9" style="enable-background:new 0 0 61 78.9;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.3;enable-background:new;}
.st1{fill:currentColor;stroke:#1A1919;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g id="Layer_2_1_">
<g id="Layer_1-2">
<path class="st0" d="M33.5,4C18.3,4,6,16.3,6,31.5c0,18.2,23.4,44.6,24.4,45.7c1.5,1.7,4.1,1.8,5.8,0.3c0.1-0.1,0.2-0.2,0.3-0.3
c1-1.1,24.4-27.4,24.4-45.7C61,16.3,48.7,4,33.5,4z"/>
<path class="st1" d="M28.7,1.7c-14.9,0-27,12.1-27.1,27.1c0,18.5,24.2,45.7,25.3,46.9c0.9,1,2.4,1.1,3.4,0.2
c0.1-0.1,0.1-0.1,0.2-0.2c1-1.1,25.3-28.3,25.3-46.9C55.8,13.8,43.6,1.7,28.7,1.7z"/>
</g>
</g>
</g>
</svg>
</t>
</templates>

View File

@ -0,0 +1,56 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { MapArchParser } from "./map_arch_parser";
import { MapModel } from "./map_model";
import { MapController } from "./map_controller";
import { MapRenderer } from "./map_renderer";
export const mapView = {
type: "map",
Controller: MapController,
Renderer: MapRenderer,
Model: MapModel,
ArchParser: MapArchParser,
buttonTemplate: "web_map.MapView.Buttons",
props: (genericProps, view, config) => {
let modelParams = genericProps.state;
if (!modelParams) {
const { arch, resModel, fields, context } = genericProps;
const parser = new view.ArchParser();
const archInfo = parser.parse(arch);
const views = config.views || [];
modelParams = {
allowResequence: archInfo.allowResequence || false,
context,
defaultOrder: archInfo.defaultOrder,
fieldNames: archInfo.fieldNames,
fieldNamesMarkerPopup: archInfo.fieldNamesMarkerPopup,
fields,
hasFormView: views.some((view) => view[1] === "form"),
hideAddress: archInfo.hideAddress || false,
hideName: archInfo.hideName || false,
hideTitle: archInfo.hideTitle || false,
limit: archInfo.limit || 80,
numbering: archInfo.routing || false,
offset: 0,
panelTitle: archInfo.panelTitle || config.getDisplayName() || _t("Items"),
resModel,
resPartnerField: archInfo.resPartnerField,
routing: archInfo.routing || false,
};
}
return {
...genericProps,
Model: view.Model,
modelParams,
Renderer: view.Renderer,
buttonTemplate: view.buttonTemplate,
};
},
};
registry.category("views").add("map", mapView);

View File

@ -1,182 +0,0 @@
$map-table-row-padding: 25px;
$map-table-line-padding: 20px;
$map-number-color: white;
$map-number-font-size: 19px;
$map-marker-color: #2c8397;
.o_map_view {
height: 100%;
.o_map_container {
height: 100%;
.o_map_popup_table {
vertical-align: top;
.contentName {
font-weight: bold;
white-space: nowrap;
line-height: $map-table-row-padding;
vertical-align: baseline;
}
.space {
padding-left: $map-table-line-padding;
}
.contentString {
vertical-align: baseline;
}
}
div {
.center {
display: flex;
justify-content: left;
align-items: flex-end;
margin-top: 8px;
}
.divider {
width: 5px;
height: auto;
display: inline-block;
}
}
}
.o_pin_list_container {
padding: 8px 8px 8px 22px;
background-color: white;
border-left: 1px solid #dee2e6;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
cursor: default;
> .o_pin_list_group {
margin-bottom: 1rem;
> .o_pin_list_group_header {
display: flex;
align-items: baseline;
color: #212529;
svg {
height: 1.25rem;
margin-right: 0.5rem;
}
> i {
margin-right: 1rem;
width: 0.5rem;
}
}
> .o_pin_list_details {
margin-left: 2rem;
}
}
.o_pin_list_header {
padding: 8px 0;
text-transform: uppercase;
color: #666666;
i {
color: $o-enterprise-color;
margin-right: 0.5rem;
}
span {
font-weight: bold;
}
}
.o_pin_list_details {
padding-left: 0px;
padding-bottom: 0px;
a {
color: $gray-900;
}
> li {
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.o_pin_located:hover {
text-decoration: none;
background-color: $o-gray-100;
}
&:not(.o_pin_located) {
cursor: not-allowed;
}
}
}
ul.o_pin_list_details {
list-style: none;
cursor: default;
padding-bottom: 2px;
}
}
//the height and width correspond to the height and width of the custom icon png file
.o_map_marker {
height: 40px !important;
width: 30px !important;
margin-top: -40px !important;
margin-left: -15px !important;
color: $map-marker-color;
}
.o_map_marker_badge {
@include o-position-absolute($top: -8px, $right: -10px);
font-size: 12px;
}
.o_number_icon {
position: relative;
top: -40px;
color: $map-number-color;
font-size: $map-number-font-size;
text-align: center;
margin-top: 10%;
}
.leaflet-fade-anim .leaflet-popup {
// used to disabled opening animation for the popups.
transition: none;
.leaflet-popup-content-wrapper {
border-radius: 10px;
.leaflet-popup-content {
margin: 24px 20px 20px 20px;
}
}
.leaflet-popup-close-button {
color: #666666;
}
}
.o-map-alert {
@include o-position-absolute($top: 0);
width: 100%;
z-index: 401; // leaflet have 400
}
.leaflet-container a {
color: $link-color;
&:hover {
color: $link-hover-color;
}
}
}
/* Fix opw-2124233, preventing rtlcss to reverse the map position */
.leaflet-pane, .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow,
.leaflet-tile-container, .leaflet-pane > svg, .leaflet-pane > canvas,
.leaflet-zoom-box, .leaflet-image-layer, .leaflet-layer {
left: 0 #{"/*rtl:ignore*/"};
right: auto #{"/*rtl:ignore*/"};
}

View File

@ -1,179 +0,0 @@
<?xml version ="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="odex30_web_map.MapRenderer" owl="1">
<div class="o_map_view row no-gutters">
<t t-if="props.routingError">
<t t-call="odex30_web_map.MapRenderer.RountingUnavailable"/>
</t>
<t t-elif="props.routing and !props.mapBoxToken">
<t t-call="odex30_web_map.MapRenderer.NoMapToken"/>
</t>
<t t-if="props.fetchingCoordinates">
<t t-call="odex30_web_map.MapRenderer.FetchingCoordinates"/>
</t>
<div class="o_map_container col-md-12 col-lg-10" t-ref="mapContainer"/>
<t t-call="odex30_web_map.MapRenderer.PinListContainer"/>
</div>
</t>
<t t-name="odex30_web_map.MapRenderer.FetchingCoordinates" owl="1">
<div class="alert alert-info col-md-12 col-lg-10 pr-5 pl-5 mb-0 text-center o-map-alert"
role="status">
<i class="fa fa-spin fa-spinner"/> Locating new addresses...
</div>
</t>
<t t-name="odex30_web_map.MapRenderer.NoMapToken" owl="1">
<div class="alert alert-info alert-dismissible col-md-12 col-lg-10 pr-5 pl-5 mb-0 text-center o-map-alert"
role="status">
To get routing on your map, you first need to setup your Mapbox token.
<a href="/web#action=base_setup.action_general_configuration" class="ml8">
<i class="fa fa-arrow-right"/>
Set up token
</a>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span>×</span>
</button>
</div>
</t>
<t t-name="odex30_web_map.MapRenderer.PinListContainer" owl="1">
<div class="o_pin_list_container d-none d-lg-block col-2">
<div t-if="!props.hideTitle" class="o_pin_list_header">
<header>
<i class="fa fa-list"/>
<span t-esc="props.panelTitle"/>
</header>
</div>
<t t-if="props.groupBy">
<t t-foreach="props.recordGroups" t-as="groupId" t-key="groupId">
<div class="o_pin_list_group">
<t t-set="group" t-value="props.recordGroups[groupId]"/>
<div class="o_pin_list_group_header" t-on-click="_toggleGroup(groupId)">
<i t-attf-class="fa fa-caret-{{ state.closedGroupIds.includes(groupId) ? 'right' : 'down' }}"/>
<span t-att-style="'color:' + _getGroupColor(groupId)"
t-raw="props.numbering ? _pinNoCircleSVG : _pinCircleSVG"/>
<t t-if="group.name" t-esc="group.name"/>
<t t-else="">Undefined</t>
</div>
<t t-if="!state.closedGroupIds.includes(groupId)">
<t t-call="odex30_web_map.MapRenderer.PinList">
<t t-set="records" t-value="group.records"/>
</t>
</t>
</div>
</t>
</t>
<t t-else="">
<t t-call="odex30_web_map.MapRenderer.PinList">
<t t-set="records" t-value="props.records"/>
</t>
</t>
</div>
</t>
<t t-name="odex30_web_map.MapRenderer.PinList" owl="1">
<ol t-if="props.numbering" class="o_pin_list_details">
<t t-call="odex30_web_map.MapRenderer.PinListItems"/>
</ol>
<ul t-else="" class="o_pin_list_details">
<t t-call="odex30_web_map.MapRenderer.PinListItems"/>
</ul>
</t>
<t t-name="odex30_web_map.MapRenderer.PinListItems" owl="1">
<t t-foreach="records" t-as="record" t-key="record.id">
<t t-set="latitude" t-value="record.partner and record.partner.partner_latitude"/>
<t t-set="longitude" t-value="record.partner and record.partner.partner_longitude"/>
<li
t-att-class="{o_pin_located: latitude and longitude}"
t-att-title="(!latitude or !longitude) and 'Could not locate'"
>
<a t-if="latitude and longitude" href=""
t-on-click.prevent="_centerAndOpenPin(record)">
<t t-esc="record.display_name"/>
</a>
<div t-else="" class="text-muted d-flex">
<span t-esc="record.display_name"/>
<span class="ml-auto" t-if="record.partner and record.partner.fetchingCoordinate">
<i class="fa fa-spin fa-spinner"/>
</span>
</div>
</li>
</t>
</t>
<t t-name="odex30_web_map.MapRenderer.RountingUnavailable" owl="1">
<div class="alert alert-warning alert-dismissible col-md-12 col-lg-10 pr-5 pl-5 mb-0 text-center o-map-alert"
role="status">
<strong>Unsuccessful routing request: </strong>
<t t-raw="props.routingError"/>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span>×</span>
</button>
</div>
</t>
<t t-name="odex30_web_map.marker" owl="1">
<div t-att-style="color and ('color:' + color)">
<t t-raw="pinSVG"/>
<t t-if="numbering" t-call="odex30_web_map.markerNumber"/>
<t t-elif="isMulti" t-call="odex30_web_map.markerBadge"/>
</div>
</t>
<t t-name="odex30_web_map.markerBadge" owl="1">
<span class="badge badge-danger badge-pill border-0 o_map_marker_badge">
<t t-esc="count"/>
</span>
</t>
<t t-name="odex30_web_map.markerNumber" owl="1">
<p class="o_number_icon">
<t t-esc="number"/>
<t t-if="count gt 1">
<t t-call="odex30_web_map.markerBadge"/>
</t>
</p>
</t>
<t t-name="odex30_web_map.markerPopup" owl="1">
<div>
<table class="o_map_popup_table">
<thead>
<tr>
<th colspan="2"></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr t-foreach="fields" t-as="field">
<td class="contentName">
<t t-esc="field.string"/>
</td>
<td class="space"></td>
<td class="contentString">
<t t-esc="field.value"/>
</td>
</tr>
</tbody>
</table>
<div class="center mt8">
<button class="btn btn-primary o_open" t-if="hasFormView">
open
</button>
<div class="divider"/>
<a class="btn btn-primary" style="color:white"
t-att-href="url" target="_blank">navigate to</a>
</div>
</div>
</t>
<t t-name="MapView.buttons">
<div>
<a class ="btn btn-primary" style="color:white"
t-att-href="widget" target="_blank">View in Google Maps</a>
</div>
</t>
</templates>

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import os
from lxml import etree
from odoo.loglevels import ustr
from odoo.tools import misc, view_validation
_logger = logging.getLogger(__name__)
_map_view_validator = None
@view_validation.validate('map')
def schema_map_view(arch, **kwargs):
global _map_view_validator
if _map_view_validator is None:
with misc.file_open('odex30_web_map/views/odex30_web_map.rng') as f:
with misc.file_open(os.path.join('web_map', 'views', 'odex30_web_map.rng')) as f:
_map_view_validator = etree.RelaxNG(etree.parse(f))
if _map_view_validator.validate(arch):
return True
for error in _map_view_validator.error_log:
_logger.error(ustr(error))
_logger.error("%s", error)
return False
# @view_validation.validate('map')
# def schema_map_view(arch, **kwargs):
# global _map_view_validator
#
# if _map_view_validator is None:
# with misc.file_open(os.path.join('odex30_web_map', 'views', 'odex30_web_map.rng')) as f:
# _map_view_validator = etree.RelaxNG(etree.parse(f))
#
# if _map_view_validator.validate(arch):
# return True
#
# for error in _map_view_validator.error_log:
# _logger.error(ustr(error))
# return False

View File

@ -12,6 +12,12 @@
<optional>
<attribute name="default_order"/>
</optional>
<optional>
<attribute name="default_group_by"/>
</optional>
<optional>
<attribute name="allow_resequence"/>
</optional>
<optional>
<attribute name="routing"/>
</optional>
@ -30,6 +36,9 @@
<optional>
<attribute name="limit"/>
</optional>
<optional>
<attribute name="js_class"/>
</optional>
<zeroOrMore>
<optional>
<ref name="field"/>

View File

@ -6,25 +6,18 @@
<field name="priority" eval ="46"/>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='module_base_geolocalize']" position="after">
<div class="col-12 col-lg-6 o_setting_box" id="token_map_view">
<div class="o_setting_right_pane">
<span class ="o_form_label">Map Routes</span>
<div class ="content-group">
<div class ="text-muted mb8">
Set a MapBox account to activate routes and style
</div>
<label for="map_box_token" string="Token"/>
<field name ="map_box_token"/>
<div class ="text-rigth" style="position:relative;">
<a class="oe_link" href="https://www.mapbox.com/" target="_blank">
<i class="fa fa-arrow-right"/>
Get token
</a>
</div>
<xpath expr="//setting[@id='base_geolocalize']" position="after">
<setting id="token_map_view" string="Map Routes" help="Set a MapBox account to activate routes and style">
<div class ="content-group">
<label for="map_box_token" string="Token" class="mr8"/>
<field name ="map_box_token"/>
<div class="text-start" style="position:relative;">
<a class="oe_link" href="https://account.mapbox.com/auth/signup/" target="_blank">
<i class="oi oi-arrow-right"/> Sign up to MapBox to get a free token
</a>
</div>
</div>
</div>
</setting>
</xpath>
</field>
</record>

View File

@ -5,10 +5,21 @@
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<field name="lang" position="after">
<field name="partner_longitude" invisible="1"/>
<field name="partner_latitude" invisible="1"/>
</field>
<sheet position="inside">
<field name="partner_longitude" invisible="1"/> <!-- Used by the MapModel JavaScript -->
<field name="partner_latitude" invisible="1"/> <!-- Used by the MapModel JavaScript -->
</sheet>
</field>
</record>
<record id="view_res_partner_filter_inherit_map" model="ir.ui.view">
<field name="name">res.partner.search.inherit.map</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_res_partner_filter" />
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="contact_address_complete" string="Address"/>
</xpath>
</field>
</record>
</odoo>