diff --git a/addons/account/static/src/js/tours/account.js b/addons/account/static/src/js/tours/account.js
index e854f1c3bd0..8410249f275 100644
--- a/addons/account/static/src/js/tours/account.js
+++ b/addons/account/static/src/js/tours/account.js
@@ -2,6 +2,7 @@ odoo.define('account.tour', function(require) {
"use strict";
var core = require('web.core');
+const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
var _t = core._t;
@@ -36,7 +37,7 @@ tour.register('account_tour', {
}, {
trigger: "div[name=partner_id] input",
extra_trigger: "[name=move_type][raw-value=out_invoice]",
- content: _t("Write a company name to create one or see suggestions."),
+ content: Markup(_t("Write a company name to create one or see suggestions.")),
position: "bottom",
}, {
trigger: ".o_m2o_dropdown_option a:contains('Create')",
@@ -46,7 +47,7 @@ tour.register('account_tour', {
}, {
trigger: ".modal-content button.btn-primary",
extra_trigger: "[name=move_type][raw-value=out_invoice]",
- content: _t("Once everything is set, you are good to continue. You will be able to edit this later in the Customers menu."),
+ content: Markup(_t("Once everything is set, you are good to continue. You will be able to edit this later in the Customers menu.")),
auto: true,
}, {
trigger: "div[name=invoice_line_ids] .o_field_x2many_list_row_add a:not([data-context])",
@@ -74,7 +75,7 @@ tour.register('account_tour', {
}, {
trigger: "input[name=email]",
extra_trigger: "[name=move_type][raw-value=out_invoice]",
- content: _t("Write here your own email address to test the flow."),
+ content: Markup(_t("Write here your own email address to test the flow.")),
run: 'text customer@example.com',
auto: true,
}, {
diff --git a/addons/account/views/account_payment_term_views.xml b/addons/account/views/account_payment_term_views.xml
index f8958bc57f5..e88df582e41 100644
--- a/addons/account/views/account_payment_term_views.xml
+++ b/addons/account/views/account_payment_term_views.xml
@@ -118,7 +118,7 @@
-
+
diff --git a/addons/calendar/__manifest__.py b/addons/calendar/__manifest__.py
index f44f1abeca5..a10644bcdca 100644
--- a/addons/calendar/__manifest__.py
+++ b/addons/calendar/__manifest__.py
@@ -45,7 +45,6 @@ If you need to manage your meetings, you should install the CRM module.
'calendar/static/src/js/calendar_controller.js',
'calendar/static/src/js/calendar_model.js',
'calendar/static/src/js/calendar_view.js',
- 'calendar/static/src/js/mail_activity.js',
'calendar/static/src/js/systray_activity_menu.js',
'calendar/static/src/js/services/calendar_notification_service.js',
],
diff --git a/addons/calendar/static/src/js/mail_activity.js b/addons/calendar/static/src/js/mail_activity.js
deleted file mode 100644
index b25cc663204..00000000000
--- a/addons/calendar/static/src/js/mail_activity.js
+++ /dev/null
@@ -1,60 +0,0 @@
-odoo.define('calendar.Activity', function (require) {
-"use strict";
-
-var Activity = require('@mail/js/activity')[Symbol.for("default")];
-var Dialog = require('web.Dialog');
-var core = require('web.core');
-var _t = core._t;
-
-Activity.include({
-
- /**
- * Override behavior to redirect to calendar event instead of activity
- *
- * @override
- */
- _onEditActivity: function (event, options) {
- var self = this;
- var activity_id = $(event.currentTarget).data('activity-id');
- var activity = _.find(this.activities, function (act) { return act.id === activity_id; });
- if (activity && activity.activity_category === 'meeting' && activity.calendar_event_id) {
- return self._super(event, _.extend({
- res_model: 'calendar.event',
- res_id: activity.calendar_event_id[0],
- }));
- }
- return self._super(event, options);
- },
-
- /**
- * Override behavior to warn that the calendar event is about to be removed as well
- *
- * @override
- */
- _onUnlinkActivity: function (event, options) {
- event.preventDefault();
- var self = this;
- var activity_id = $(event.currentTarget).data('activity-id');
- var activity = _.find(this.activities, function (act) { return act.id === activity_id; });
- if (activity && activity.activity_category === 'meeting' && activity.calendar_event_id) {
- Dialog.confirm(
- self,
- _t("The activity is linked to a meeting. Deleting it will remove the meeting as well. Do you want to proceed ?"), {
- confirm_callback: function () {
- return self._rpc({
- model: 'mail.activity',
- method: 'unlink_w_meeting',
- args: [[activity_id]],
- })
- .then(self._reload.bind(self, {activity: true}));
- },
- }
- );
- }
- else {
- return self._super(event, options);
- }
- },
-});
-
-});
diff --git a/addons/crm/static/src/js/tours/crm.js b/addons/crm/static/src/js/tours/crm.js
index 8c2e13506fb..12893ba0f15 100644
--- a/addons/crm/static/src/js/tours/crm.js
+++ b/addons/crm/static/src/js/tours/crm.js
@@ -2,6 +2,7 @@ odoo.define('crm.tour', function(require) {
"use strict";
var core = require('web.core');
+const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
var _t = core._t;
@@ -12,22 +13,22 @@ tour.register('crm_tour', {
sequence: 10,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
- content: _t('Ready to boost your sales? Let\'s have a look at your Pipeline.'),
+ content: Markup(_t('Ready to boost your sales? Let\'s have a look at your Pipeline.')),
position: 'bottom',
edition: 'community',
}, {
trigger: '.o_app[data-menu-xmlid="crm.crm_menu_root"]',
- content: _t('Ready to boost your sales? Let\'s have a look at your Pipeline.'),
+ content: Markup(_t('Ready to boost your sales? Let\'s have a look at your Pipeline.')),
position: 'bottom',
edition: 'enterprise',
}, {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_opportunity_kanban',
- content: _t("Create your first opportunity."),
+ content: Markup(_t("Create your first opportunity.")),
position: 'bottom',
}, {
trigger: ".o_kanban_quick_create .o_field_widget[name='partner_id']",
- content: _t('Write a few letters to look for a company, or create a new one.'),
+ content: Markup(_t('Write a few letters to look for a company, or create a new one.')),
position: "top",
run: function (actions) {
actions.text("Brandon Freeman", this.$anchor.find("input"));
@@ -38,28 +39,28 @@ tour.register('crm_tour', {
in_modal: false,
}, {
trigger: ".o_kanban_quick_create .o_kanban_add",
- content: _t("Now, add your Opportunity to your Pipeline."),
+ content: Markup(_t("Now, add your Opportunity to your Pipeline.")),
position: "bottom",
}, {
trigger: ".o_opportunity_kanban .o_kanban_group:first-child .o_kanban_record:last-child .oe_kanban_content",
extra_trigger: ".o_opportunity_kanban",
- content: _t("Drag & drop opportunities between columns as you progress in your sales cycle."),
+ content: Markup(_t("Drag & drop opportunities between columns as you progress in your sales cycle.")),
position: "right",
run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(2) ",
}, {
trigger: ".o_kanban_record:not(.o_updating) .o_activity_color_default",
extra_trigger: ".o_opportunity_kanban",
- content: _t("Looks like nothing is planned. :(
Tip : Schedule activities to keep track of everything you have to do!"),
+ content: Markup(_t("Looks like nothing is planned. :(
Tip : Schedule activities to keep track of everything you have to do!")),
position: "bottom",
}, {
trigger: ".o_schedule_activity",
extra_trigger: ".o_opportunity_kanban",
- content: _t("Let's Schedule an Activity."),
+ content: Markup(_t("Let's Schedule an Activity.")),
position: "bottom",
width: 200,
}, {
trigger: '.modal-footer button[name="action_close_dialog"]',
- content: _t("All set. Let’s Schedule it."),
+ content: Markup(_t("All set. Let’s Schedule it.")),
position: "top", // dot NOT move to bottom, it would cause a resize flicker, see task-2476595
run: function (actions) {
actions.auto('.modal-footer button[special=cancel]');
@@ -67,7 +68,7 @@ tour.register('crm_tour', {
}, {
id: "drag_opportunity_to_won_step",
trigger: ".o_opportunity_kanban .o_kanban_record:last-child",
- content: _t("Drag your opportunity to Won when you get the deal. Congrats !"),
+ content: Markup(_t("Drag your opportunity to Won when you get the deal. Congrats !")),
position: "bottom",
run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(3) ",
}, {
diff --git a/addons/crm_iap_lead/static/src/js/tours/crm_iap_lead.js b/addons/crm_iap_lead/static/src/js/tours/crm_iap_lead.js
index 577b6cd14d9..8d9b913645b 100644
--- a/addons/crm_iap_lead/static/src/js/tours/crm_iap_lead.js
+++ b/addons/crm_iap_lead/static/src/js/tours/crm_iap_lead.js
@@ -2,6 +2,7 @@ odoo.define('crm_iap_lead.generate_leads_steps', function (require) {
"use strict";
var tour = require('web_tour.tour');
+const {Markup} = require('web.utils');
var core = require('web.core');
require('crm.tour');
@@ -20,7 +21,7 @@ tour.tours.crm_tour.steps.splice(DragOppToWonStepIndex + 1, 0, {
*
*/
trigger: ".o_button_generate_leads",
- content: _t("Looking for more opportunities ? Try the Lead Generation tool."),
+ content: Markup(_t("Looking for more opportunities ? Try the Lead Generation tool.")),
position: "bottom",
run: function (actions) {
actions.auto('.o_button_generate_leads');
@@ -38,4 +39,4 @@ tour.tours.crm_tour.steps.splice(DragOppToWonStepIndex + 1, 0, {
}
});
-});
\ No newline at end of file
+});
diff --git a/addons/event/static/src/js/tours/event_tour.js b/addons/event/static/src/js/tours/event_tour.js
index d336729357e..88caff125db 100644
--- a/addons/event/static/src/js/tours/event_tour.js
+++ b/addons/event/static/src/js/tours/event_tour.js
@@ -18,8 +18,8 @@ return EventAdditionalTourSteps;
odoo.define('event.event_tour', function (require) {
"use strict";
-var core = require('web.core');
-var _t = core._t;
+const {_t} = require('web.core');
+const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
var EventAdditionalTourSteps = require('event.event_steps');
@@ -30,33 +30,33 @@ tour.register('event_tour', {
sequence: 210,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="event.event_main_menu"]',
- content: _t("Ready to organize events in a few minutes? Let's get started!"),
+ content: Markup(_t("Ready to organize events in a few minutes? Let's get started!")),
position: 'bottom',
edition: 'enterprise',
}, {
trigger: '.o_app[data-menu-xmlid="event.event_main_menu"]',
- content: _t("Ready to organize events in a few minutes? Let's get started!"),
+ content: Markup(_t("Ready to organize events in a few minutes? Let's get started!")),
edition: 'community',
}, {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_event_kanban_view',
- content: _t("Let's create your first event."),
+ content: Markup(_t("Let's create your first event.")),
position: 'bottom',
width: 175,
}, {
trigger: '.o_event_form_view input[name="name"]',
- content: _t("This is the name your guests will see when registering."),
+ content: Markup(_t("This is the name your guests will see when registering.")),
run: 'text Odoo Experience 2020',
}, {
trigger: '.o_event_form_view input[name="date_end"]',
- content: _t("When will your event take place? Select the start and end dates and click Apply."),
+ content: Markup(_t("When will your event take place? Select the start and end dates and click Apply.")),
run: function () {
$('input[name="date_begin"]').val('09/30/2020 08:00:00').change();
$('input[name="date_end"]').val('10/02/2020 23:00:00').change();
},
}, {
trigger: '.o_event_form_view div[name="event_ticket_ids"] .o_field_x2many_list_row_add a',
- content: _t("Ticket types allow you to distinguish your attendees. Let's create a new one."),
+ content: Markup(_t("Ticket types allow you to distinguish your attendees. Let's create a new one.")),
}, ...new EventAdditionalTourSteps()._get_website_event_steps(), {
trigger: '.o_event_form_view div[name="stage_id"]',
extra_trigger: 'div.o_form_buttons_view:not(.o_hidden)',
@@ -65,7 +65,7 @@ tour.register('event_tour', {
}, {
trigger: 'ol.breadcrumb li.breadcrumb-item:first',
extra_trigger: '.o_event_form_view div[name="stage_id"]',
- content: _t("Use the breadcrumbs to go back to your kanban overview."),
+ content: Markup(_t("Use the breadcrumbs to go back to your kanban overview.")),
position: 'bottom',
run: 'click',
}].filter(Boolean));
diff --git a/addons/gamification/views/badge.xml b/addons/gamification/views/badge.xml
index f59c5985d2a..539e5254a7e 100644
--- a/addons/gamification/views/badge.xml
+++ b/addons/gamification/views/badge.xml
@@ -145,7 +145,7 @@
this month
Click on Action Create Report to submit selected expenses to your manager
'),
+ content: Markup(_t('
Click on Action Create Report to submit selected expenses to your manager
')),
position: 'right',
}, {
trigger: '.o_expense_sheet_submit',
- content: _t('Once your Expense report is ready, you can submit it to your manager and wait for the approval from your manager.'),
+ content: Markup(_t('Once your Expense report is ready, you can submit it to your manager and wait for the approval from your manager.')),
position: 'bottom',
}, {
trigger: '.o_expense_sheet_approve',
- content: _t("
Approve the report here.
Tip: if you refuse, don’t forget to give the reason thanks to the hereunder message tool
"),
+ content: Markup(_t("
Approve the report here.
Tip: if you refuse, don’t forget to give the reason thanks to the hereunder message tool
"),
@@ -13,12 +12,12 @@ tour.register('hr_recruitment_tour',{
sequence: 230,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="hr_recruitment.menu_hr_recruitment_root"]',
- content: _t("Let's have a look at how to improve your hiring process."),
+ content: Markup(_t("Let's have a look at how to improve your hiring process.")),
position: 'right',
edition: 'community'
}, {
trigger: '.o_app[data-menu-xmlid="hr_recruitment.menu_hr_recruitment_root"]',
- content: _t("Let's have a look at how to improve your hiring process."),
+ content: Markup(_t("Let's have a look at how to improve your hiring process.")),
position: 'bottom',
edition: 'enterprise'
}, {
@@ -62,24 +61,24 @@ tour.register('hr_recruitment_tour',{
}, {
trigger: ".oe_kanban_action_button",
extra_trigger: '.o_hr_recruitment_kanban',
- content: _t("Did you apply by sending an email? Check incoming applications."),
+ content: Markup(_t("Did you apply by sending an email? Check incoming applications.")),
position: "bottom"
}, {
trigger: ".oe_kanban_card",
extra_trigger: '.o_kanban_applicant',
- content: _t("Drag this card, to qualify him for a first interview."),
+ content: Markup(_t("Drag this card, to qualify him for a first interview.")),
position: "bottom",
run: "drag_and_drop .o_kanban_group:eq(1) ",
}, {
trigger: ".oe_kanban_card",
extra_trigger: '.o_kanban_applicant',
- content: _t("Click to view the application."),
+ content: Markup(_t("Click to view the application.")),
position: "bottom",
width: 195
}, {
trigger: ".o_Chatter .o_ChatterTopbar_buttonSendMessage",
extra_trigger: '.o_applicant_form',
- content: _t("
Try to send an email to the applicant.
Tips: All emails sent or received are saved in the history here"),
+ content: Markup(_t("
Try to send an email to the applicant.
Tips: All emails sent or received are saved in the history here")),
position: "bottom"
}, {
trigger: ".o_Chatter .o_Composer_buttonSend",
diff --git a/addons/im_livechat/static/src/legacy/public_livechat.js b/addons/im_livechat/static/src/legacy/public_livechat.js
index 25445bde1ea..509fccd23d6 100644
--- a/addons/im_livechat/static/src/legacy/public_livechat.js
+++ b/addons/im_livechat/static/src/legacy/public_livechat.js
@@ -71,31 +71,25 @@ var LivechatButton = Widget.extend({
this._messages = [];
this._serverURL = serverURL;
},
- willStart: function () {
- var self = this;
+ async willStart() {
var cookie = utils.get_cookie('im_livechat_session');
- var ready;
- if (!cookie) {
- ready = session.rpc('/im_livechat/init', { channel_id: this.options.channel_id })
- .then(function (result) {
- if (!result.available_for_me) {
- return Promise.reject();
- }
- self._rule = result.rule;
- });
- } else {
+ if (cookie) {
var channel = JSON.parse(cookie);
- ready = session.rpc('/mail/chat_history', { uuid: channel.uuid, limit: 100 })
- .then(function (history) {
- self._history = history;
- });
+ this._history = await session.rpc('/mail/chat_history', {uuid: channel.uuid, limit: 100});
+ this._history.reverse().forEach(message => { message.body = utils.Markup(message.body); });
+ } else {
+ const result = await session.rpc('/im_livechat/init', {channel_id: this.options.channel_id});
+ if (!result.available_for_me) {
+ return Promise.reject();
+ }
+ this._rule = result.rule;
}
- return ready.then(this._loadQWebTemplate.bind(this));
+ return this._loadQWebTemplate();
},
start: function () {
this.$el.text(this.options.button_text);
if (this._history) {
- _.each(this._history.reverse(), this._addMessage.bind(this));
+ this._history.forEach(m => this._addMessage(m));
this._openChat();
} else if (!config.device.isMobile && this._rule.action === 'auto_popup') {
var autoPopupCookie = utils.get_cookie('im_livechat_auto_popup');
@@ -204,6 +198,7 @@ var LivechatButton = Widget.extend({
if (this._messages.some(message => message.getID() === notificationData.id)) {
return;
}
+ notificationData.body = utils.Markup(notificationData.body);
this._addMessage(notificationData);
if (this._chatWindow.isFolded() || !this._chatWindow.isAtBottom()) {
this._livechat.incrementUnreadCounter();
diff --git a/addons/im_livechat/static/src/legacy/public_livechat.xml b/addons/im_livechat/static/src/legacy/public_livechat.xml
index b5cf12a6ff8..1250bcf4e5a 100644
--- a/addons/im_livechat/static/src/legacy/public_livechat.xml
+++ b/addons/im_livechat/static/src/legacy/public_livechat.xml
@@ -400,7 +400,7 @@
`,
buttons: [{text: _t("Ok"), close: true}]
diff --git a/addons/payment/static/src/js/post_processing.js b/addons/payment/static/src/js/post_processing.js
index ccc24e211cc..4be04db63cb 100644
--- a/addons/payment/static/src/js/post_processing.js
+++ b/addons/payment/static/src/js/post_processing.js
@@ -2,8 +2,8 @@ odoo.define('payment.post_processing', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
- var ajax = require('web.ajax');
var core = require('web.core');
+ const {Markup} = require('web.utils');
var _t = core._t;
@@ -92,6 +92,9 @@ odoo.define('payment.post_processing', function (require) {
display_values_list.forEach(function (display_values) {
var key = 'tx_' + display_values.state;
if(key in render_values) {
+ if (display_values["display_message"]) {
+ display_values.display_message = Markup(display_values.display_message)
+ }
render_values[key].push(display_values);
}
});
diff --git a/addons/payment/static/src/xml/payment_post_processing.xml b/addons/payment/static/src/xml/payment_post_processing.xml
index 685122596cf..9c8723f5b64 100644
--- a/addons/payment/static/src/xml/payment_post_processing.xml
+++ b/addons/payment/static/src/xml/payment_post_processing.xml
@@ -54,7 +54,7 @@
-
+
Your payment is in pending state.
@@ -75,7 +75,7 @@
-
+
Your payment has been received but need to be confirmed manually.
@@ -100,7 +100,7 @@
-
+
We are waiting for the payment acquirer to confirm the payment.
diff --git a/addons/payment/views/payment_acquirer_views.xml b/addons/payment/views/payment_acquirer_views.xml
index 4a89136774a..f9b8bf92ac2 100644
--- a/addons/payment/views/payment_acquirer_views.xml
+++ b/addons/payment/views/payment_acquirer_views.xml
@@ -132,7 +132,7 @@
-
+
diff --git a/addons/payment_ogone/controllers/main.py b/addons/payment_ogone/controllers/main.py
index 11cc2a60a0c..c24e52d220d 100644
--- a/addons/payment_ogone/controllers/main.py
+++ b/addons/payment_ogone/controllers/main.py
@@ -5,6 +5,7 @@ import logging
import pprint
import re
+import markupsafe
import werkzeug
from odoo import _, http
@@ -88,7 +89,7 @@ class OgoneController(http.Controller):
# The content of the HTML is not sanitized to preserve the redirection in the script.
# After redirection, the customer comes back to the directlink return URL and we proceed
# with the (now authenticated) payment request.
- redirect_html = ustr(base64.b64decode(redirect_html_element.text))
+ redirect_html = markupsafe.Markup(ustr(base64.b64decode(redirect_html_element.text)))
return request.render(
'payment_ogone.directlink_feedback', {'redirect_html': redirect_html}
)
diff --git a/addons/payment_ogone/views/payment_ogone_templates.xml b/addons/payment_ogone/views/payment_ogone_templates.xml
index 6fb278c0554..74a6eb2a96b 100644
--- a/addons/payment_ogone/views/payment_ogone_templates.xml
+++ b/addons/payment_ogone/views/payment_ogone_templates.xml
@@ -43,7 +43,7 @@
-
+
diff --git a/addons/point_of_sale/static/src/js/tours/point_of_sale.js b/addons/point_of_sale/static/src/js/tours/point_of_sale.js
index 49a6fc0ac3e..37bc8736206 100644
--- a/addons/point_of_sale/static/src/js/tours/point_of_sale.js
+++ b/addons/point_of_sale/static/src/js/tours/point_of_sale.js
@@ -1,30 +1,29 @@
odoo.define('point_of_sale.tour', function(require) {
"use strict";
-var core = require('web.core');
+const {_t} = require('web.core');
+const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
-var _t = core._t;
-
tour.register('point_of_sale_tour', {
url: "/web",
rainbowMan: false,
sequence: 45,
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]',
- content: _t("Ready to launch your point of sale?"),
+ content: Markup(_t("Ready to launch your point of sale?")),
width: 215,
position: 'right',
edition: 'community'
}, {
trigger: '.o_app[data-menu-xmlid="point_of_sale.menu_point_root"]',
- content: _t("Ready to launch your point of sale?"),
+ content: Markup(_t("Ready to launch your point of sale?")),
width: 215,
position: 'bottom',
edition: 'enterprise'
}, {
trigger: ".o_pos_kanban button.oe_kanban_action_button",
- content: _t("
Ready to have a look at the POS Interface? Let's start our first session.
"),
+ content: Markup(_t("
Ready to have a look at the POS Interface? Let's start our first session.
")),
position: "bottom"
}]);
diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js
index 4aaa473ce90..962699bc003 100644
--- a/addons/portal/static/src/js/portal_chatter.js
+++ b/addons/portal/static/src/js/portal_chatter.js
@@ -6,6 +6,7 @@ const dom = require('web.dom');
var publicWidget = require('web.public.widget');
var time = require('web.time');
var portalComposer = require('portal.composer');
+const {Markup} = require('web.utils');
var qweb = core.qweb;
var _t = core._t;
@@ -118,13 +119,14 @@ var PortalChatter = publicWidget.Widget.extend({
/**
* Update the messages format
*
- * @param {Array
diff --git a/addons/pos_epson_printer/static/src/xml/epos_templates.xml b/addons/pos_epson_printer/static/src/xml/epos_templates.xml
index 3c8921bbf0c..b964772bd15 100644
--- a/addons/pos_epson_printer/static/src/xml/epos_templates.xml
+++ b/addons/pos_epson_printer/static/src/xml/epos_templates.xml
@@ -4,7 +4,7 @@
-
+
diff --git a/addons/product_matrix/models/product_template.py b/addons/product_matrix/models/product_template.py
index 6915833ad83..286833e2931 100644
--- a/addons/product_matrix/models/product_template.py
+++ b/addons/product_matrix/models/product_template.py
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
-from odoo import models
+from odoo import models, fields
class ProductTemplate(models.Model):
@@ -79,14 +79,7 @@ class ProductTemplateAttributeValue(models.Model):
} # The " " is to avoid having 'Not available' if the template has only one attribute line.
extra_price = sum(self.mapped('price_extra')) if display_extra else 0
if extra_price:
- sign = '+ ' if extra_price > 0 else '- '
- header_cell.update({
- "price": sign + self.env['ir.qweb.field.monetary'].value_to_html(
- extra_price, {
- 'from_currency': fro_currency,
- 'display_currency': to_currency,
- 'company_id': company.id,
- }
- )
- })
+ header_cell['currency_id'] = to_currency.id
+ header_cell['price'] = fro_currency._convert(
+ extra_price, to_currency, company.id, fields.Date.today())
return header_cell
diff --git a/addons/product_matrix/static/src/js/section_and_note_widget.js b/addons/product_matrix/static/src/js/section_and_note_widget.js
index 650efbf22af..c59efc06e50 100644
--- a/addons/product_matrix/static/src/js/section_and_note_widget.js
+++ b/addons/product_matrix/static/src/js/section_and_note_widget.js
@@ -4,7 +4,9 @@ var Dialog = require('web.Dialog');
var core = require('web.core');
var _t = core._t;
var qweb = core.qweb;
+const {Markup} = require('web.utils');
var fieldRegistry = require('web.field_registry');
+var {format} = require('web.field_utils');
require('account.section_and_note_backend');
var SectionAndNoteFieldOne2Many = fieldRegistry.get('section_and_note_one2many');
@@ -98,6 +100,12 @@ SectionAndNoteFieldOne2Many.include({
'product_matrix.matrix', {
header: infos.header,
rows: infos.matrix,
+ format({price, currency_id}) {
+ if (!price) { return ""; }
+ const sign = price < 0 ? '-' : '+';
+ const formatted = format.monetary(Math.abs(price), null, {currency_id});
+ return Markup`${sign} ${formatted}`;
+ }
}
)),
buttons: [
diff --git a/addons/product_matrix/static/src/xml/product_matrix.xml b/addons/product_matrix/static/src/xml/product_matrix.xml
index 09fc7ba06c7..e75406f12cd 100644
--- a/addons/product_matrix/static/src/xml/product_matrix.xml
+++ b/addons/product_matrix/static/src/xml/product_matrix.xml
@@ -7,7 +7,7 @@
-
+
@@ -16,12 +16,10 @@
-
+
-
-
-
-
+
+
-
+
-
+
diff --git a/addons/product_matrix/tests/common.py b/addons/product_matrix/tests/common.py
index 51e0a366588..ac3b5a1e9aa 100644
--- a/addons/product_matrix/tests/common.py
+++ b/addons/product_matrix/tests/common.py
@@ -44,3 +44,8 @@ class TestMatrixCommon(common.HttpCase):
'value_ids': [(6, 0, attribute.value_ids.ids)]
}) for attribute in product_attributes],
})
+ def get_ptav(pav_name):
+ return self.env['product.template.attribute.value']\
+ .search([('product_attribute_value_id.name', '=', pav_name)])
+ get_ptav('PAV12').price_extra = 50
+ get_ptav('PAV31').price_extra = -25
diff --git a/addons/project/static/src/js/project_task_kanban_examples.js b/addons/project/static/src/js/project_task_kanban_examples.js
index e7001a0e59b..4e6c548b84c 100644
--- a/addons/project/static/src/js/project_task_kanban_examples.js
+++ b/addons/project/static/src/js/project_task_kanban_examples.js
@@ -1,12 +1,13 @@
/** @odoo-module **/
import { _lt } from 'web.core';
+import {Markup} from 'web.utils';
import kanbanExamplesRegistry from 'web.kanban_examples_registry';
-const greenBullet = '';
-const redBullet = '';
-const star = '';
-const clock = '';
+const greenBullet = Markup``;
+const redBullet = Markup``;
+const star = Markup``;
+const clock = Markup``;
const descriptionActivities = escFormat(_lt('%s Use the %s icon to organize your daily activities.'), ' ', clock);
const description = escFormat(_lt('Prioritize Tasks by using the %s icon.' +
@@ -15,21 +16,23 @@ const description = escFormat(_lt('Prioritize Tasks by using the %s icon.' +
'%s'), star, ' ', greenBullet, ' ', redBullet, descriptionActivities);
/**
- * Helper function to escape a text before formatting it.
+ * Helper function to escape the format string, but not the values formatted in,
+ * rougly equivalent to `_.str.sprintf(_.escape(fmt), ...)`.
*
- * First argument is the string to format and the other arguments are the values
- * to inject into the string.
+ * Performs the escaping lazily (at point of use) as it's designed to be used
+ * with `_lt` as the source of the format string.
*
- * Sort of 'lazy escaping' as it is used alongside _lt.
- *
- * @returns {string} the formatted and escaped string
+ * @param fmt format string, to escape
+ * @param args objects to format into `fmt`, unescaped
+ * @returns {Object} a stringifiable object returning the escaped then formatted string
*/
-function escFormat() {
- const args = arguments;
+function escFormat(fmt, ...args) {
return {
- toString: function () {
- args[0] = _.escape(args[0]);
- return _.str.sprintf.apply(_.str, args);
+ [_.escapeMethod]() {
+ return this;
+ },
+ toString() {
+ return _.str.sprintf(_.escape(fmt), ...args);
},
};
}
diff --git a/addons/project/static/src/js/tours/project.js b/addons/project/static/src/js/tours/project.js
index a0984eff0c8..c90d2eab0bb 100644
--- a/addons/project/static/src/js/tours/project.js
+++ b/addons/project/static/src/js/tours/project.js
@@ -1,46 +1,45 @@
odoo.define('project.tour', function(require) {
"use strict";
-var core = require('web.core');
+const {_t} = require('web.core');
+const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
-var _t = core._t;
-
tour.register('project_tour', {
sequence: 110,
url: "/web",
rainbowManMessage: "Congratulations, you are now a master of project management.",
}, [tour.stepUtils.showAppsMenuItem(), {
trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
- content: _t('Want a better way to manage your projects? It starts here.'),
+ content: Markup(_t('Want a better way to manage your projects? It starts here.')),
position: 'right',
edition: 'community',
}, {
trigger: '.o_app[data-menu-xmlid="project.menu_main_pm"]',
- content: _t('Want a better way to manage your projects? It starts here.'),
+ content: Markup(_t('Want a better way to manage your projects? It starts here.')),
position: 'bottom',
edition: 'enterprise',
}, {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_project_kanban',
- content: _t('Let\'s create your first project.'),
+ content: Markup(_t('Let\'s create your first project.')),
position: 'bottom',
width: 200,
}, {
trigger: 'input.o_project_name',
- content: _t('Choose a name for your project. It can be anything you want: the name of a customer,\
- of a product, of a team, of a construction site, etc.'),
+ content: Markup(_t('Choose a name for your project. It can be anything you want: the name of a customer,\
+ of a product, of a team, of a construction site, etc.')),
position: 'right',
}, {
trigger: '.o_open_tasks',
- content: _t('Let\'s create your first project.'),
+ content: Markup(_t('Let\'s create your first project.')),
position: 'top',
run: function (actions) {
actions.auto('.modal:visible .btn.btn-primary');
},
}, {
trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group",
- content: _t("Add columns to organize your tasks into stagese.g. New - In Progress - Done."),
+ content: Markup(_t("Add columns to organize your tasks into stagese.g. New - In Progress - Done.")),
position: 'right',
run: function (actions) {
actions.text("Test", this.$anchor.find("input"));
@@ -51,7 +50,7 @@ tour.register('project_tour', {
}, {
trigger: ".o_kanban_project_tasks .o_column_quick_create .input-group",
extra_trigger: '.o_kanban_group',
- content: _t("Add columns to organize your tasks into stagese.g. New - In Progress - Done."),
+ content: Markup(_t("Add columns to organize your tasks into stagese.g. New - In Progress - Done.")),
position: 'right',
run: function (actions) {
actions.text("Test", this.$anchor.find("input"));
@@ -62,13 +61,13 @@ tour.register('project_tour', {
}, {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_kanban_group:eq(1)',
- content: _t("Let's create your first task."),
+ content: Markup(_t("Let's create your first task.")),
position: 'bottom',
width: 200,
}, {
trigger: '.o_kanban_quick_create input.o_field_char[name=name]',
extra_trigger: '.o_kanban_project_tasks',
- content: _t('Choose a task name(e.g. Website Design, Purchase Goods...)'),
+ content: Markup(_t('Choose a task name(e.g. Website Design, Purchase Goods...)')),
position: 'right',
}, {
trigger: '.o_kanban_quick_create .o_kanban_add',
@@ -78,7 +77,7 @@ tour.register('project_tour', {
}, {
trigger: ".o_kanban_record .oe_kanban_content",
extra_trigger: '.o_kanban_project_tasks',
- content: _t("Drag & drop the card to change your task from stage."),
+ content: Markup(_t("Drag & drop the card to change your task from stage.")),
position: "bottom",
run: "drag_and_drop .o_kanban_group:eq(1) ",
}, {
@@ -88,20 +87,20 @@ tour.register('project_tour', {
position: "bottom",
}, {
trigger: ".o_ChatterTopbar_buttonSendMessage",
- content: _t("Use the chatter to send emails and communicate efficiently with your customers. \
- Add new people to the followers' list to make them aware of the main changes about this task."),
+ content: Markup(_t("Use the chatter to send emails and communicate efficiently with your customers. \
+ Add new people to the followers' list to make them aware of the main changes about this task.")),
width: 350,
position: "bottom",
}, {
trigger: ".o_ChatterTopbar_buttonLogNote",
- content: _t("Log notes for internal communications (the people following this task won't be notified \
+ content: Markup(_t("Log notes for internal communications (the people following this task won't be notified \
of the note you are logging unless you specifically tag them). Use @ mentions to ping a colleague \
- or # mentions to reach an entire team."),
+ or # mentions to reach an entire team.")),
width: 350,
position: "bottom"
}, {
trigger: ".o_ChatterTopbar_buttonScheduleActivity",
- content: _t("Use activities to organize your daily work."),
+ content: Markup(_t("Use activities to organize your daily work.")),
}, {
trigger: ".modal-dialog .btn-primary",
content: "Schedule your activity once it is ready.",
@@ -110,7 +109,7 @@ tour.register('project_tour', {
}, {
trigger: ".breadcrumb-item:not(.active):last",
extra_trigger: '.o_form_project_tasks.o_form_readonly',
- content: _t("Let's go back to the kanban view to have an overview of your next tasks."),
+ content: Markup(_t("Let's go back to the kanban view to have an overview of your next tasks.")),
position: "right",
run: 'click',
}]);
diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py
index cfa3ea3489e..b9c5fbd7cdf 100644
--- a/addons/purchase_stock/models/stock_rule.py
+++ b/addons/purchase_stock/models/stock_rule.py
@@ -156,13 +156,13 @@ class StockRule(models.Model):
buy_rule.ensure_one()
supplier_delay = seller[0].delay
if supplier_delay and not bypass_delay_description:
- delay_description += '
%s
+ %d %s
' % (_('Vendor Lead Time'), supplier_delay, _('day(s)'))
+ delay_description.append((_('Vendor Lead Time'), _('+ %d day(s)', supplier_delay)))
security_delay = buy_rule.picking_type_id.company_id.po_lead
if not bypass_delay_description:
- delay_description += '
%s
+ %d %s
' % (_('Purchase Security Lead Time'), security_delay, _('day(s)'))
+ delay_description.append((_('Purchase Security Lead Time'), _('+ %d day(s)', security_delay)))
days_to_purchase = buy_rule.company_id.days_to_purchase
if not bypass_delay_description:
- delay_description += '
%s
+ %d %s
' % (_('Days to Purchase'), days_to_purchase, _('day(s)'))
+ delay_description.append((_('Days to Purchase'), _('+ %d day(s)', days_to_purchase)))
return delay + supplier_delay + security_delay + days_to_purchase, delay_description
@api.model
diff --git a/addons/sale/static/src/js/tours/sale.js b/addons/sale/static/src/js/tours/sale.js
index d01b0ad6837..9c603aed152 100644
--- a/addons/sale/static/src/js/tours/sale.js
+++ b/addons/sale/static/src/js/tours/sale.js
@@ -1,11 +1,10 @@
odoo.define('sale.tour', function(require) {
"use strict";
-var core = require('web.core');
+const {_t} = require('web.core');
+const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
-var _t = core._t;
-
tour.register("sale_tour", {
url: "/web",
rainbowMan: false,
@@ -108,11 +107,11 @@ tour.register("sale_quote_tour", {
}, {
trigger: ".o_field_widget[name='price_unit'] ",
extra_trigger: ".o_sale_order",
- content: _t("Set a price."),
+ content: Markup(_t("Set a price.")),
position: "right",
run: "text 10.0"
},
- ...tour.stepUtils.statusbarButtonsSteps("Send by Email", _t("Send the quote to yourself and check what the customer will receive."), ".o_statusbar_buttons button[name='action_quotation_send']"),
+ ...tour.stepUtils.statusbarButtonsSteps("Send by Email", Markup(_t("Send the quote to yourself and check what the customer will receive.")), ".o_statusbar_buttons button[name='action_quotation_send']"),
{
trigger: ".modal-footer button.btn-primary",
auto: true,
diff --git a/addons/sale_product_matrix/static/tests/tours/sale_product_matrix_tour.js b/addons/sale_product_matrix/static/tests/tours/sale_product_matrix_tour.js
index e5e56c5c4e0..65c63bfffca 100644
--- a/addons/sale_product_matrix/static/tests/tours/sale_product_matrix_tour.js
+++ b/addons/sale_product_matrix/static/tests/tours/sale_product_matrix_tour.js
@@ -2,6 +2,20 @@ odoo.define('sale_product_matrix.sale_matrix_tour', function (require) {
"use strict";
var tour = require('web_tour.tour');
+let EXPECTED = [
+ "Matrix", "PAV11", "PAV12 + $ 50.00",
+]
+for (let no of ['PAV41', 'PAV42']) {
+ for (let dyn of ['PAV31', 'PAV32']) {
+ for (let al of ['PAV21', 'PAV22']) {
+ let row_label = [al, dyn, no].join(' • ');
+ if (dyn === 'PAV31') {
+ row_label += ' - $ 25.00';
+ }
+ EXPECTED.push(row_label, "", "");
+ }
+ }
+}
tour.register('sale_matrix_tour', {
url: "/web",
@@ -45,6 +59,18 @@ tour.register('sale_matrix_tour', {
}, {
trigger: '.o_product_variant_matrix',
run: function () {
+ // whitespace normalization: removes newlines around text from markup
+ // content, then collapse & convert internal whitespace to regular
+ // spaces.
+ const texts = $('.o_matrix_input_table').find('th, td')
+ .map((_, el) => el.innerText.trim().replace(/\s+/g, ' '))
+ .get();
+
+ for (let i=0; i(e.g. Service Product (Prepaid Hours))',
+ content: Markup('Select a prepaid service product (e.g. Service Product (Prepaid Hours))'),
run: function (actions) {
actions.text('Service Product (Prepaid Hours)', this.$anchor.find('input'));
},
@@ -91,7 +92,7 @@ tour.register('sale_timesheet_tour', {
run: 'click',
}, {
trigger: 'div[name="partner_id"]',
- content: 'Select the customer of your Sales Order (e.g. Brandon Freeman). Since we have a Sales Order for this customer with a prepaid service product which the remaining hours to deliver is greater than 0, the Sales Order Item in the task should be contain the Sales Order Item containing this prepaid service product.',
+ content: Markup('Select the customer of your Sales Order (e.g. Brandon Freeman). Since we have a Sales Order for this customer with a prepaid service product which the remaining hours to deliver is greater than 0, the Sales Order Item in the task should be contain the Sales Order Item containing this prepaid service product.'),
run: function (actions) {
actions.text('Brandon Freeman', this.$anchor.find('input'));
},
@@ -186,7 +187,7 @@ tour.register('sale_timesheet_tour', {
},
}, {
trigger: 'div[name="partner_id"]',
- content: 'Add the customer for this project to select an SO and SOL for this customer (e.g. Brandon Freeman).',
+ content: Markup('Add the customer for this project to select an SO and SOL for this customer (e.g. Brandon Freeman).'),
run: function (actions) {
actions.text('Azure Interior, Brandon Freeman', this.$anchor.find('input'));
},
diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py
index b5ac9cdfd01..79d718d6e8f 100644
--- a/addons/stock/models/stock_rule.py
+++ b/addons/stock/models/stock_rule.py
@@ -327,13 +327,17 @@ class StockRule(models.Model):
:param product: the product of the procurement
:type product: :class:`~odoo.addons.product.models.product.ProductProduct`
:return: the cumulative delay and cumulative delay's description
- :rtype: tuple
+ :rtype: tuple[int, list[str, str]]
"""
delay = sum(self.filtered(lambda r: r.action in ['pull', 'pull_push']).mapped('delay'))
if self.env.context.get('bypass_delay_description'):
- delay_description = ""
+ delay_description = []
else:
- delay_description = ''.join(['
%s %s
+ %d %s
' % (_('Delay on'), html_escape(rule.name), rule.delay, _('day(s)')) for rule in self if rule.action in ['pull', 'pull_push'] and rule.delay])
+ delay_description = [
+ (_('Delay on %s', rule.name), _('+ %d day(s)', rule.delay))
+ for rule in self
+ if rule.action in ['pull', 'pull_push'] and rule.delay
+ ]
return delay, delay_description
diff --git a/addons/stock/static/src/xml/stock_orderpoint.xml b/addons/stock/static/src/xml/stock_orderpoint.xml
index afd744c5a04..6c1b9f60b65 100644
--- a/addons/stock/static/src/xml/stock_orderpoint.xml
+++ b/addons/stock/static/src/xml/stock_orderpoint.xml
@@ -7,7 +7,7 @@
The forecasted stock on the
is
below the inventory minimum of
- : should be replenished to reach the maximum of
+ : should be replenished to reach the maximum of
.
@@ -17,16 +17,19 @@
Today
-
+
-
+
+
+
+
Forecasted Date
- =
+ =
diff --git a/addons/web/static/lib/qweb/qweb2.js b/addons/web/static/lib/qweb/qweb2.js
index d2a367f1c33..60a7c0beea5 100644
--- a/addons/web/static/lib/qweb/qweb2.js
+++ b/addons/web/static/lib/qweb/qweb2.js
@@ -28,7 +28,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
var QWeb2 = {
expressions_cache: { },
RESERVED_WORDS: 'true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,typeof,eval,void,Math,RegExp,Array,Object,Date'.split(','),
- ACTIONS_PRECEDENCE: 'foreach,if,elif,else,call,set,tag,esc,raw,js,debug,log'.split(','),
+ ACTIONS_PRECEDENCE: 'foreach,if,elif,else,call,set,tag,out,esc,raw,js,debug,log'.split(','),
WORD_REPLACEMENT: {
'and': '&&',
'or': '||',
@@ -65,15 +65,14 @@ var QWeb2 = {
js_escape: function(s, noquotes) {
return (noquotes ? '' : "'") + s.replace(/\r?\n/g, "\\n").replace(/'/g, "\\'") + (noquotes ? '' : "'");
},
- html_escape: function(s, attribute) {
+ html_escape: function(s) {
if (s == null) {
return '';
}
- s = String(s).replace(/&/g, '&').replace(//g, '>');
- if (attribute) {
- s = s.replace(/"/g, '"');
- }
- return s;
+ return _.escape(s);
+ },
+ markup(s) {
+ return new _Markup(s);
},
gen_attribute: function(o) {
if (o !== null && o !== undefined) {
@@ -94,7 +93,10 @@ var QWeb2 = {
return '';
},
format_attribute: function(name, value) {
- return ' ' + name + '="' + this.html_escape(value, true) + '"';
+ // we want to ensure `value` is *not* a `Markup`, because markup-safe
+ // strings are not necessarily attributes-safe.
+ const attrvalue = value == null ? '' : this.html_escape(String(value));
+ return ` ${name}="${attrvalue}"`;
},
extend: function(dst, src, exclude) {
for (var p in src) {
@@ -149,9 +151,9 @@ var QWeb2 = {
var new_dict = this.extend({}, old_dict);
new_dict['__caller__'] = old_dict['__template__'];
if (callback) {
- new_dict[0] = callback(context, new_dict);
+ new_dict[0] = this.markup(callback(context, new_dict));
}
- return context.engine._render(template, new_dict);
+ return this.markup(context.engine._render(template, new_dict));
},
foreach: function(context, enu, as, old_dict, callback) {
if (enu != null) {
@@ -689,7 +691,7 @@ QWeb2.Element = (function() {
s && r.push(_this.engine.tools.js_escape(s));
}
- var re = /(?:#{(.+?)}|{{(.+?)}})/g, start = 0, r = [], m;
+ var re = /#{(.+?)}|{{(.+?)}}/g, start = 0, r = [], m;
while (m = re.exec(s)) {
// extract literal string between previous and current match
append_literal(s.slice(start, re.lastIndex - m[0].length));
@@ -800,7 +802,7 @@ QWeb2.Element = (function() {
this.bottom("}));");
this.indent();
this.top("var r = [];");
- return this.bottom("return r.join('');");
+ return this.bottom("return context.engine.tools.markup(r.join(''));");
}
},
compile_action_set : function(value) {
@@ -822,11 +824,14 @@ QWeb2.Element = (function() {
this.bottom("})(dict);");
this.indent();
this.top("var r = [];");
- this.bottom("return r.join('');");
+ this.bottom("return context.engine.tools.markup(r.join(''));");
}
}
},
compile_action_esc : function(value) {
+ return this.compile_action_out(value);
+ },
+ compile_action_out(value) {
this.top("var t = " + this.format_str(value) + ";");
this.top("if (t != null) r.push(context.engine.tools.html_escape(t));");
this.top("else {");
@@ -834,6 +839,14 @@ QWeb2.Element = (function() {
this.indent();
},
compile_action_raw : function(value) {
+ let e = this.node;
+ while (e.parentElement && !e.getAttribute('t-name')) {
+ e = e.parentElement;
+ }
+ console.warn(
+ "Found deprecated directive '@t-raw=\"%s\"' in "
+ + "template '%s'. Replace by @t-out, and explicitely wrap content in "
+ + "utils.Markup if necessary.", value, e.getAttribute('t-name'));
this.top("var t = " + this.format_str(value) + ";");
this.top("if (t != null) r.push(t);");
this.top("else {");
diff --git a/addons/web/static/src/legacy/js/core/ajax.js b/addons/web/static/src/legacy/js/core/ajax.js
index 1ec31f8bdd8..18f3e4d02a1 100644
--- a/addons/web/static/src/legacy/js/core/ajax.js
+++ b/addons/web/static/src/legacy/js/core/ajax.js
@@ -4,6 +4,7 @@ odoo.define('web.ajax', function (require) {
var config = require('web.config');
var concurrency = require('web.concurrency');
var core = require('web.core');
+const {Markup} = require('web.utils');
var time = require('web.time');
var download = require('web.download');
var contentdisposition = require('web.contentdisposition');
@@ -460,13 +461,13 @@ var loadAsset = (function () {
return $(this).attr('href');
}).get(),
cssContents: $xml.filter('style').map(function () {
- return $(this).html();
+ return Markup($(this).html());
}).get(),
jsLibs: $xml.filter('script[src]').map(function () {
return $(this).attr('src');
}).get(),
jsContents: $xml.filter('script:not([src])').map(function () {
- return $(this).html();
+ return Markup($(this).html());
}).get(),
};
}).guardedCatch(reason => {
diff --git a/addons/web/static/src/legacy/js/core/utils.js b/addons/web/static/src/legacy/js/core/utils.js
index 05d2f14b358..95ad897c9a1 100644
--- a/addons/web/static/src/legacy/js/core/utils.js
+++ b/addons/web/static/src/legacy/js/core/utils.js
@@ -240,9 +240,81 @@ class UnknownPatchError extends Error {
}
}
+const __escape = _.escape;
+_.escapeMethod = Symbol('html')
+_.escape = function escape(s) {
+ return s[_.escapeMethod] ? s[_.escapeMethod]() : __escape(s);
+}
+
+// notable issues:
+// * objects can't be negative in JS, so !!"" -> false but
+// !!(new String) -> true, likewise markup
+// TODO (?)
+// * Markup.join / Markup#join => escapes items and returns a Markup
+// * Markup#replace => automatically escapes the replacements (difficult impl)
+class _Markup extends String {
+ [_.escapeMethod]() {
+ return this;
+ }
+}
+// exposed for qweb2.js
+window._Markup = _Markup;
+
+/**
+ * Returns a markup object, which acts like a String but is considered safe by
+ * `_.escape`, and will therefore be injected as-is (without additional
+ * escaping) in templates. Can be used to inject dynamic HTML in templates
+ * (where the template itself can't), see first example.
+ *
+ * Can also be used as a *template tag*, in which case the literal content
+ * won't be escaped but the substitutions which are not already markup objects
+ * will be.
+ *
+ * ## WARNINGS:
+ * * A markup object is a `String` (boxed) but not a `string` (primitive), they
+ * typecheck differently which can be relevant.
+ * * To strip out the "markupness", just call `String(markup)`.
+ * * Most string operations (e.g. concatenation, `String#replace`, ...) will
+ * also strip out markupness
+ * * If the input is empty, returns a regular string (that way boolean tests
+ * work as expected).
+ *
+ * @returns a markup object
+ *
+ * @example regular function
+ * let h;
+ * if (someTest) {
+ * h = Markup(_t("This is a success"));
+ * } else {
+ * h = Markup(_t("Things did not work out"));
+ * }
+ * qweb.render("some_template", { message: h });
+ *
+ * @example template tag
+ * const escaped = " text";
+ * const asis = Markup`some text`;
+ * const h = Markup`Regular strings get ${escaped} but markup is injected ${asis}`;
+ */
+function Markup(v, ...exprs) {
+ if (!(v instanceof Array)) {
+ return v ? new _Markup(v) : '';
+ }
+ const elements = [];
+ let i = 0;
+ for(; i < exprs.length; ++i) {
+ elements.push(v[i], _.escape(exprs[i]));
+ }
+ elements.push(v[i]);
+
+ const s = elements.join('');
+ if (!s) { return '' }
+ return new _Markup(s);
+}
+
var utils = {
AlreadyDefinedPatchError,
UnknownPatchError,
+ Markup,
/**
* Throws an error if the given condition is not true
@@ -818,25 +890,40 @@ var utils = {
},
/**
* Returns a string formatted using given values.
+ *
* If the value is an object, its keys will replace `%(key)s` expressions.
* If the values are a set of strings, they will replace `%s` expressions.
* If no value is given, the string will not be formatted.
*
- * @param {string} string
- * @param {(Object|...string)} values
+ * `Markup`-aware: if a `Markup` object is provided as format string,
+ * automatically escapes injected values and returns a new `Markup`
+ * object.
+ *
+ * @param {string|Markup} string format string
+ * @param values values injected into the format string, can be either a
+ * sequence of positional parameters (for positional
+ * placeholders) or a single Object acting a as map (for named
+ * placeholders).
+ * @returns {string|Markup}
*/
- sprintf: function (string, ...values) {
+ sprintf(string, ...values) {
+ let finalizer, mapper;
+ finalizer = mapper = a => a;
+ if (string instanceof _Markup) {
+ string = String(string);
+ finalizer = Markup;
+ mapper = _.escape;
+ }
if (values.length === 1 && typeof values[0] === 'object') {
const valuesDict = values[0];
- for (const value in valuesDict) {
- string = string.replace(`%(${value})s`, valuesDict[value]);
- }
+ string = string.replace(
+ /%\((\w+)\)s/g,
+ (_, group) => mapper(valuesDict[group]));
} else {
- for (const value of values) {
- string = string.replace(/%s/, value);
- }
+ let i = 0;
+ string = string.replace(/%s/g, () => mapper(values[i++]));
}
- return string;
+ return finalizer(string);
},
/**
* Sort an array in place, keeping the initial order for identical values.
diff --git a/addons/web/static/src/legacy/js/fields/field_utils.js b/addons/web/static/src/legacy/js/fields/field_utils.js
index beba1b07f50..f91c51fcb2c 100644
--- a/addons/web/static/src/legacy/js/fields/field_utils.js
+++ b/addons/web/static/src/legacy/js/fields/field_utils.js
@@ -332,7 +332,7 @@ function formatX2Many(value) {
* @param {boolean} [options.forceString=false]
* if false, returns a string encoding the html formatted value (with
* whitespace encoded as ' ')
- * @returns {string}
+ * @returns {string | _Markup}
*/
function formatMonetary(value, field, options) {
if (value === false) {
@@ -361,12 +361,13 @@ function formatMonetary(value, field, options) {
if (!currency || options.noSymbol) {
return formatted_value;
}
- const ws = options.forceString ? ' ' : ' ';
- if (currency.position === "after") {
- return formatted_value + ws + currency.symbol;
- } else {
- return currency.symbol + ws + formatted_value;
+ const val = currency.position === 'after'
+ ? [formatted_value, currency.symbol]
+ : [currency.symbol, formatted_value];
+ if (options.forceString) {
+ return val.join(' ');
}
+ return utils.Markup(val.map((v) => _.escape(v)).join(' '));
}
/**
* Returns a string representing the given value (multiplied by 100)
diff --git a/addons/web/static/src/legacy/js/fields/relational_fields.js b/addons/web/static/src/legacy/js/fields/relational_fields.js
index 4c949da8c9b..0bdda5eedbe 100644
--- a/addons/web/static/src/legacy/js/fields/relational_fields.js
+++ b/addons/web/static/src/legacy/js/fields/relational_fields.js
@@ -410,7 +410,7 @@ var FieldMany2One = AbstractField.extend({
domain: this.record.getDomain({fieldName: this.name}),
context: _.extend({}, this.record.getContext(this.recordParams), context || {}),
dynamicFilters: dynamicFilters || [],
- title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string,
+ title: _.str.sprintf((view === 'search' ? _t("Search: %s") : _t("Create: %s")), this.string),
initial_ids: ids,
initial_view: view,
disable_multiple_selection: true,
diff --git a/addons/web/static/src/legacy/js/views/calendar/calendar_controller.js b/addons/web/static/src/legacy/js/views/calendar/calendar_controller.js
index c58e6ce97aa..2dc2f3ba9ae 100644
--- a/addons/web/static/src/legacy/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/legacy/js/views/calendar/calendar_controller.js
@@ -411,7 +411,7 @@ var CalendarController = AbstractController.extend({
res_model: self.modelName,
res_id: id || null,
context: event.context || self.context,
- title: _t("Open: ") + _.escape(event.data.title),
+ title: _.str.sprintf(_t("Open: %s"), event.data.title),
on_saved: function () {
if (event.data.on_save) {
event.data.on_save();
diff --git a/addons/web/static/src/legacy/js/views/kanban/kanban_examples_registry.js b/addons/web/static/src/legacy/js/views/kanban/kanban_examples_registry.js
index effdd7ff102..8020894e2e1 100644
--- a/addons/web/static/src/legacy/js/views/kanban/kanban_examples_registry.js
+++ b/addons/web/static/src/legacy/js/views/kanban/kanban_examples_registry.js
@@ -14,10 +14,7 @@ odoo.define('web.kanban_examples_registry', function (require) {
* following keys:
* - name (string)
* - columns (Array[string])
- * - description (string, optional) BE CAREFUL [*]
- *
- * [*] The description is added with a t-raw so the translated texts must be
- * properly escaped.
+ * - description (string, optional)
*/
var Registry = require('web.Registry');
diff --git a/addons/web/static/src/legacy/js/views/kanban/kanban_record.js b/addons/web/static/src/legacy/js/views/kanban/kanban_record.js
index 41964d87a7f..5794e980c3c 100644
--- a/addons/web/static/src/legacy/js/views/kanban/kanban_record.js
+++ b/addons/web/static/src/legacy/js/views/kanban/kanban_record.js
@@ -610,31 +610,29 @@ var KanbanRecord = Widget.extend(WidgetAdapterMixin, {
* @returns {Object} transformed record data
*/
_transformRecord: function (recordData) {
- var self = this;
- var new_record = {};
- _.each(this.state.getFieldNames(), function (name) {
- var value = recordData[name];
- var r = _.clone(self.fields[name] || {});
+ const new_record = Object.create(null);
+ for (const name of this.state.getFieldNames()) {
+ const value = recordData[name];
+ const r = new_record[name] = _.clone(this.fields[name] || {});
+ const formatter = r.type ? field_utils.format[r.type] : _.identity;
- if ((r.type === 'date' || r.type === 'datetime') && value) {
- r.raw_value = value.toDate();
- } else if (r.type === 'one2many' || r.type === 'many2many') {
+ r.raw_value = value;
+ r.value = formatter(value, this.fields[name], recordData, this.state);
+ switch (r.type) {
+ case 'date': case 'datetime':
+ r.raw_value = value && value.toDate();
+ break;
+ case 'one2many': case 'many2many':
r.raw_value = value.count ? value.res_ids : [];
- } else if (r.type === 'many2one') {
+ break;
+ case 'many2one':
r.raw_value = value && value.res_id || false;
- } else {
- r.raw_value = value;
+ break;
+ case 'html': // or should this be in `format` instead of _.identity?
+ r.value = utils.Markup(value);
+ break;
}
-
- if (r.type) {
- var formatter = field_utils.format[r.type];
- r.value = formatter(value, self.fields[name], recordData, self.state);
- } else {
- r.value = value;
- }
-
- new_record[name] = r;
- });
+ }
return new_record;
},
/**
diff --git a/addons/web/static/src/legacy/js/widgets/translation_dialog.js b/addons/web/static/src/legacy/js/widgets/translation_dialog.js
index e407ff1da49..899b5d7079a 100644
--- a/addons/web/static/src/legacy/js/widgets/translation_dialog.js
+++ b/addons/web/static/src/legacy/js/widgets/translation_dialog.js
@@ -42,7 +42,7 @@ odoo.define('web.TranslationDialog', function (require) {
this._super(parent, _.extend({
size: 'large',
- title: _t('Translate: ') + `${this.fieldName}`,
+ title: _.str.sprintf(_t('Translate: %s'), this.fieldName),
buttons: [
{ text: _t('Save'), classes: 'btn-primary', close: true, click: this._onSave.bind(this) },
{ text: _t('Discard'), close: true },
diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml
index d7d4010ab9a..7e990117677 100644
--- a/addons/web/static/src/legacy/xml/base.xml
+++ b/addons/web/static/src/legacy/xml/base.xml
@@ -1041,7 +1041,7 @@
-
+
@@ -1321,11 +1321,11 @@
-
+
-
+
@@ -1475,7 +1475,7 @@
-
+
diff --git a/addons/web/static/src/legacy/xml/dialog.xml b/addons/web/static/src/legacy/xml/dialog.xml
index 790f8251a6b..47870688782 100644
--- a/addons/web/static/src/legacy/xml/dialog.xml
+++ b/addons/web/static/src/legacy/xml/dialog.xml
@@ -14,7 +14,7 @@