合并企业版代码(未测试,先提交到测试分支)
This commit is contained in:
19
web_map/static/img/pin-circle.svg
Normal file
19
web_map/static/img/pin-circle.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 997 B |
17
web_map/static/img/pin-no-circle.svg
Normal file
17
web_map/static/img/pin-no-circle.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 776 B |
70
web_map/static/src/map_view/map_arch_parser.js
Normal file
70
web_map/static/src/map_view/map_arch_parser.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { unique } from "@web/core/utils/arrays";
|
||||
import { XMLParser } from "@web/core/utils/xml";
|
||||
import { archParseBoolean } from "@web/views/utils";
|
||||
|
||||
export class MapArchParser extends XMLParser {
|
||||
parse(arch) {
|
||||
const archInfo = {
|
||||
fieldNames: [],
|
||||
fieldNamesMarkerPopup: [],
|
||||
};
|
||||
|
||||
this.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 = archParseBoolean(node.getAttribute("routing"));
|
||||
}
|
||||
if (node.hasAttribute("hide_title")) {
|
||||
archInfo.hideTitle = archParseBoolean(node.getAttribute("hide_title"));
|
||||
}
|
||||
if (node.hasAttribute("hide_address")) {
|
||||
archInfo.hideAddress = archParseBoolean(node.getAttribute("hide_address"));
|
||||
}
|
||||
if (node.hasAttribute("hide_name")) {
|
||||
archInfo.hideName = archParseBoolean(node.getAttribute("hide_name"));
|
||||
}
|
||||
if (!archInfo.hideName) {
|
||||
archInfo.fieldNames.push("display_name");
|
||||
}
|
||||
if (node.hasAttribute("default_order")) {
|
||||
archInfo.defaultOrder = {
|
||||
name: node.getAttribute("default_order"),
|
||||
asc: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
visitField(node, params) {
|
||||
params.fieldNames.push(node.getAttribute("name"));
|
||||
params.fieldNamesMarkerPopup.push({
|
||||
fieldName: node.getAttribute("name"),
|
||||
string: node.getAttribute("string"),
|
||||
});
|
||||
}
|
||||
}
|
||||
124
web_map/static/src/map_view/map_controller.js
Normal file
124
web_map/static/src/map_view/map_controller.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { loadJS, loadCSS } from "@web/core/assets";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useModel } from "@web/views/model";
|
||||
import { standardViewProps } from "@web/views/standard_view_props";
|
||||
import { useSetupView } from "@web/views/view_hook";
|
||||
import { Layout } from "@web/search/layout";
|
||||
import { usePager } from "@web/search/pager_hook";
|
||||
|
||||
const { Component, onWillUnmount, onWillStart } = owl;
|
||||
|
||||
export class MapController extends Component {
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
|
||||
/** @type {typeof MapModel} */
|
||||
const Model = this.props.Model;
|
||||
const model = useModel(Model, this.props.modelParams);
|
||||
this.model = model;
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.model.stopFetchingCoordinates();
|
||||
});
|
||||
|
||||
useSetupView({
|
||||
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 }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {any}
|
||||
*/
|
||||
get rendererProps() {
|
||||
return {
|
||||
model: this.model,
|
||||
onMarkerClick: this.openRecords.bind(this),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get googleMapUrl() {
|
||||
let url = "https://www.google.com/maps/dir/?api=1";
|
||||
if (this.model.data.records.length) {
|
||||
const allCoordinates = this.model.data.records.filter(
|
||||
({ partner }) => partner && partner.partner_latitude && partner.partner_longitude
|
||||
);
|
||||
const uniqueCoordinates = allCoordinates.reduce((coords, { partner }) => {
|
||||
const coord = partner.partner_latitude + "," + partner.partner_longitude;
|
||||
if (!coords.includes(coord)) {
|
||||
coords.push(coord);
|
||||
}
|
||||
return coords;
|
||||
}, []);
|
||||
if (uniqueCoordinates.length && this.model.metaData.routing) {
|
||||
// When routing is enabled, make last record the destination
|
||||
url += `&destination=${uniqueCoordinates.pop()}`;
|
||||
}
|
||||
if (uniqueCoordinates.length) {
|
||||
url += `&waypoints=${uniqueCoordinates.join("|")}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to views when clicked on open button in marker popup.
|
||||
*
|
||||
* @param {number[]} ids
|
||||
*/
|
||||
openRecords(ids) {
|
||||
if (ids.length > 1) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: this.env.config.getDisplayName() || this.env._t("Untitled"),
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
res_model: this.props.resModel,
|
||||
domain: [["id", "in", ids]],
|
||||
});
|
||||
} else {
|
||||
this.action.switchView("form", {
|
||||
resId: ids[0],
|
||||
mode: "readonly",
|
||||
model: this.props.resModel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MapController.template = "web_map.MapView";
|
||||
|
||||
MapController.components = {
|
||||
Layout,
|
||||
};
|
||||
|
||||
MapController.props = {
|
||||
...standardViewProps,
|
||||
Model: Function,
|
||||
modelParams: Object,
|
||||
Renderer: Function,
|
||||
buttonTemplate: String,
|
||||
};
|
||||
7
web_map/static/src/map_view/map_controller.scss
Normal file
7
web_map/static/src/map_view/map_controller.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.o_map_view {
|
||||
height: 100%;
|
||||
|
||||
.o_content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
21
web_map/static/src/map_view/map_controller.xml
Normal file
21
web_map/static/src/map_view/map_controller.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web_map.MapView" owl="1">
|
||||
<div t-att-class="props.className">
|
||||
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''" display="props.display">
|
||||
<t t-set-slot="layout-buttons">
|
||||
<t t-call="{{ props.buttonTemplate }}"/>
|
||||
</t>
|
||||
<t t-component="props.Renderer" t-props="rendererProps" />
|
||||
</Layout>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web_map.MapView.Buttons" owl="1">
|
||||
<div class="o-map-view--buttons">
|
||||
<a class="btn btn-primary text-white" t-att-href="googleMapUrl" target="_blank" data-hotkey="m">View in Google Maps</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
631
web_map/static/src/map_view/map_model.js
Normal file
631
web_map/static/src/map_view/map_model.js
Normal file
@@ -0,0 +1,631 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Model } from "@web/views/model";
|
||||
import { session } from "@web/session";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { parseDate, parseDateTime } from "@web/core/l10n/dates";
|
||||
import { KeepLast } from "@web/core/utils/concurrency";
|
||||
|
||||
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,
|
||||
partnerIds: [],
|
||||
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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Protected
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds the corresponding partner to a record.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
_addPartnerToRecord(metaData, data) {
|
||||
for (const record of data.records) {
|
||||
for (const partner of data.partners) {
|
||||
let recordPartnerId;
|
||||
if (metaData.resModel === "res.partner" && metaData.resPartnerField === "id") {
|
||||
recordPartnerId = record.id;
|
||||
} else {
|
||||
recordPartnerId = record[metaData.resPartnerField][0];
|
||||
}
|
||||
|
||||
if (recordPartnerId == partner.id) {
|
||||
record.partner = partner;
|
||||
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,
|
||||
partnerIds: [],
|
||||
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));
|
||||
data.records = results.records;
|
||||
data.count = results.length;
|
||||
if (data.isGrouped) {
|
||||
data.recordGroups = await this._getRecordGroups(metaData, data);
|
||||
} else {
|
||||
data.recordGroups = [];
|
||||
}
|
||||
|
||||
data.partnerIds = [];
|
||||
if (metaData.resModel === "res.partner" && metaData.resPartnerField === "id") {
|
||||
for (const record of data.records) {
|
||||
data.partnerIds.push(record.id);
|
||||
record.partner_id = [record.id];
|
||||
}
|
||||
} else {
|
||||
this._fillPartnerIds(metaData, data);
|
||||
}
|
||||
|
||||
data.partnerIds = [...new Set(data.partnerIds)];
|
||||
await this._partnerFetching(metaData, data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the records for a given model.
|
||||
*
|
||||
* @protected
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_fetchRecordData(metaData, data) {
|
||||
const fields = data.groupByKey
|
||||
? metaData.fieldNames.concat(data.groupByKey.split(":")[0])
|
||||
: metaData.fieldNames;
|
||||
const orderBy = [];
|
||||
if (metaData.defaultOrder) {
|
||||
orderBy.push(metaData.defaultOrder.name);
|
||||
if (metaData.defaultOrder.asc) {
|
||||
orderBy.push("ASC");
|
||||
}
|
||||
}
|
||||
return this.orm.webSearchRead(metaData.resModel, metaData.domain, fields, {
|
||||
limit: metaData.limit,
|
||||
offset: metaData.offset,
|
||||
order: orderBy.join(" "),
|
||||
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/${address}?format=jsonv2`;
|
||||
return this.http.get(encodedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {number[]} ids contains the ids from the partners
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_fetchRecordsPartner(metaData, data, ids) {
|
||||
const domain = [
|
||||
["contact_address_complete", "!=", "False"],
|
||||
["id", "in", ids],
|
||||
];
|
||||
const fields = ["contact_address_complete", "partner_latitude", "partner_longitude"];
|
||||
return this.orm.searchRead("res.partner", domain, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {Object[]} records the records that are going to be filtered
|
||||
*/
|
||||
_fillPartnerIds(metaData, data) {
|
||||
for (const record of data.records) {
|
||||
if (record[metaData.resPartnerField]) {
|
||||
data.partnerIds.push(record[metaData.resPartnerField][0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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": this.env._t(
|
||||
"Too many routing points (maximum 25)"
|
||||
),
|
||||
"Route exceeds maximum distance limitation": this.env._t(
|
||||
"Some routing points are too far apart"
|
||||
),
|
||||
"Too Many Requests": this.env._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 groups = {};
|
||||
const idToFetch = {};
|
||||
const fieldType = metaData.fields[fieldName].type;
|
||||
for (const record of data.records) {
|
||||
const value = record[fieldName];
|
||||
let id, name;
|
||||
if (["date", "datetime"].includes(fieldType) && value) {
|
||||
const date = fieldType === "date" ? parseDate(value) : parseDateTime(value);
|
||||
id = name = date.toFormat(DATE_GROUP_FORMATS[subGroup]);
|
||||
} else if (fieldType === "boolean") {
|
||||
id = name = value ? this.env._t("Yes") : this.env._t("No");
|
||||
} else {
|
||||
id = Array.isArray(value) ? value[0] : value;
|
||||
name = Array.isArray(value) ? value[1] : value;
|
||||
}
|
||||
|
||||
if (id === false && name === false) {
|
||||
id = name = this.env._t("None");
|
||||
}
|
||||
|
||||
if (["many2many", "one2many"].includes(fieldType) && value.length) {
|
||||
for (const m2mId of value) {
|
||||
idToFetch[m2mId] = undefined;
|
||||
}
|
||||
} else if (!groups[id]) {
|
||||
groups[id] = {
|
||||
name,
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
if (!["many2many", "one2many"].includes(fieldType) || !value.length) {
|
||||
groups[id].records.push(record);
|
||||
}
|
||||
}
|
||||
if (["many2many", "one2many"].includes(fieldType)) {
|
||||
const m2mList = await this.orm.nameGet(
|
||||
metaData.fields[fieldName].relation,
|
||||
Object.keys(idToFetch).map(Number)
|
||||
);
|
||||
for (const [m2mId, m2mName] of m2mList) {
|
||||
idToFetch[m2mId] = m2mName;
|
||||
}
|
||||
|
||||
for (const record of data.records) {
|
||||
for (const m2mId of record[fieldName]) {
|
||||
if (!groups[m2mId]) {
|
||||
groups[m2mId] = {
|
||||
name: idToFetch[m2mId],
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
groups[m2mId].records.push(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 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 =
|
||||
coordinates.features[0].geometry.coordinates[0];
|
||||
partner.partner_latitude =
|
||||
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(
|
||||
this.env._t(
|
||||
"The view has switched to another provider but functionalities will be limited"
|
||||
),
|
||||
{
|
||||
title: this.env._t("Token invalid"),
|
||||
type: "danger",
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 403:
|
||||
this.notification.add(
|
||||
this.env._t(
|
||||
"The view has switched to another provider but functionalities will be limited"
|
||||
),
|
||||
{
|
||||
title: this.env._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(
|
||||
this.env._t(
|
||||
"The view has switched to another provider but functionalities will be limited"
|
||||
),
|
||||
{
|
||||
title: this.env._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 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 = coordinates[0].lon;
|
||||
partner.partner_latitude = 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 (_e) {
|
||||
for (const partner of data.partners) {
|
||||
partner.fetchingCoordinate = false;
|
||||
}
|
||||
data.fetchingCoordinates = false;
|
||||
this.shouldFetchCoordinates = false;
|
||||
this.notification.add(
|
||||
this.env._t("OpenStreetMap's request limit exceeded, try again later."),
|
||||
{ type: "danger" }
|
||||
);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
};
|
||||
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.
|
||||
*
|
||||
* @protected
|
||||
* @param {number[]} partnerIds this array contains the ids from the partner that are linked to records
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _partnerFetching(metaData, data) {
|
||||
data.partners = data.partnerIds.length
|
||||
? await this.keepLast.add(this._fetchRecordsPartner(metaData, data, data.partnerIds))
|
||||
: [];
|
||||
this._addPartnerToRecord(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;
|
||||
394
web_map/static/src/map_view/map_renderer.js
Normal file
394
web_map/static/src/map_view/map_renderer.js
Normal file
@@ -0,0 +1,394 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/*global L*/
|
||||
|
||||
import { renderToString } from "@web/core/utils/render";
|
||||
|
||||
const { Component, onWillUnmount, onWillUpdateProps, useEffect, useRef, useState } = 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 {
|
||||
setup() {
|
||||
this.leafletMap = null;
|
||||
this.markers = [];
|
||||
this.polylines = [];
|
||||
this.mapContainerRef = useRef("mapContainer");
|
||||
this.state = useState({
|
||||
closedGroupIds: [],
|
||||
});
|
||||
this.nextId = 1;
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
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.model.metaData.mapBoxToken,
|
||||
}).addTo(this.leafletMap);
|
||||
},
|
||||
() => []
|
||||
);
|
||||
useEffect(() => {
|
||||
this.updateMap();
|
||||
});
|
||||
|
||||
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].ids.push(record.id);
|
||||
} else {
|
||||
pinInSamePlace[lat_long] = ++pinInSamePlace[lat_long] || 0;
|
||||
markersInfo[key] = {
|
||||
record: record,
|
||||
ids: [record.id],
|
||||
pinInSamePlace: pinInSamePlace[lat_long],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
return record.id === markerInfo.record.id;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 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 popupFields = this.getMarkerPopupFields(markerInfo);
|
||||
const partner = markerInfo.record.partner;
|
||||
const popupHtml = renderToString("web_map.markerPopup", {
|
||||
fields: popupFields,
|
||||
hasFormView: this.props.model.metaData.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 + 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 = () => {
|
||||
this.props.onMarkerClick(markerInfo.ids);
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
getMarkerPopupFields(markerInfo) {
|
||||
const record = markerInfo.record;
|
||||
const fieldsView = [];
|
||||
// Only display address in multi coordinates marker popup
|
||||
if (markerInfo.ids.length > 1) {
|
||||
if (!this.props.model.metaData.hideAddress) {
|
||||
fieldsView.push({
|
||||
id: this.nextId++,
|
||||
value: record.partner.contact_address_complete,
|
||||
string: this.env._t("Address"),
|
||||
});
|
||||
}
|
||||
return fieldsView;
|
||||
}
|
||||
if (!this.props.model.metaData.hideName) {
|
||||
fieldsView.push({
|
||||
id: this.nextId++,
|
||||
value: record.display_name,
|
||||
string: this.env._t("Name"),
|
||||
});
|
||||
}
|
||||
if (!this.props.model.metaData.hideAddress) {
|
||||
fieldsView.push({
|
||||
id: this.nextId++,
|
||||
value: record.partner.contact_address_complete,
|
||||
string: this.env._t("Address"),
|
||||
});
|
||||
}
|
||||
for (const field of this.props.model.metaData.fieldNamesMarkerPopup) {
|
||||
if (record[field.fieldName]) {
|
||||
const fieldName =
|
||||
record[field.fieldName] instanceof Array
|
||||
? record[field.fieldName][1]
|
||||
: record[field.fieldName];
|
||||
fieldsView.push({
|
||||
id: this.nextId++,
|
||||
value: fieldName,
|
||||
string: field.string,
|
||||
});
|
||||
}
|
||||
}
|
||||
return fieldsView;
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
/**
|
||||
* @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.template = "web_map.MapRenderer";
|
||||
MapRenderer.props = {
|
||||
model: Object,
|
||||
onMarkerClick: Function,
|
||||
};
|
||||
184
web_map/static/src/map_view/map_renderer.scss
Normal file
184
web_map/static/src/map_view/map_renderer.scss
Normal file
@@ -0,0 +1,184 @@
|
||||
$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%;
|
||||
|
||||
&--container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&--popup-table {
|
||||
vertical-align: top;
|
||||
|
||||
&-content-name {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
line-height: $map-table-row-padding;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
&-content-value {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
&-space {
|
||||
padding-left: $map-table-line-padding;
|
||||
}
|
||||
}
|
||||
|
||||
&--popup-buttons {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
align-items: flex-end;
|
||||
margin-top: 8px;
|
||||
|
||||
&-divider {
|
||||
width: 5px;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&--pin-list {
|
||||
&-container {
|
||||
padding: 8px 8px 8px 22px !important;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&-group {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 1rem;
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
> .o-map-renderer--pin-list-details {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
padding: 8px 0;
|
||||
text-transform: uppercase;
|
||||
color: $headings-color;
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-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-map-renderer--pin-located:hover {
|
||||
text-decoration: none;
|
||||
background-color: $o-gray-100;
|
||||
}
|
||||
|
||||
&:not(.o-map-renderer--pin-located) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
position: relative;
|
||||
top: -40px;
|
||||
color: $map-number-color;
|
||||
font-size: $map-number-font-size;
|
||||
text-align: center;
|
||||
margin-top: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
&--alert {
|
||||
@include o-position-absolute($top: 0);
|
||||
width: 100%;
|
||||
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-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*/"};
|
||||
}
|
||||
217
web_map/static/src/map_view/map_renderer.xml
Normal file
217
web_map/static/src/map_view/map_renderer.xml
Normal file
@@ -0,0 +1,217 @@
|
||||
<?xml version ="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web_map.MapRenderer" owl="1">
|
||||
<div class="o-map-renderer row g-0">
|
||||
<t t-if="props.model.data.routingError">
|
||||
<t t-call="web_map.MapRenderer.RountingUnavailable"/>
|
||||
</t>
|
||||
<t t-elif="props.model.metaData.routing and !props.model.data.useMapBoxAPI">
|
||||
<t t-call="web_map.MapRenderer.NoMapToken"/>
|
||||
</t>
|
||||
<t t-if="props.model.data.fetchingCoordinates">
|
||||
<t t-call="web_map.MapRenderer.FetchingCoordinates"/>
|
||||
</t>
|
||||
<div class="o-map-renderer--container col-md-12 col-lg-10" t-ref="mapContainer"/>
|
||||
<t t-call="web_map.MapRenderer.PinListContainer"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web_map.MapRenderer.FetchingCoordinates" owl="1">
|
||||
<div class="alert alert-info col-md-12 col-lg-10 pe-5 ps-5 mb-0 text-center 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" owl="1">
|
||||
<div class="alert alert-info alert-dismissible col-md-12 col-lg-10 pe-5 ps-5 mb-0 text-center o-map-renderer--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="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web_map.MapRenderer.PinListContainer" owl="1">
|
||||
<div class="o-map-renderer--pin-list-container d-none d-lg-block col-2 bg-view border-start cursor-default">
|
||||
<t t-if="!props.model.metaData.hideTitle">
|
||||
<div class="o-map-renderer--pin-list-header o_pin_list_header">
|
||||
<header>
|
||||
<i class="fa fa-list text-odoo"/>
|
||||
<span class="fs-6 fw-bold" t-out="props.model.metaData.panelTitle"/>
|
||||
</header>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="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">
|
||||
<t t-set="group" t-value="props.model.data.recordGroups[groupId]"/>
|
||||
<div class="o-map-renderer--pin-list-group-header" t-on-click="() => this.toggleGroup(groupId)">
|
||||
<i t-attf-class="fa fa-caret-{{ state.closedGroupIds.includes(groupId) ? 'right' : 'down' }}"/>
|
||||
<span 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="web_map.MapRenderer.PinList">
|
||||
<t t-set="records" t-value="group.records"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="web_map.MapRenderer.PinList">
|
||||
<t t-set="records" t-value="props.model.data.records"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web_map.MapRenderer.PinList" owl="1">
|
||||
<t t-tag="props.model.metaData.numbering ? 'ol' : 'ul'" class="o-map-renderer--pin-list-details">
|
||||
<t t-call="web_map.MapRenderer.PinListItems"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="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-map-renderer--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="() => this.centerAndOpenPin(record)">
|
||||
<t t-esc="record.display_name"/>
|
||||
</a>
|
||||
<t t-else="">
|
||||
<span class="text-muted" t-esc="record.display_name"/>
|
||||
<span class="float-end" t-if="record.partner and record.partner.fetchingCoordinate">
|
||||
<i class="fa fa-spin fa-circle-o-notch"/>
|
||||
</span>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="web_map.MapRenderer.RountingUnavailable" owl="1">
|
||||
<div class="alert alert-warning alert-dismissible col-md-12 col-lg-10 pe-5 ps-5 mb-0 text-center 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" owl="1">
|
||||
<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" owl="1">
|
||||
<span class="badge text-bg-danger rounded-pill o-map-renderer--marker-badge" t-att-style="color and `background-color: ${color} !important`">
|
||||
<t t-esc="count"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<t t-name="web_map.markerNumber" owl="1">
|
||||
<p class="o-map-renderer--marker-number">
|
||||
<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" owl="1">
|
||||
<div>
|
||||
<table class="o-map-renderer--popup-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2"></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="fields" t-as="field" t-key="field.id">
|
||||
<td class="o-map-renderer--popup-table-content-name">
|
||||
<t t-esc="field.string"/>
|
||||
</td>
|
||||
<td class="o-map-renderer--popup-table-space"></td>
|
||||
<td class="o-map-renderer--popup-table-content-value">
|
||||
<t t-esc="field.value"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="o-map-renderer--popup-buttons 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"/>
|
||||
<a class="btn btn-primary text-white" t-att-href="url" target="_blank">
|
||||
navigate to
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web_map.pinSVG" owl="1">
|
||||
<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" owl="1">
|
||||
<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" owl="1">
|
||||
<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>
|
||||
60
web_map/static/src/map_view/map_view.js
Normal file
60
web_map/static/src/map_view/map_view.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } 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",
|
||||
display_name: _lt("Map"),
|
||||
icon: "fa fa-map-marker",
|
||||
multiRecord: true,
|
||||
isMobileFriendly: true,
|
||||
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 = {
|
||||
context: context,
|
||||
defaultOrder: archInfo.defaultOrder,
|
||||
fieldNames: archInfo.fieldNames,
|
||||
fieldNamesMarkerPopup: archInfo.fieldNamesMarkerPopup,
|
||||
fields: 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() || _lt("Items"),
|
||||
resModel: 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);
|
||||
73
web_map/static/tests/map_view/map_view_mobile_tests.js
Normal file
73
web_map/static/tests/map_view/map_view_mobile_tests.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { createWebClient, getActionManagerServerData, doAction } from "@web/../tests/webclient/helpers";
|
||||
import { getFixture } from "@web/../tests/helpers/utils";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module('WebMap Mobile', {
|
||||
beforeEach() {
|
||||
serverData = getActionManagerServerData();
|
||||
target = getFixture();
|
||||
Object.assign(serverData, {
|
||||
actions: {
|
||||
1: {
|
||||
id: 1,
|
||||
name: 'Task Action 1',
|
||||
res_model: 'project.task',
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'list'], [false, 'map'], [false, 'kanban'], [false, 'form']],
|
||||
},
|
||||
},
|
||||
views: {
|
||||
'project.task,false,map': `
|
||||
<map res_partner="partner_id" routing="1">
|
||||
<field name="name" string="Project"/>
|
||||
</map>`,
|
||||
'project.task,false,list': '<tree><field name="name"/></tree>',
|
||||
'project.task,false,kanban': `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
'project.task,false,form':
|
||||
`<form>
|
||||
<group>
|
||||
<field name="display_name"/>
|
||||
</group>
|
||||
</form>`,
|
||||
'project.task,false,search': '<search><field name="name" string="Project"/></search>',
|
||||
},
|
||||
models: {
|
||||
'project.task': {
|
||||
fields: {
|
||||
display_name: { string: "name", type: "char" },
|
||||
sequence: { string: "sequence", type: "integer" },
|
||||
partner_id: {
|
||||
string: "partner",
|
||||
type: "many2one",
|
||||
relation: "res.partner",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test("uses a Map(first mobile-friendly) view by default", async function (assert) {
|
||||
const webClient = await createWebClient({ serverData });
|
||||
// should open Map(first mobile-friendly) view for action
|
||||
await doAction(webClient, 1);
|
||||
|
||||
assert.containsNone(target, '.o_list_view');
|
||||
assert.containsNone(target, '.o_kanban_view');
|
||||
assert.containsOnce(target, '.o_map_view');
|
||||
|
||||
});
|
||||
2538
web_map/static/tests/map_view/map_view_tests.js
Normal file
2538
web_map/static/tests/map_view/map_view_tests.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user