commit
d4ea266ffd
|
|
@ -27,7 +27,7 @@ export class KpiFormulaField extends Component {
|
|||
onWillStart(async () => {
|
||||
try {
|
||||
// Load draggabilly library
|
||||
await loadJS("kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js");
|
||||
await loadJS("/kpi_scorecard/static/lib/draggabilly/draggabilly.pkgd.js");
|
||||
await this.loadFormulaData();
|
||||
} catch (error) {
|
||||
console.error("Error loading KPI formula:", error);
|
||||
|
|
@ -51,12 +51,14 @@ export class KpiFormulaField extends Component {
|
|||
|
||||
async loadFormulaData() {
|
||||
try {
|
||||
const fieldValue = this.props.record.data[this.props.name] || "";
|
||||
|
||||
if (this.props.readonly) {
|
||||
// Load formula parts for readonly display
|
||||
this.state.formulaParts = await this.orm.call(
|
||||
"kpi.item",
|
||||
"action_render_formula",
|
||||
[this.props.value || ""]
|
||||
[fieldValue]
|
||||
);
|
||||
} else {
|
||||
// Load variables for edit mode
|
||||
|
|
@ -65,13 +67,14 @@ export class KpiFormulaField extends Component {
|
|||
this.state.variables = await this.orm.call(
|
||||
"kpi.item",
|
||||
"action_return_measures",
|
||||
[[recordId], this.props.value || ""]
|
||||
[[recordId], fieldValue]
|
||||
);
|
||||
}
|
||||
}
|
||||
this.state.isLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Error loading formula data:", error);
|
||||
this.state.isLoaded = true; // Set to true even on error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +131,8 @@ export class KpiFormulaField extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
this.props.update(formula.trim());
|
||||
// Update using Odoo 18 method
|
||||
this.props.record.update({ [this.props.name]: formula.trim() });
|
||||
}
|
||||
|
||||
onSearch(ev) {
|
||||
|
|
@ -176,5 +180,7 @@ export class KpiFormulaField extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// Register the field widget
|
||||
registry.category("fields").add("kpi_formula", KpiFormulaField);
|
||||
// ✅ الطريقة الصحيحة للتسجيل في Odoo 18
|
||||
registry.category("fields").add("kpi_formula", {
|
||||
component: KpiFormulaField,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
<templates>
|
||||
<t t-name="kpi_scorecard.KpiFormulaField">
|
||||
<div class="o_field_kpi_formula" t-ref="formula">
|
||||
<!-- Readonly Mode -->
|
||||
<t t-if="props.readonly">
|
||||
<div class="formula-readonly">
|
||||
<t t-if="state.formulaParts and state.formulaParts.length">
|
||||
<t t-foreach="state.formulaParts" t-as="part" t-key="part_index">
|
||||
<span t-att-class="'formula-part ' + (part.type || '').toLowerCase()"
|
||||
t-att-title="part.name">
|
||||
<t t-esc="part.name"/>
|
||||
</span>
|
||||
t-att-title="part.name"
|
||||
t-esc="part.name"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
|
|
@ -17,51 +17,74 @@
|
|||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<t t-else="">
|
||||
<div class="formula-editor" t-if="state.isLoaded">
|
||||
<!-- Search Input -->
|
||||
<div class="formula-search mb-2">
|
||||
<input type="text"
|
||||
class="kpi-search-input form-control"
|
||||
placeholder="Search..."
|
||||
t-on-keyup="onSearch"/>
|
||||
t-on-input="onSearch"/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Left Column: Variables and Operators -->
|
||||
<div class="col-md-6">
|
||||
<!-- Variables Section -->
|
||||
<div class="formula-variables" t-if="state.variables">
|
||||
<t t-foreach="state.variables.sections || []" t-as="section" t-key="section_index">
|
||||
<div class="variable-section mb-3">
|
||||
<h6 t-esc="section.name" class="text-primary"/>
|
||||
<div class="variable-items">
|
||||
<t t-foreach="section.items || []" t-as="item" t-key="item_index">
|
||||
<div class="formula-item draggable btn btn-sm btn-outline-secondary m-1"
|
||||
t-att-data-formula="item.formula"
|
||||
t-att-data-part-id="item.id">
|
||||
<span t-esc="item.name"/>
|
||||
<button class="btn btn-sm btn-danger ms-1"
|
||||
t-on-click="() => onDeletePart(item.id)"
|
||||
type="button">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="state.variables.sections">
|
||||
<t t-foreach="state.variables.sections" t-as="section" t-key="section_index">
|
||||
<div class="variable-section mb-3">
|
||||
<h6 t-esc="section.name" class="text-primary"/>
|
||||
<div class="variable-items">
|
||||
<t t-if="section.items">
|
||||
<t t-foreach="section.items" t-as="item" t-key="item_index">
|
||||
<div class="formula-item draggable btn btn-sm btn-outline-secondary m-1"
|
||||
t-att-data-formula="item.formula"
|
||||
t-att-data-part-id="item.id">
|
||||
<span t-esc="item.name"/>
|
||||
<button class="btn btn-sm btn-danger ms-1"
|
||||
t-on-click.stop="(ev) => this.onDeletePart(item.id)"
|
||||
type="button">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Operators Section -->
|
||||
<div class="formula-operators mb-3">
|
||||
<h6 class="text-primary">Operators</h6>
|
||||
<button t-foreach="['+', '-', '*', '/', '(', ')']"
|
||||
t-as="op"
|
||||
t-key="op"
|
||||
class="btn btn-secondary btn-sm m-1"
|
||||
t-on-click="() => onAddOperator(op)"
|
||||
type="button">
|
||||
<t t-esc="op"/>
|
||||
</button>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.onAddOperator('+')"
|
||||
type="button">+</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.onAddOperator('-')"
|
||||
type="button">-</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.onAddOperator('*')"
|
||||
type="button">*</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.onAddOperator('/')"
|
||||
type="button">/</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.onAddOperator('(')"
|
||||
type="button">(</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.onAddOperator(')')"
|
||||
type="button">)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Number Input Section -->
|
||||
<div class="formula-number mb-3">
|
||||
<h6 class="text-primary">Number</h6>
|
||||
<input type="number"
|
||||
|
|
@ -71,6 +94,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Formula Builder -->
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary">Formula Builder</h6>
|
||||
<div class="formula-drop-zone border rounded p-3 mb-3"
|
||||
|
|
@ -82,16 +106,117 @@
|
|||
<label class="form-label">Current Formula:</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
t-att-value="props.value || ''"
|
||||
t-att-value="props.record.data[props.name] || ''"
|
||||
readonly="readonly"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div t-else="" class="text-center text-muted p-3">
|
||||
<i class="fa fa-spinner fa-spin me-2"/>
|
||||
Loading formula editor...
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</templates>
|
||||
|
||||
<!--<?xml version="1.0" encoding="UTF-8"?>-->
|
||||
<!--<templates>-->
|
||||
<!-- <t t-name="kpi_scorecard.KpiFormulaField">-->
|
||||
<!-- <div class="o_field_kpi_formula" t-ref="formula">-->
|
||||
<!-- <t t-if="props.readonly">-->
|
||||
<!-- <div class="formula-readonly">-->
|
||||
<!-- <t t-if="state.formulaParts and state.formulaParts.length">-->
|
||||
<!-- <t t-foreach="state.formulaParts" t-as="part" t-key="part_index">-->
|
||||
<!-- <span t-att-class="'formula-part ' + (part.type || '').toLowerCase()"-->
|
||||
<!-- t-att-title="part.name">-->
|
||||
<!-- <t t-esc="part.name"/>-->
|
||||
<!-- </span>-->
|
||||
<!-- </t>-->
|
||||
<!-- </t>-->
|
||||
<!-- <t t-else="">-->
|
||||
<!-- <span class="text-muted">No formula defined</span>-->
|
||||
<!-- </t>-->
|
||||
<!-- </div>-->
|
||||
<!-- </t>-->
|
||||
<!-- <t t-else="">-->
|
||||
<!-- <div class="formula-editor" t-if="state.isLoaded">-->
|
||||
<!-- <div class="formula-search mb-2">-->
|
||||
<!-- <input type="text"-->
|
||||
<!-- class="kpi-search-input form-control"-->
|
||||
<!-- placeholder="Search..."-->
|
||||
<!-- t-on-keyup="onSearch"/>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="row">-->
|
||||
<!-- <div class="col-md-6">-->
|
||||
<!-- <div class="formula-variables" t-if="state.variables">-->
|
||||
<!-- <t t-foreach="state.variables.sections || []" t-as="section" t-key="section_index">-->
|
||||
<!-- <div class="variable-section mb-3">-->
|
||||
<!-- <h6 t-esc="section.name" class="text-primary"/>-->
|
||||
<!-- <div class="variable-items">-->
|
||||
<!-- <t t-foreach="section.items || []" t-as="item" t-key="item_index">-->
|
||||
<!-- <div class="formula-item draggable btn btn-sm btn-outline-secondary m-1"-->
|
||||
<!-- t-att-data-formula="item.formula"-->
|
||||
<!-- t-att-data-part-id="item.id">-->
|
||||
<!-- <span t-esc="item.name"/>-->
|
||||
<!-- <button class="btn btn-sm btn-danger ms-1"-->
|
||||
<!-- t-on-click="() => onDeletePart(item.id)"-->
|
||||
<!-- type="button">-->
|
||||
<!-- <i class="fa fa-times"/>-->
|
||||
<!-- </button>-->
|
||||
<!-- </div>-->
|
||||
<!-- </t>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </t>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="formula-operators mb-3">-->
|
||||
<!-- <h6 class="text-primary">Operators</h6>-->
|
||||
<!-- <button t-foreach="['+', '-', '*', '/', '(', ')']"-->
|
||||
<!-- t-as="op"-->
|
||||
<!-- t-key="op"-->
|
||||
<!-- class="btn btn-secondary btn-sm m-1"-->
|
||||
<!-- t-on-click="() => onAddOperator(op)"-->
|
||||
<!-- type="button">-->
|
||||
<!-- <t t-esc="op"/>-->
|
||||
<!-- </button>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="formula-number mb-3">-->
|
||||
<!-- <h6 class="text-primary">Number</h6>-->
|
||||
<!-- <input type="number"-->
|
||||
<!-- class="kpi-number-input form-control"-->
|
||||
<!-- placeholder="Enter number..."-->
|
||||
<!-- t-on-change="onChangeNumber"/>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="col-md-6">-->
|
||||
<!-- <h6 class="text-primary">Formula Builder</h6>-->
|
||||
<!-- <div class="formula-drop-zone border rounded p-3 mb-3"-->
|
||||
<!-- style="min-height: 100px; background-color: #f8f9fa;">-->
|
||||
<!-- <small class="text-muted">Drag formula parts here</small>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="current-formula">-->
|
||||
<!-- <label class="form-label">Current Formula:</label>-->
|
||||
<!-- <input type="text"-->
|
||||
<!-- class="form-control"-->
|
||||
<!-- t-att-value="props.value || ''"-->
|
||||
<!-- readonly="readonly"/>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div t-else="" class="text-center text-muted p-3">-->
|
||||
<!-- Loading formula editor...-->
|
||||
<!-- </div>-->
|
||||
<!-- </t>-->
|
||||
<!-- </div>-->
|
||||
<!-- </t>-->
|
||||
<!--</templates>-->
|
||||
|
|
@ -14,22 +14,21 @@
|
|||
"views/res_config_settings.xml",
|
||||
"views/res_partner_views.xml",
|
||||
],
|
||||
'qweb':[
|
||||
"static/src/xml/map.xml"
|
||||
],
|
||||
# 'qweb':[
|
||||
# "static/src/xml/map.xml"
|
||||
# ],
|
||||
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'odex30_web_map/static/src/js/map_controller.js',
|
||||
'odex30_web_map/static/src/js/map_model.js',
|
||||
'odex30_web_map/static/src/js/map_renderer.js',
|
||||
'odex30_web_map/static/src/js/map_view.js',
|
||||
'odex30_web_map/static/src/scss/map_view.scss',
|
||||
'odex30_web_map/static/lib/leaflet/leaflet.js',
|
||||
'odex30_web_map/static/lib/leaflet/leaflet.css',
|
||||
'web.assets_backend_lazy': [
|
||||
'web_map/static/src/**/*',
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'web_map/static/lib/**/*',
|
||||
'web_map/static/tests/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'/odex30_web_map/static/tests/map_view_tests.js'
|
||||
]
|
||||
'web_map/static/lib/**/*',
|
||||
],
|
||||
},
|
||||
|
||||
'auto_install': True,
|
||||
|
|
|
|||
|
|
@ -8,3 +8,6 @@ class View(models.Model):
|
|||
_inherit = 'ir.ui.view'
|
||||
|
||||
type = fields.Selection(selection_add=[('map', "Map")])
|
||||
|
||||
def _get_view_info(self):
|
||||
return {'map': {'icon': 'fa fa-map-marker'}} | super()._get_view_info()
|
||||
|
|
|
|||
|
|
@ -6,38 +6,75 @@ from odoo.exceptions import UserError
|
|||
import requests
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
map_box_token = fields.Char(config_parameter='odex30_web_map.token_map_box',string = 'Token Map Box', help='Necessary for some functionalities in the map view', copy=True, default='', store=True)
|
||||
map_box_token = fields.Char(config_parameter='web_map.token_map_box',string = 'Token Map Box', help='Necessary for some functionalities in the map view', copy=True, default='', store=True)
|
||||
|
||||
def check_token_validity(self):
|
||||
url = 'https://api.mapbox.com/directions/v5/mapbox/driving/-73.989%2C40.733%3B-74%2C40.733?access_token='+self.map_box_token+'&steps=true&geometries=geojson'
|
||||
environ = request.httprequest.headers.environ
|
||||
# if self.map_box_token != '':
|
||||
# try:
|
||||
# headers = {
|
||||
# 'referer': environ.get('HTTP_REFERER')
|
||||
# }
|
||||
# result = requests.get(url=url, headers=headers)
|
||||
# error_code = result.status_code
|
||||
# if(result.status_code != 200):
|
||||
# self.map_box_token = ''
|
||||
# if error_code == 401:
|
||||
# raise UserError(_('The token input is not valid'))
|
||||
# elif error_code == 403:
|
||||
# raise UserError(_('This referer is not authorized'))
|
||||
# elif error_code == 500:
|
||||
# raise UserError(_('The MapBox server is unreachable'))
|
||||
# except Exception:
|
||||
# raise
|
||||
@api.onchange('map_box_token')
|
||||
def _onchange_map_box_token(self):
|
||||
if not self.map_box_token:
|
||||
return
|
||||
map_box_token = self.env['ir.config_parameter'].get_param('web_map.token_map_box')
|
||||
if self.map_box_token == map_box_token:
|
||||
return
|
||||
|
||||
@api.model
|
||||
def set_values(self):
|
||||
# check validity of mapbox token only when it is being changed
|
||||
if self.map_box_token:
|
||||
map_box_token = self.env['ir.config_parameter'].get_param('odex30_web_map.token_map_box')
|
||||
if map_box_token != self.map_box_token:
|
||||
self.check_token_validity()
|
||||
super(ResConfigSettings, self).set_values()
|
||||
url = 'https://api.mapbox.com/directions/v5/mapbox/driving/-73.989%2C40.733%3B-74%2C40.733'
|
||||
headers = {
|
||||
'referer': request.httprequest.headers.environ.get('HTTP_REFERER'),
|
||||
}
|
||||
params = {
|
||||
'access_token': self.map_box_token,
|
||||
'steps': 'true',
|
||||
'geometries': 'geojson',
|
||||
}
|
||||
try:
|
||||
result = requests.head(url=url, headers=headers, params=params, timeout=5)
|
||||
error_code = result.status_code
|
||||
except requests.exceptions.RequestException:
|
||||
error_code = 500
|
||||
if error_code == 200:
|
||||
return
|
||||
self.map_box_token = ''
|
||||
if error_code == 401:
|
||||
return {'warning': {'message': _('The token input is not valid')}}
|
||||
elif error_code == 403:
|
||||
return {'warning': {'message': _('This referer is not authorized')}}
|
||||
elif error_code == 500:
|
||||
return {'warning': {'message': _('The MapBox server is unreachable')}}
|
||||
|
||||
|
||||
# class ResConfigSettings(models.TransientModel):
|
||||
# _inherit = 'res.config.settings'
|
||||
#
|
||||
# map_box_token = fields.Char(config_parameter='odex30_web_map.token_map_box',string = 'Token Map Box', help='Necessary for some functionalities in the map view', copy=True, default='', store=True)
|
||||
#
|
||||
# # def check_token_validity(self):
|
||||
# url = 'https://api.mapbox.com/directions/v5/mapbox/driving/-73.989%2C40.733%3B-74%2C40.733?access_token='+self.map_box_token+'&steps=true&geometries=geojson'
|
||||
# environ = request.httprequest.headers.environ
|
||||
# # if self.map_box_token != '':
|
||||
# # try:
|
||||
# # headers = {
|
||||
# # 'referer': environ.get('HTTP_REFERER')
|
||||
# # }
|
||||
# # result = requests.get(url=url, headers=headers)
|
||||
# # error_code = result.status_code
|
||||
# # if(result.status_code != 200):
|
||||
# # self.map_box_token = ''
|
||||
# # if error_code == 401:
|
||||
# # raise UserError(_('The token input is not valid'))
|
||||
# # elif error_code == 403:
|
||||
# # raise UserError(_('This referer is not authorized'))
|
||||
# # elif error_code == 500:
|
||||
# # raise UserError(_('The MapBox server is unreachable'))
|
||||
# # except Exception:
|
||||
# # raise
|
||||
|
||||
# @api.model
|
||||
# def set_values(self):
|
||||
# # check validity of mapbox token only when it is being changed
|
||||
# if self.map_box_token:
|
||||
# map_box_token = self.env['ir.config_parameter'].get_param('odex30_web_map.token_map_box')
|
||||
# if map_box_token != self.map_box_token:
|
||||
# self.check_token_validity()
|
||||
# super(ResConfigSettings, self).set_values()
|
||||
|
|
|
|||
|
|
@ -7,11 +7,31 @@ from odoo import api, fields, models
|
|||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_name = 'res.partner'
|
||||
_inherit = 'res.partner'
|
||||
|
||||
contact_address_complete = fields.Char(compute='_compute_complete_address', store=True)
|
||||
|
||||
def write(self, vals):
|
||||
# Reset latitude/longitude in case we modify the address without
|
||||
# updating the related geolocation fields
|
||||
if any(field in vals for field in ['street', 'zip', 'city', 'state_id', 'country_id']) \
|
||||
and not all('partner_%s' % field in vals for field in ['latitude', 'longitude']):
|
||||
vals.update({
|
||||
'partner_latitude': False,
|
||||
'partner_longitude': False,
|
||||
})
|
||||
return super().write(vals)
|
||||
|
||||
@api.model
|
||||
def _address_fields(self):
|
||||
return super()._address_fields() + ['partner_latitude', 'partner_longitude']
|
||||
|
||||
@api.model
|
||||
def _formatting_address_fields(self):
|
||||
"""Returns the list of address fields usable to format addresses."""
|
||||
result = super()._formatting_address_fields()
|
||||
return [item for item in result if item not in ['partner_latitude', 'partner_longitude']]
|
||||
|
||||
@api.model
|
||||
def update_latitude_longitude(self, partners):
|
||||
partners_data = defaultdict(list)
|
||||
|
|
@ -34,12 +54,14 @@ class ResPartner(models.Model):
|
|||
self.partner_latitude = False
|
||||
self.partner_longitude = False
|
||||
|
||||
@api.depends('street', 'zip', 'city', 'country_id')
|
||||
@api.depends('street', 'street2', 'zip', 'city', 'country_id')
|
||||
def _compute_complete_address(self):
|
||||
for record in self:
|
||||
record.contact_address_complete = ''
|
||||
if record.street:
|
||||
record.contact_address_complete += record.street + ', '
|
||||
if record.street2:
|
||||
record.contact_address_complete += record.street2 + ', '
|
||||
if record.zip:
|
||||
record.contact_address_complete += record.zip + ' '
|
||||
if record.city:
|
||||
|
|
@ -49,3 +71,48 @@ class ResPartner(models.Model):
|
|||
if record.country_id:
|
||||
record.contact_address_complete += record.country_id.name
|
||||
record.contact_address_complete = record.contact_address_complete.strip().strip(',')
|
||||
|
||||
#
|
||||
# class ResPartner(models.Model):
|
||||
# _name = 'res.partner'
|
||||
# _inherit = 'res.partner'
|
||||
#
|
||||
# contact_address_complete = fields.Char(compute='_compute_complete_address', store=True)
|
||||
#
|
||||
# @api.model
|
||||
# def update_latitude_longitude(self, partners):
|
||||
# partners_data = defaultdict(list)
|
||||
#
|
||||
# for partner in partners:
|
||||
# if 'id' in partner and 'partner_latitude' in partner and 'partner_longitude' in partner:
|
||||
# partners_data[(partner['partner_latitude'], partner['partner_longitude'])].append(partner['id'])
|
||||
#
|
||||
# for values, partner_ids in partners_data.items():
|
||||
# # NOTE this should be done in sudo to avoid crashing as soon as the view is used
|
||||
# self.browse(partner_ids).sudo().write({
|
||||
# 'partner_latitude': values[0],
|
||||
# 'partner_longitude': values[1],
|
||||
# })
|
||||
#
|
||||
# return {}
|
||||
#
|
||||
# @api.onchange('street', 'zip', 'city', 'state_id', 'country_id')
|
||||
# def _delete_coordinates(self):
|
||||
# self.partner_latitude = False
|
||||
# self.partner_longitude = False
|
||||
#
|
||||
# @api.depends('street', 'zip', 'city', 'country_id')
|
||||
# def _compute_complete_address(self):
|
||||
# for record in self:
|
||||
# record.contact_address_complete = ''
|
||||
# if record.street:
|
||||
# record.contact_address_complete += record.street + ', '
|
||||
# if record.zip:
|
||||
# record.contact_address_complete += record.zip + ' '
|
||||
# if record.city:
|
||||
# record.contact_address_complete += record.city + ', '
|
||||
# if record.state_id:
|
||||
# record.contact_address_complete += record.state_id.name + ', '
|
||||
# if record.country_id:
|
||||
# record.contact_address_complete += record.country_id.name
|
||||
# record.contact_address_complete = record.contact_address_complete.strip().strip(',')
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
|
|
@ -41,7 +45,10 @@
|
|||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg,
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
|
|
@ -49,8 +56,15 @@
|
|||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
|
|
@ -162,9 +176,6 @@
|
|||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-tile {
|
||||
will-change: opacity;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
|
|
@ -179,9 +190,10 @@
|
|||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
|
|
@ -237,7 +249,8 @@
|
|||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive {
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
|
@ -246,13 +259,7 @@
|
|||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline: 0;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-container a.leaflet-active {
|
||||
outline: 2px solid orange;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
|
|
@ -262,7 +269,10 @@
|
|||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -272,8 +282,7 @@
|
|||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-bar a:hover {
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
|
|
@ -290,7 +299,8 @@
|
|||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover {
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
|
|
@ -380,6 +390,8 @@
|
|||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
|
|
@ -388,7 +400,7 @@
|
|||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path {
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
|
@ -397,23 +409,27 @@
|
|||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover {
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-container .leaflet-control-attribution,
|
||||
.leaflet-container .leaflet-control-scale {
|
||||
font-size: 11px;
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
|
|
@ -426,14 +442,11 @@
|
|||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
|
|
@ -469,17 +482,22 @@
|
|||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 19px;
|
||||
line-height: 1.4;
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 18px 0;
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
|
@ -490,6 +508,7 @@
|
|||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
|
|
@ -506,28 +525,25 @@
|
|||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 4px 4px 0 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 18px;
|
||||
height: 14px;
|
||||
font: 16px/14px Tahoma, Verdana, sans-serif;
|
||||
color: #c3c3c3;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover {
|
||||
color: #999;
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
zoom: 1;
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
|
|
@ -536,9 +552,6 @@
|
|||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip-container {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
|
|
@ -573,7 +586,7 @@
|
|||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-clickable {
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
|
@ -633,3 +646,13 @@
|
|||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.o_map_view {
|
||||
height: 100%;
|
||||
|
||||
.o_content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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*/"};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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*/"};
|
||||
}
|
||||
|
|
@ -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
|
|
@ -1,44 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from odoo.loglevels import ustr
|
||||
from odoo.tools import misc, view_validation
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_map_view_validator = None
|
||||
|
||||
|
||||
@view_validation.validate('map')
|
||||
def schema_map_view(arch, **kwargs):
|
||||
global _map_view_validator
|
||||
|
||||
if _map_view_validator is None:
|
||||
with misc.file_open('odex30_web_map/views/odex30_web_map.rng') as f:
|
||||
with misc.file_open(os.path.join('web_map', 'views', 'odex30_web_map.rng')) as f:
|
||||
_map_view_validator = etree.RelaxNG(etree.parse(f))
|
||||
|
||||
if _map_view_validator.validate(arch):
|
||||
return True
|
||||
|
||||
for error in _map_view_validator.error_log:
|
||||
_logger.error(ustr(error))
|
||||
_logger.error("%s", error)
|
||||
return False
|
||||
|
||||
# @view_validation.validate('map')
|
||||
# def schema_map_view(arch, **kwargs):
|
||||
# global _map_view_validator
|
||||
#
|
||||
# if _map_view_validator is None:
|
||||
# with misc.file_open(os.path.join('odex30_web_map', 'views', 'odex30_web_map.rng')) as f:
|
||||
# _map_view_validator = etree.RelaxNG(etree.parse(f))
|
||||
#
|
||||
# if _map_view_validator.validate(arch):
|
||||
# return True
|
||||
#
|
||||
# for error in _map_view_validator.error_log:
|
||||
# _logger.error(ustr(error))
|
||||
# return False
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@
|
|||
<optional>
|
||||
<attribute name="default_order"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="default_group_by"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="allow_resequence"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="routing"/>
|
||||
</optional>
|
||||
|
|
@ -30,6 +36,9 @@
|
|||
<optional>
|
||||
<attribute name="limit"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="js_class"/>
|
||||
</optional>
|
||||
<zeroOrMore>
|
||||
<optional>
|
||||
<ref name="field"/>
|
||||
|
|
|
|||
|
|
@ -6,25 +6,18 @@
|
|||
<field name="priority" eval ="46"/>
|
||||
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='module_base_geolocalize']" position="after">
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="token_map_view">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class ="o_form_label">Map Routes</span>
|
||||
<div class ="content-group">
|
||||
<div class ="text-muted mb8">
|
||||
Set a MapBox account to activate routes and style
|
||||
</div>
|
||||
<label for="map_box_token" string="Token"/>
|
||||
<field name ="map_box_token"/>
|
||||
<div class ="text-rigth" style="position:relative;">
|
||||
<a class="oe_link" href="https://www.mapbox.com/" target="_blank">
|
||||
<i class="fa fa-arrow-right"/>
|
||||
Get token
|
||||
</a>
|
||||
</div>
|
||||
<xpath expr="//setting[@id='base_geolocalize']" position="after">
|
||||
<setting id="token_map_view" string="Map Routes" help="Set a MapBox account to activate routes and style">
|
||||
<div class ="content-group">
|
||||
<label for="map_box_token" string="Token" class="mr8"/>
|
||||
<field name ="map_box_token"/>
|
||||
<div class="text-start" style="position:relative;">
|
||||
<a class="oe_link" href="https://account.mapbox.com/auth/signup/" target="_blank">
|
||||
<i class="oi oi-arrow-right"/> Sign up to MapBox to get a free token
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,21 @@
|
|||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="lang" position="after">
|
||||
<field name="partner_longitude" invisible="1"/>
|
||||
<field name="partner_latitude" invisible="1"/>
|
||||
</field>
|
||||
<sheet position="inside">
|
||||
<field name="partner_longitude" invisible="1"/> <!-- Used by the MapModel JavaScript -->
|
||||
<field name="partner_latitude" invisible="1"/> <!-- Used by the MapModel JavaScript -->
|
||||
</sheet>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_res_partner_filter_inherit_map" model="ir.ui.view">
|
||||
<field name="name">res.partner.search.inherit.map</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_res_partner_filter" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="contact_address_complete" string="Address"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
Loading…
Reference in New Issue