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

View File

@ -2,14 +2,14 @@
<templates> <templates>
<t t-name="kpi_scorecard.KpiFormulaField"> <t t-name="kpi_scorecard.KpiFormulaField">
<div class="o_field_kpi_formula" t-ref="formula"> <div class="o_field_kpi_formula" t-ref="formula">
<!-- Readonly Mode -->
<t t-if="props.readonly"> <t t-if="props.readonly">
<div class="formula-readonly"> <div class="formula-readonly">
<t t-if="state.formulaParts and state.formulaParts.length"> <t t-if="state.formulaParts and state.formulaParts.length">
<t t-foreach="state.formulaParts" t-as="part" t-key="part_index"> <t t-foreach="state.formulaParts" t-as="part" t-key="part_index">
<span t-att-class="'formula-part ' + (part.type || '').toLowerCase()" <span t-att-class="'formula-part ' + (part.type || '').toLowerCase()"
t-att-title="part.name"> t-att-title="part.name"
<t t-esc="part.name"/> t-esc="part.name"/>
</span>
</t> </t>
</t> </t>
<t t-else=""> <t t-else="">
@ -17,51 +17,74 @@
</t> </t>
</div> </div>
</t> </t>
<!-- Edit Mode -->
<t t-else=""> <t t-else="">
<div class="formula-editor" t-if="state.isLoaded"> <div class="formula-editor" t-if="state.isLoaded">
<!-- Search Input -->
<div class="formula-search mb-2"> <div class="formula-search mb-2">
<input type="text" <input type="text"
class="kpi-search-input form-control" class="kpi-search-input form-control"
placeholder="Search..." placeholder="Search..."
t-on-keyup="onSearch"/> t-on-input="onSearch"/>
</div> </div>
<div class="row"> <div class="row">
<!-- Left Column: Variables and Operators -->
<div class="col-md-6"> <div class="col-md-6">
<!-- Variables Section -->
<div class="formula-variables" t-if="state.variables"> <div class="formula-variables" t-if="state.variables">
<t t-foreach="state.variables.sections || []" t-as="section" t-key="section_index"> <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"> <div class="variable-section mb-3">
<h6 t-esc="section.name" class="text-primary"/> <h6 t-esc="section.name" class="text-primary"/>
<div class="variable-items"> <div class="variable-items">
<t t-foreach="section.items || []" t-as="item" t-key="item_index"> <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" <div class="formula-item draggable btn btn-sm btn-outline-secondary m-1"
t-att-data-formula="item.formula" t-att-data-formula="item.formula"
t-att-data-part-id="item.id"> t-att-data-part-id="item.id">
<span t-esc="item.name"/> <span t-esc="item.name"/>
<button class="btn btn-sm btn-danger ms-1" <button class="btn btn-sm btn-danger ms-1"
t-on-click="() => onDeletePart(item.id)" t-on-click.stop="(ev) => this.onDeletePart(item.id)"
type="button"> type="button">
<i class="fa fa-times"/> <i class="fa fa-times"/>
</button> </button>
</div> </div>
</t> </t>
</t>
</div> </div>
</div> </div>
</t> </t>
</t>
</div> </div>
<!-- Operators Section -->
<div class="formula-operators mb-3"> <div class="formula-operators mb-3">
<h6 class="text-primary">Operators</h6> <h6 class="text-primary">Operators</h6>
<button t-foreach="['+', '-', '*', '/', '(', ')']" <div class="d-flex flex-wrap gap-1">
t-as="op" <button class="btn btn-secondary btn-sm"
t-key="op" t-on-click="() => this.onAddOperator('+')"
class="btn btn-secondary btn-sm m-1" type="button">+</button>
t-on-click="() => onAddOperator(op)" <button class="btn btn-secondary btn-sm"
type="button"> t-on-click="() => this.onAddOperator('-')"
<t t-esc="op"/> type="button">-</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> </div>
<!-- Number Input Section -->
<div class="formula-number mb-3"> <div class="formula-number mb-3">
<h6 class="text-primary">Number</h6> <h6 class="text-primary">Number</h6>
<input type="number" <input type="number"
@ -71,6 +94,7 @@
</div> </div>
</div> </div>
<!-- Right Column: Formula Builder -->
<div class="col-md-6"> <div class="col-md-6">
<h6 class="text-primary">Formula Builder</h6> <h6 class="text-primary">Formula Builder</h6>
<div class="formula-drop-zone border rounded p-3 mb-3" <div class="formula-drop-zone border rounded p-3 mb-3"
@ -82,16 +106,117 @@
<label class="form-label">Current Formula:</label> <label class="form-label">Current Formula:</label>
<input type="text" <input type="text"
class="form-control" class="form-control"
t-att-value="props.value || ''" t-att-value="props.record.data[props.name] || ''"
readonly="readonly"/> readonly="readonly"/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Loading State -->
<div t-else="" class="text-center text-muted p-3"> <div t-else="" class="text-center text-muted p-3">
<i class="fa fa-spinner fa-spin me-2"/>
Loading formula editor... Loading formula editor...
</div> </div>
</t> </t>
</div> </div>
</t> </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_config_settings.xml",
"views/res_partner_views.xml", "views/res_partner_views.xml",
], ],
'qweb':[ # 'qweb':[
"static/src/xml/map.xml" # "static/src/xml/map.xml"
], # ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend_lazy': [
'odex30_web_map/static/src/js/map_controller.js', 'web_map/static/src/**/*',
'odex30_web_map/static/src/js/map_model.js', ],
'odex30_web_map/static/src/js/map_renderer.js', 'web.assets_unit_tests': [
'odex30_web_map/static/src/js/map_view.js', 'web_map/static/lib/**/*',
'odex30_web_map/static/src/scss/map_view.scss', 'web_map/static/tests/**/*',
'odex30_web_map/static/lib/leaflet/leaflet.js',
'odex30_web_map/static/lib/leaflet/leaflet.css',
], ],
'web.qunit_suite_tests': [ 'web.qunit_suite_tests': [
'/odex30_web_map/static/tests/map_view_tests.js' 'web_map/static/lib/**/*',
] ],
}, },
'auto_install': True, 'auto_install': True,

View File

@ -8,3 +8,6 @@ class View(models.Model):
_inherit = 'ir.ui.view' _inherit = 'ir.ui.view'
type = fields.Selection(selection_add=[('map', "Map")]) 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 import requests
from odoo.http import request from odoo.http import request
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings' _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): @api.onchange('map_box_token')
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' def _onchange_map_box_token(self):
environ = request.httprequest.headers.environ if not self.map_box_token:
# if self.map_box_token != '': return
# try: map_box_token = self.env['ir.config_parameter'].get_param('web_map.token_map_box')
# headers = { if self.map_box_token == map_box_token:
# 'referer': environ.get('HTTP_REFERER') return
# }
# 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 url = 'https://api.mapbox.com/directions/v5/mapbox/driving/-73.989%2C40.733%3B-74%2C40.733'
def set_values(self): headers = {
# check validity of mapbox token only when it is being changed 'referer': request.httprequest.headers.environ.get('HTTP_REFERER'),
if self.map_box_token: }
map_box_token = self.env['ir.config_parameter'].get_param('odex30_web_map.token_map_box') params = {
if map_box_token != self.map_box_token: 'access_token': self.map_box_token,
self.check_token_validity() 'steps': 'true',
super(ResConfigSettings, self).set_values() '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): class ResPartner(models.Model):
_name = 'res.partner'
_inherit = 'res.partner' _inherit = 'res.partner'
contact_address_complete = fields.Char(compute='_compute_complete_address', store=True) 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 @api.model
def update_latitude_longitude(self, partners): def update_latitude_longitude(self, partners):
partners_data = defaultdict(list) partners_data = defaultdict(list)
@ -34,12 +54,14 @@ class ResPartner(models.Model):
self.partner_latitude = False self.partner_latitude = False
self.partner_longitude = 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): def _compute_complete_address(self):
for record in self: for record in self:
record.contact_address_complete = '' record.contact_address_complete = ''
if record.street: if record.street:
record.contact_address_complete += record.street + ', ' record.contact_address_complete += record.street + ', '
if record.street2:
record.contact_address_complete += record.street2 + ', '
if record.zip: if record.zip:
record.contact_address_complete += record.zip + ' ' record.contact_address_complete += record.zip + ' '
if record.city: if record.city:
@ -49,3 +71,48 @@ class ResPartner(models.Model):
if record.country_id: if record.country_id:
record.contact_address_complete += record.country_id.name record.contact_address_complete += record.country_id.name
record.contact_address_complete = record.contact_address_complete.strip().strip(',') 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; user-select: none;
-webkit-user-drag: 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 */ /* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile { .leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast; 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 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 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-marker-pane img,
.leaflet-container .leaflet-shadow-pane img, .leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img, .leaflet-container .leaflet-tile-pane img,
@ -49,6 +56,13 @@
.leaflet-container .leaflet-tile { .leaflet-container .leaflet-tile {
max-width: none !important; max-width: none !important;
max-height: 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 { .leaflet-container.leaflet-touch-zoom {
@ -162,9 +176,6 @@
/* zoom and fade animations */ /* zoom and fade animations */
.leaflet-fade-anim .leaflet-tile {
will-change: opacity;
}
.leaflet-fade-anim .leaflet-popup { .leaflet-fade-anim .leaflet-popup {
opacity: 0; opacity: 0;
-webkit-transition: opacity 0.2s linear; -webkit-transition: opacity 0.2s linear;
@ -179,9 +190,10 @@
-ms-transform-origin: 0 0; -ms-transform-origin: 0 0;
transform-origin: 0 0; transform-origin: 0 0;
} }
.leaflet-zoom-anim .leaflet-zoom-animated { svg.leaflet-zoom-animated {
will-change: transform; will-change: transform;
} }
.leaflet-zoom-anim .leaflet-zoom-animated { .leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); -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); -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
@ -237,7 +249,8 @@
.leaflet-marker-icon.leaflet-interactive, .leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.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: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto; pointer-events: auto;
} }
@ -246,13 +259,7 @@
.leaflet-container { .leaflet-container {
background: #ddd; background: #ddd;
outline: 0; outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-container a.leaflet-active {
outline: 2px solid orange;
} }
.leaflet-zoom-box { .leaflet-zoom-box {
border: 2px dotted #38f; border: 2px dotted #38f;
@ -262,7 +269,10 @@
/* general typography */ /* general typography */
.leaflet-container { .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); box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px; border-radius: 4px;
} }
.leaflet-bar a, .leaflet-bar a {
.leaflet-bar a:hover {
background-color: #fff; background-color: #fff;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
width: 26px; width: 26px;
@ -290,7 +299,8 @@
background-repeat: no-repeat; background-repeat: no-repeat;
display: block; display: block;
} }
.leaflet-bar a:hover { .leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4; background-color: #f4f4f4;
} }
.leaflet-bar a:first-child { .leaflet-bar a:first-child {
@ -380,6 +390,8 @@
} }
.leaflet-control-layers label { .leaflet-control-layers label {
display: block; display: block;
font-size: 13px;
font-size: 1.08333em;
} }
.leaflet-control-layers-separator { .leaflet-control-layers-separator {
height: 0; height: 0;
@ -388,7 +400,7 @@
} }
/* Default icon URLs */ /* 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); background-image: url(images/marker-icon.png);
} }
@ -397,23 +409,27 @@
.leaflet-container .leaflet-control-attribution { .leaflet-container .leaflet-control-attribution {
background: #fff; background: #fff;
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.8);
margin: 0; margin: 0;
} }
.leaflet-control-attribution, .leaflet-control-attribution,
.leaflet-control-scale-line { .leaflet-control-scale-line {
padding: 0 5px; padding: 0 5px;
color: #333; color: #333;
line-height: 1.4;
} }
.leaflet-control-attribution a { .leaflet-control-attribution a {
text-decoration: none; text-decoration: none;
} }
.leaflet-control-attribution a:hover { .leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline; text-decoration: underline;
} }
.leaflet-container .leaflet-control-attribution, .leaflet-attribution-flag {
.leaflet-container .leaflet-control-scale { display: inline !important;
font-size: 11px; vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
} }
.leaflet-left .leaflet-control-scale { .leaflet-left .leaflet-control-scale {
margin-left: 5px; margin-left: 5px;
@ -426,14 +442,11 @@
border-top: none; border-top: none;
line-height: 1.1; line-height: 1.1;
padding: 2px 5px 1px; padding: 2px 5px 1px;
font-size: 11px;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
box-sizing: border-box; box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
background: #fff; text-shadow: 1px 1px #fff;
background: rgba(255, 255, 255, 0.5);
} }
.leaflet-control-scale-line:not(:first-child) { .leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777; border-top: 2px solid #777;
@ -469,17 +482,22 @@
border-radius: 12px; border-radius: 12px;
} }
.leaflet-popup-content { .leaflet-popup-content {
margin: 13px 19px; margin: 13px 24px 13px 20px;
line-height: 1.4; line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
} }
.leaflet-popup-content p { .leaflet-popup-content p {
margin: 18px 0; margin: 17px 0;
margin: 1.3em 0;
} }
.leaflet-popup-tip-container { .leaflet-popup-tip-container {
width: 40px; width: 40px;
height: 20px; height: 20px;
position: absolute; position: absolute;
left: 50%; left: 50%;
margin-top: -1px;
margin-left: -20px; margin-left: -20px;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
@ -490,6 +508,7 @@
padding: 1px; padding: 1px;
margin: -10px auto 0; margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg); -webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg); -moz-transform: rotate(45deg);
@ -506,28 +525,25 @@
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
padding: 4px 4px 0 0;
border: none; border: none;
text-align: center; text-align: center;
width: 18px; width: 24px;
height: 14px; height: 24px;
font: 16px/14px Tahoma, Verdana, sans-serif; font: 16px/24px Tahoma, Verdana, sans-serif;
color: #c3c3c3; color: #757575;
text-decoration: none; text-decoration: none;
font-weight: bold;
background: transparent; background: transparent;
} }
.leaflet-container a.leaflet-popup-close-button:hover { .leaflet-container a.leaflet-popup-close-button:hover,
color: #999; .leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
} }
.leaflet-popup-scrolled { .leaflet-popup-scrolled {
overflow: auto; overflow: auto;
border-bottom: 1px solid #ddd;
border-top: 1px solid #ddd;
} }
.leaflet-oldie .leaflet-popup-content-wrapper { .leaflet-oldie .leaflet-popup-content-wrapper {
zoom: 1; -ms-zoom: 1;
} }
.leaflet-oldie .leaflet-popup-tip { .leaflet-oldie .leaflet-popup-tip {
width: 24px; width: 24px;
@ -536,9 +552,6 @@
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; -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); 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-zoom,
.leaflet-oldie .leaflet-control-layers, .leaflet-oldie .leaflet-control-layers,
@ -573,7 +586,7 @@
pointer-events: none; pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4); box-shadow: 0 1px 3px rgba(0,0,0,0.4);
} }
.leaflet-tooltip.leaflet-clickable { .leaflet-tooltip.leaflet-interactive {
cursor: pointer; cursor: pointer;
pointer-events: auto; pointer-events: auto;
} }
@ -633,3 +646,13 @@
margin-left: -12px; margin-left: -12px;
border-right-color: #fff; 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 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging import logging
import os import os
from lxml import etree from lxml import etree
from odoo.loglevels import ustr
from odoo.tools import misc, view_validation from odoo.tools import misc, view_validation
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_map_view_validator = None _map_view_validator = None
@view_validation.validate('map') @view_validation.validate('map')
def schema_map_view(arch, **kwargs): def schema_map_view(arch, **kwargs):
global _map_view_validator global _map_view_validator
if _map_view_validator is None: 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)) _map_view_validator = etree.RelaxNG(etree.parse(f))
if _map_view_validator.validate(arch): if _map_view_validator.validate(arch):
return True return True
for error in _map_view_validator.error_log: for error in _map_view_validator.error_log:
_logger.error(ustr(error)) _logger.error("%s", error)
return False 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> <optional>
<attribute name="default_order"/> <attribute name="default_order"/>
</optional> </optional>
<optional>
<attribute name="default_group_by"/>
</optional>
<optional>
<attribute name="allow_resequence"/>
</optional>
<optional> <optional>
<attribute name="routing"/> <attribute name="routing"/>
</optional> </optional>
@ -30,6 +36,9 @@
<optional> <optional>
<attribute name="limit"/> <attribute name="limit"/>
</optional> </optional>
<optional>
<attribute name="js_class"/>
</optional>
<zeroOrMore> <zeroOrMore>
<optional> <optional>
<ref name="field"/> <ref name="field"/>

View File

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

View File

@ -5,10 +5,21 @@
<field name="model">res.partner</field> <field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/> <field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<field name="lang" position="after"> <sheet position="inside">
<field name="partner_longitude" invisible="1"/> <field name="partner_longitude" invisible="1"/> <!-- Used by the MapModel JavaScript -->
<field name="partner_latitude" invisible="1"/> <field name="partner_latitude" invisible="1"/> <!-- Used by the MapModel JavaScript -->
</sheet>
</field> </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> </field>
</record> </record>
</odoo> </odoo>