合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

View 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

View 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

View 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"),
});
}
}

View 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,
};

View File

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

View 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>

View 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;

View 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,
};

View 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*/"};
}

View 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>

View 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);

View 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');
});

File diff suppressed because it is too large Load Diff