diff --git a/addons/mail/views/mail_mail_views.xml b/addons/mail/views/mail_mail_views.xml index 81dffdc8aad..d8cb4365fec 100644 --- a/addons/mail/views/mail_mail_views.xml +++ b/addons/mail/views/mail_mail_views.xml @@ -43,7 +43,9 @@ - + diff --git a/addons/web_editor/static/src/js/backend/html_field.js b/addons/web_editor/static/src/js/backend/html_field.js index c2ae82d701f..884df6c65e1 100644 --- a/addons/web_editor/static/src/js/backend/html_field.js +++ b/addons/web_editor/static/src/js/backend/html_field.js @@ -76,11 +76,8 @@ export class HtmlFieldWysiwygAdapterComponent extends ComponentAdapter { export class HtmlField extends Component { setup() { - // readonly if nodes in as the head will be removed on insertion in the DOM - // this prevents the field from overwriting the value with invalid HTML when relevant - const domParser = new DOMParser(); - const parsedOriginal = domParser.parseFromString(this.props.record.data[this.props.name] || '', 'text/html'); - this.containsComplexHTML = !!parsedOriginal.head.innerHTML.trim(); + this.containsComplexHTML = this.computeContainsComplexHTML(); + this.sandboxedPreview = this.props.sandboxedPreview || this.containsComplexHTML; this.readonlyElementRef = useRef("readonlyElement"); this.codeViewRef = useRef("codeView"); @@ -126,7 +123,7 @@ export class HtmlField extends Component { res_id: this.props.record.resId, }; onWillUpdateProps((newProps) => { - if (!newProps.readonly && this.state.iframeVisible) { + if (!newProps.readonly && !this.sandboxedPreview && this.state.iframeVisible) { this.state.iframeVisible = false; } @@ -144,7 +141,7 @@ export class HtmlField extends Component { if (this._qwebPlugin) { this._qwebPlugin.destroy(); } - if (this.props.readonly) { + if (this.props.readonly || (!this.state.showCodeView && this.sandboxedPreview)) { if (this.showIframe) { await this._setupReadonlyIframe(); } else if (this.readonlyElementRef.el) { @@ -182,6 +179,19 @@ export class HtmlField extends Component { }); } + /** + * Check whether the current value contains nodes that would break + * on insertion inside an existing body. + * + * @returns {boolean} true if 'this.props.value' contains a node + * that can only exist once per document. + */ + computeContainsComplexHTML() { + const domParser = new DOMParser(); + const parsedOriginal = domParser.parseFromString(this.props.record.data[this.props.name] || '', 'text/html'); + return !!parsedOriginal.head.innerHTML.trim(); + } + get isTranslatable() { return this.props.record.fields[this.props.name].translate; } @@ -189,7 +199,7 @@ export class HtmlField extends Component { return this.props.record.data[this.props.name]; } get showIframe () { - return this.props.readonly && this.props.cssReadonlyAssetId; + return (this.sandboxedPreview && !this.state.showCodeView) || (this.props.readonly && this.props.cssReadonlyAssetId); } get wysiwygOptions() { let dynamicPlaceholderOptions = {}; @@ -411,7 +421,10 @@ export class HtmlField extends Component { return this.state.showCodeView && this.codeViewRef.el; } async _setupReadonlyIframe() { - const iframeTarget = this.iframeRef.el.contentDocument.querySelector('#iframe_target'); + const iframeTarget = this.sandboxedPreview + ? this.iframeRef.el.contentDocument.documentElement + : this.iframeRef.el.contentDocument.querySelector('#iframe_target'); + if (this.iframePromise && iframeTarget) { if (iframeTarget.innerHTML !== this.props.record.data[this.props.name]) { iframeTarget.innerHTML = this.props.record.data[this.props.name]; @@ -440,7 +453,6 @@ export class HtmlField extends Component { this.iframeRef.el.addEventListener('load', async () => { const _avoidDoubleLoad = ++avoidDoubleLoad; - const asset = await ajax.loadAsset(this.props.cssReadonlyAssetId); if (_avoidDoubleLoad !== avoidDoubleLoad) { console.warn('Wysiwyg immediate iframe double load detected'); @@ -452,52 +464,65 @@ export class HtmlField extends Component { } catch { return; } - cwindow.document - .open("text/html", "replace") - .write( - '' + - '' + - '' + - '\n' + - '\n' + - '\n' + - '\n' + - '
\n' + - '' + - ''); - - for (const cssLib of asset.cssLibs) { - const link = cwindow.document.createElement('link'); - link.setAttribute('type', 'text/css'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('href', cssLib); - cwindow.document.head.append(link); + if (!this.sandboxedPreview) { + cwindow.document + .open("text/html", "replace") + .write( + '' + + '' + + '' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '' + + ''); } - for (const cssContent of asset.cssContents) { - const style = cwindow.document.createElement('style'); - style.setAttribute('type', 'text/css'); - const textNode = cwindow.document.createTextNode(cssContent); - style.append(textNode); - cwindow.document.head.append(style); + if (this.props.cssReadonlyAssetId) { + const asset = await ajax.loadAsset(this.props.cssReadonlyAssetId); + for (const cssLib of asset.cssLibs) { + const link = cwindow.document.createElement('link'); + link.setAttribute('type', 'text/css'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('href', cssLib); + cwindow.document.head.append(link); + } + for (const cssContent of asset.cssContents) { + const style = cwindow.document.createElement('style'); + style.setAttribute('type', 'text/css'); + const textNode = cwindow.document.createTextNode(cssContent); + style.append(textNode); + cwindow.document.head.append(style); + } } - const iframeTarget = cwindow.document.querySelector('#iframe_target'); - iframeTarget.innerHTML = value; + if (!this.sandboxedPreview) { + const iframeTarget = cwindow.document.querySelector('#iframe_target'); + iframeTarget.innerHTML = value; - const script = cwindow.document.createElement('script'); - script.setAttribute('type', 'text/javascript'); - const scriptTextNode = document.createTextNode( - `if (window.top.${this._onUpdateIframeId}) {` + - `window.top.${this._onUpdateIframeId}(${_avoidDoubleLoad})` + - `}` - ); - script.append(scriptTextNode); - cwindow.document.body.append(script); + const script = cwindow.document.createElement('script'); + script.setAttribute('type', 'text/javascript'); + const scriptTextNode = document.createTextNode( + `if (window.top.${this._onUpdateIframeId}) {` + + `window.top.${this._onUpdateIframeId}(${_avoidDoubleLoad})` + + `}` + ); + script.append(scriptTextNode); + cwindow.document.body.append(script); + } else { + cwindow.document.documentElement.innerHTML = value; + } const height = cwindow.document.body.scrollHeight; this.iframeRef.el.style.height = Math.max(30, Math.min(height, 500)) + 'px'; retargetLinks(cwindow.document.body); + if (this.sandboxedPreview) { + this.state.iframeVisible = true; + this.onIframeUpdated(); + resolve(); + } }); // Force the iframe to call the `load` event. Without this line, the // event 'load' might never trigger. @@ -648,6 +673,7 @@ HtmlField.props = { cssReadonlyAssetId: { type: String, optional: true }, cssEditAssetId: { type: String, optional: true }, isInlineStyle: { type: Boolean, optional: true }, + sandboxedPreview: {type: Boolean, optional: true}, wrapper: { type: String, optional: true }, wysiwygOptions: { type: Object }, hasReadonlyModifiers: { type: Boolean, optional: true }, @@ -739,6 +765,7 @@ export const htmlField = { return { codeview: Boolean(odoo.debug && options.codeview), placeholder: attrs.placeholder, + sandboxedPreview: Boolean(options.sandboxedPreview), isCollaborative: options.collaborative, cssReadonlyAssetId: options.cssReadonly, diff --git a/addons/web_editor/static/src/js/backend/html_field.xml b/addons/web_editor/static/src/js/backend/html_field.xml index 354971cbb75..26eff3eea55 100644 --- a/addons/web_editor/static/src/js/backend/html_field.xml +++ b/addons/web_editor/static/src/js/backend/html_field.xml @@ -2,9 +2,9 @@ - + - +
@@ -29,7 +29,7 @@
-
+
diff --git a/addons/web_editor/static/tests/html_field_tests.js b/addons/web_editor/static/tests/html_field_tests.js index e537e8b2cd3..12b4a2e5b85 100644 --- a/addons/web_editor/static/tests/html_field_tests.js +++ b/addons/web_editor/static/tests/html_field_tests.js @@ -1,10 +1,21 @@ /** @odoo-module **/ -import { click, editInput, getFixture, makeDeferred, patchWithCleanup } from "@web/../tests/helpers/utils"; +import { click, editInput, getFixture, makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; import { HtmlField } from "@web_editor/js/backend/html_field"; import { onRendered } from "@odoo/owl"; +async function iframeReady(iframe) { + const iframeLoadPromise = makeDeferred(); + iframe.addEventListener("load", function () { + iframeLoadPromise.resolve(); + }); + if (!iframe.contentDocument.body) { + await iframeLoadPromise; + } + await nextTick(); // ensure document is loaded +} + QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { let serverData; let target; @@ -25,110 +36,46 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { setupViewRegistries(); }); - /** - * Check that documents with data in a node are set to readonly - * with a codeview option. - */ - QUnit.test("html fields with complete HTML document", async (assert) => { - assert.timeout(2000); - assert.expect(12); - let codeViewState = false; - let togglePromiseId = 0; - const togglePromises = [makeDeferred(), makeDeferred()]; - patchWithCleanup(HtmlField.prototype, { - setup: function () { - this._super(...arguments); - onRendered(() => { - if (codeViewState !== this.state.showCodeView) { - togglePromises[togglePromiseId].resolve(); - } - codeViewState = this.state.showCodeView; - }); - }, - }); - const htmlDocumentTextTemplate = (text, color) => ` - - - - - - - - - ${text} - - - `; + QUnit.module('Sandboxed Preview'); + + QUnit.test("complex html is automatically in sandboxed preview mode", async (assert) => { serverData.models.partner.records = [{ id: 1, - txt: htmlDocumentTextTemplate('Hello', 'red'), + txt: ` + + + + + + + + + + Hello + + + `, }]; - const writePromise = makeDeferred(); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: ` -
- - - - - - - + + `, - mockRPC(route, args) { - if (args.method === "write" && args.model === 'partner') { - assert.equal(args.args[1].txt, htmlDocumentTextTemplate('Hi', 'black')); - writePromise.resolve(); - } - } }); - const fieldHtml = target.querySelector('.o_field_html'); - let readonlyNode = fieldHtml.querySelector('.o_readonly'); - assert.ok(readonlyNode); - assert.equal(readonlyNode.innerText, 'Hello'); - assert.equal(window.getComputedStyle(readonlyNode).color, 'rgb(255, 0, 0)'); - - const codeViewButton = fieldHtml.querySelector('.o_codeview_btn'); - assert.ok(codeViewButton); - - await click(codeViewButton); - await togglePromises[togglePromiseId]; - const codeView = fieldHtml.querySelector('textarea.o_codeview'); - assert.ok(codeView); - assert.equal(codeView.value, htmlDocumentTextTemplate('Hello', 'red')); - - await editInput(codeView, null, htmlDocumentTextTemplate('Hi', 'black')); - - assert.ok(codeViewButton); - togglePromiseId++; - await click(codeViewButton); - await togglePromises[togglePromiseId]; - readonlyNode = fieldHtml.querySelector('.o_readonly'); - assert.ok(readonlyNode); - assert.equal(readonlyNode.innerText, 'Hi'); - assert.equal(window.getComputedStyle(readonlyNode).color, 'rgb(0, 0, 0)'); - - const saveButton = target.querySelector('.o_form_button_save'); - assert.ok(saveButton); - await click(saveButton); - await writePromise; + assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin"]'); }); - /** - * Check that documents with data in a node and with the readonly prop - * do not display the codeview button - */ - QUnit.test("html fields with complete HTML document in readonly mode", async (assert) => { + QUnit.test("readonly sandboxed preview", async (assert) => { serverData.models.partner.records = [{ id: 1, txt: ` @@ -156,15 +103,153 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { serverData, arch: `
- + `, }); - const readonlyElement = target.querySelector('.o_field_html .o_readonly'); - assert.ok(readonlyElement); - assert.strictEqual(readonlyElement.innerText, 'Hello'); - assert.strictEqual(window.getComputedStyle(readonlyElement).color, 'rgb(0, 0, 255)'); + const readonlyIframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin"]'); + assert.ok(readonlyIframe); + await iframeReady(readonlyIframe); + assert.strictEqual(readonlyIframe.contentDocument.body.innerText, 'Hello'); + assert.strictEqual(readonlyIframe.contentWindow.getComputedStyle(readonlyIframe.contentDocument.body).color, 'rgb(0, 0, 255)'); - assert.containsN(target, '.o_codeview_btn', 0, 'Codeview toggle should not be possible in readonly mode.'); + assert.containsN(target, '#codeview-btn-group > button', 0, 'Codeview toggle should not be possible in readonly mode.'); }); + + QUnit.test("sandboxed preview display and editing", async (assert) => { + let codeViewState = false; + const togglePromises = [makeDeferred(), makeDeferred()]; + let togglePromiseId = 0; + const writePromise = makeDeferred(); + patchWithCleanup(HtmlField.prototype, { + setup: function () { + this._super(...arguments); + onRendered(() => { + if (codeViewState !== this.state.showCodeView) { + togglePromises[togglePromiseId].resolve(); + } + codeViewState = this.state.showCodeView; + }); + }, + }); + const htmlDocumentTextTemplate = (text, color) => ` + + + + + + ${text} + + + `; + serverData.models.partner.records = [{ + id: 1, + txt: htmlDocumentTextTemplate('Hello', 'red'), + }]; + await makeView({ + type: "form", + resId: 1, + resModel: "partner", + serverData, + arch: ` +
+ + + + + + + +
`, + mockRPC(route, args) { + if (args.method === "write" && args.model === 'partner') { + assert.equal(args.args[1].txt, htmlDocumentTextTemplate('Hi', 'blue')); + writePromise.resolve(); + } + } + }); + + // check original displayed content + let iframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin"]'); + assert.ok(iframe, 'Should use a sanboxed iframe'); + await iframeReady(iframe); + assert.strictEqual(iframe.contentDocument.body.textContent.trim(), 'Hello'); + assert.strictEqual(iframe.contentDocument.head.querySelector('style').textContent.trim().replace(/\s/g, ''), + 'body{color:red;}', 'Head nodes should remain unaltered in the head'); + assert.equal(iframe.contentWindow.getComputedStyle(iframe.contentDocument.body).color, 'rgb(255, 0, 0)'); + // check button is there + assert.containsOnce(target, '#codeview-btn-group > button'); + // edit in xml editor + await click(target, '#codeview-btn-group > button'); + await togglePromises[togglePromiseId]; + togglePromiseId++; + assert.containsOnce(target, '.o_field_html[name="txt"] textarea'); + await editInput(target, '.o_field_html[name="txt"] textarea', htmlDocumentTextTemplate('Hi', 'blue')); + await click(target, '#codeview-btn-group > button'); + await togglePromises[togglePromiseId]; + // check dispayed content after edit + iframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin"]'); + await iframeReady(iframe); + assert.strictEqual(iframe.contentDocument.body.textContent.trim(), 'Hi'); + assert.strictEqual(iframe.contentDocument.head.querySelector('style').textContent.trim().replace(/\s/g, ''), + 'body{color:blue;}', 'Head nodes should remain unaltered in the head'); + assert.equal(iframe.contentWindow.getComputedStyle(iframe.contentDocument.body).color, 'rgb(0, 0, 255)', + 'Style should be applied inside the iframe.'); + + const saveButton = target.querySelector('.o_form_button_save'); + assert.ok(saveButton); + await click(saveButton); + await writePromise; + }); + + + QUnit.test("sanboxed preview mode not automatically enabled for regular values", async (assert) => { + serverData.models.partner.records = [{ + id: 1, + txt: ` + +

Hello

+ + `, + }]; + await makeView({ + type: "form", + resId: 1, + resModel: "partner", + serverData, + arch: ` +
+ + `, + }); + + assert.containsN(target, '.o_field_html[name="txt"] iframe[sandbox]', 0); + assert.containsN(target, '.o_field_html[name="txt"] textarea', 0); + }); + + QUnit.test("sandboxed preview option applies even for simple text", async (assert) => { + serverData.models.partner.records = [{ + id: 1, + txt: ` + Hello + `, + }]; + await makeView({ + type: "form", + resId: 1, + resModel: "partner", + serverData, + arch: ` +
+ + `, + }); + + assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin"]'); + }); + }); diff --git a/addons/website_sale/static/tests/tours/website_sale_shop_mail.js b/addons/website_sale/static/tests/tours/website_sale_shop_mail.js index d1ecd83c839..3315443c125 100644 --- a/addons/website_sale/static/tests/tours/website_sale_shop_mail.js +++ b/addons/website_sale/static/tests/tours/website_sale_shop_mail.js @@ -80,17 +80,5 @@ registry.category("web_tour.tours").add('shop_mail', { { content: "wait mail to be sent, and go see it", trigger: '.o-mail-Message-body:contains("Your"):contains("order")', - run: function () { - window.location.href = "/web#action=mail.action_view_mail_mail&view_type=list"; - }, - }, - { - content: "click on the first email", - trigger: '.o_data_cell:contains("(Ref S")', - }, - { - content: "check it's the correct email, and the URL is correct too", - trigger: 'div.o_field_html[name="body_content"] p:contains("Your"):contains("order")', - extra_trigger: 'div.o_field_html[name="body_content"] a[href^="https://my-test-domain.com"]', }, ]}); diff --git a/addons/website_sale/tests/test_website_sale_mail.py b/addons/website_sale/tests/test_website_sale_mail.py index d714532d504..001cb911315 100644 --- a/addons/website_sale/tests/test_website_sale_mail.py +++ b/addons/website_sale/tests/test_website_sale_mail.py @@ -4,6 +4,7 @@ from unittest.mock import patch import odoo +from odoo import fields from odoo.tests import tagged from odoo.tests.common import HttpCase @@ -29,4 +30,11 @@ class TestWebsiteSaleMail(HttpCase): self.env['ir.config_parameter'].sudo().set_param('mail_mobile.disable_redirect_firebase_dynamic_link', True) with patch.object(MailMail, 'unlink', lambda self: None): + start_time = fields.Datetime.now() self.start_tour("/", 'shop_mail', login="admin") + new_mail = self.env['mail.mail'].search([('create_date', '>=', start_time), + ('body_html', 'ilike', 'https://my-test-domain.com')], + order='create_date DESC', limit=1) + self.assertTrue(new_mail) + self.assertIn('Your', new_mail.body_html) + self.assertIn('Order', new_mail.body_html)