diff --git a/addons/mail/views/mail_mail_views.xml b/addons/mail/views/mail_mail_views.xml index 795589938eff734ae2b58e8d02f6d9c8ebe1eb26..b58cc0367828fb710b0089c8cb56c6733a09d90f 100644 --- a/addons/mail/views/mail_mail_views.xml +++ b/addons/mail/views/mail_mail_views.xml @@ -39,7 +39,9 @@ </group> <notebook> <page string="Body" name="body"> - <field name="body_content" options="{'style-inline': true}"/> + <field name="body_html" widget="html" + options="{'sandboxedPreview': true}" + attrs="{'readonly': [('state', 'not in', ['outgoing', 'exception'])]}"/> </page> <page string="Advanced" name="advanced" groups="base.group_no_one"> <group> 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 9deebe4414b44b63c579e664a6c76bb448a46af8..26867911413afab43b3eef65191ce5d0b666399c 100644 --- a/addons/web_editor/static/src/js/backend/html_field.js +++ b/addons/web_editor/static/src/js/backend/html_field.js @@ -74,11 +74,8 @@ export class HtmlFieldWysiwygAdapterComponent extends ComponentAdapter { export class HtmlField extends Component { setup() { - // readonly if nodes in <head> 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.value || '', '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"); @@ -117,7 +114,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; } @@ -135,7 +132,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) { @@ -170,11 +167,24 @@ 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.value || '', 'text/html'); + return !!parsedOriginal.head.innerHTML.trim(); + } + get markupValue () { return markup(this.props.value); } 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 = {}; @@ -393,7 +403,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.value) { iframeTarget.innerHTML = this.props.value; @@ -422,7 +435,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'); @@ -434,52 +446,66 @@ export class HtmlField extends Component { } catch (_e) { return; } - cwindow.document - .open("text/html", "replace") - .write( - '<!DOCTYPE html><html>' + - '<head>' + - '<meta charset="utf-8"/>' + - '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' + - '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' + - '</head>\n' + - '<body class="o_in_iframe o_readonly" style="overflow: hidden;">\n' + - '<div id="iframe_target"></div>\n' + - '</body>' + - '</html>'); - - 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); + if (!this.sandboxedPreview) { + cwindow.document + .open("text/html", "replace") + .write( + '<!DOCTYPE html><html>' + + '<head>' + + '<meta charset="utf-8"/>' + + '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' + + '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' + + '</head>\n' + + '<body class="o_in_iframe o_readonly" style="overflow: hidden;">\n' + + '<div id="iframe_target"></div>\n' + + '</body>' + + '</html>'); } - const iframeTarget = cwindow.document.querySelector('#iframe_target'); - iframeTarget.innerHTML = value; + 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 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); + 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); + } 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. @@ -626,6 +652,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 }, }; @@ -672,6 +699,7 @@ HtmlField.extractProps = ({ attrs, field }) => { isTranslatable: field.translate, fieldName: field.name, codeview: Boolean(odoo.debug && attrs.options.codeview), + sandboxedPreview: Boolean(attrs.options.sandboxedPreview), placeholder: attrs.placeholder, isCollaborative: attrs.options.collaborative, 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 41cbc1ec98ea79c60f76e939f80d5276b5b8dbce..80ca01b02ac13887fb2905061322d825bab80068 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 @@ <templates id="template" xml:space="preserve"> <t t-name="web_editor.HtmlField" owl="1"> - <t t-if="props.readonly || props.notEditable || (containsComplexHTML and !state.showCodeView)"> + <t t-if="props.readonly || props.notEditable || (sandboxedPreview and !state.showCodeView)"> <t t-if="this.showIframe"> - <iframe t-ref="iframe" t-att-class="{'d-none': !this.state.iframeVisible, 'o_readonly': true}"></iframe> + <iframe t-ref="iframe" t-att-class="{'d-none': !this.state.iframeVisible, 'o_readonly': true}" t-att-sandbox="sandboxedPreview ? 'allow-same-origin' : null"></iframe> </t> <t t-else=""> <div t-ref="readonlyElement" class="o_readonly" t-out="markupValue" /> @@ -29,7 +29,7 @@ </span> </t> </t> - <t t-if="state.showCodeView || (containsComplexHTML and !props.readonly and !props.notEditable)"> + <t t-if="state.showCodeView || (sandboxedPreview and !props.readonly and !props.notEditable)"> <div t-ref="codeViewButton" id="codeview-btn-group" class="btn-group" t-on-click="() => this.toggleCodeView()"> <button class="o_codeview_btn btn btn-primary"> <i class="fa fa-code" /> diff --git a/addons/web_editor/static/tests/html_field_tests.js b/addons/web_editor/static/tests/html_field_tests.js index 3b3caa25a897fb04dc4645ea71b26c2cd575dbe9..659cc7dd44020d9a229a3675166559a2bb1a2aab 100644 --- a/addons/web_editor/static/tests/html_field_tests.js +++ b/addons/web_editor/static/tests/html_field_tests.js @@ -1,11 +1,22 @@ /** @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 { registry } from "@web/core/registry"; 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; @@ -29,16 +40,91 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { registry.category("fields").add("html", HtmlField, { force: true }); }); - /** - * Check that documents with data in a <head> 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); + + QUnit.module('Sandboxed Preview'); + + QUnit.test("complex html is automatically in sandboxed preview mode", async (assert) => { + serverData.models.partner.records = [{ + id: 1, + txt: ` + <!DOCTYPE HTML> + <html xml:lang="en" lang="en"> + <head> + + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> + <meta name="format-detection" content="telephone=no"/> + <style type="text/css"> + body { + color: blue; + } + </style> + </head> + <body> + Hello + </body> + </html> + `, + }]; + await makeView({ + type: "form", + resId: 1, + resModel: "partner", + serverData, + arch: ` + <form> + <field name="txt" widget="html"/> + </form>`, + }); + + assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin"]'); + }); + + QUnit.test("readonly sandboxed preview", async (assert) => { + serverData.models.partner.records = [{ + id: 1, + txt: ` + <!DOCTYPE HTML> + <html xml:lang="en" lang="en"> + <head> + + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> + <meta name="format-detection" content="telephone=no"/> + <style type="text/css"> + body { + color: blue; + } + </style> + </head> + <body> + Hello + </body> + </html>`, + }]; + await makeView({ + type: "form", + resId: 1, + resModel: "partner", + serverData, + arch: ` + <form string="Partner"> + <field name="txt" widget="html" readonly="1" options="{'sandboxedPreview': true}"/> + </form>`, + }); + + 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, '#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; - let togglePromiseId = 0; const togglePromises = [makeDeferred(), makeDeferred()]; + let togglePromiseId = 0; + const writePromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { setup: function () { this._super(...arguments); @@ -51,13 +137,9 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { }, }); const htmlDocumentTextTemplate = (text, color) => ` - <!DOCTYPE HTML> - <html xml:lang="en" lang="en"> + <html> <head> - - <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> - <meta name="format-detection" content="telephone=no"/> - <style type="text/css"> + <style> body { color: ${color}; } @@ -72,55 +154,55 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { id: 1, txt: htmlDocumentTextTemplate('Hello', 'red'), }]; - const writePromise = makeDeferred(); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: ` - <form string="Partner"> + <form> <sheet> <notebook> <page string="Body" name="body"> - <field name="txt" widget="html"/> + <field name="txt" widget="html" options="{'sandboxedPreview': true}"/> </page> </notebook> </sheet> </form>`, mockRPC(route, args) { if (args.method === "write" && args.model === 'partner') { - assert.equal(args.args[1].txt, htmlDocumentTextTemplate('Hi', 'black')); + assert.equal(args.args[1].txt, htmlDocumentTextTemplate('Hi', 'blue')); 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); + // 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]; - 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); + 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]; - readonlyNode = fieldHtml.querySelector('.o_readonly'); - assert.ok(readonlyNode); - assert.equal(readonlyNode.innerText, 'Hi'); - assert.equal(window.getComputedStyle(readonlyNode).color, 'rgb(0, 0, 0)'); + // 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); @@ -128,30 +210,15 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { await writePromise; }); - /** - * Check that documents with data in a <head> 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("sanboxed preview mode not automatically enabled for regular values", async (assert) => { serverData.models.partner.records = [{ id: 1, txt: ` - <!DOCTYPE HTML> - <html xml:lang="en" lang="en"> - <head> - - <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> - <meta name="format-detection" content="telephone=no"/> - <style type="text/css"> - body { - color: blue; - } - </style> - </head> <body> - Hello + <p>Hello</p> </body> - </html>`, + `, }]; await makeView({ type: "form", @@ -159,16 +226,34 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => { resModel: "partner", serverData, arch: ` - <form string="Partner"> - <field name="txt" widget="html" readonly="1"/> + <form> + <field name="txt" widget="html"/> </form>`, }); - 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)'); + assert.containsN(target, '.o_field_html[name="txt"] iframe[sandbox]', 0); + assert.containsN(target, '.o_field_html[name="txt"] textarea', 0); + }); - assert.containsN(target, '.o_codeview_btn', 0, 'Codeview toggle should not be possible in readonly mode.'); + 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: ` + <form> + <field name="txt" widget="html" options="{'sandboxedPreview': true}"/> + </form>`, + }); + + 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 7946bc4d4d92f5ae12f56d2662f917280b8f8d91..f7580d2a64ca87f55c4cf756699f3e1d9476e49f 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 @@ -84,18 +84,6 @@ tour.register('shop_mail', { { content: "wait mail to be sent, and go see it", trigger: '.o_Message_content: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 d714532d50466ca2e5ef5003fa6359d9c560dc68..001cb9113155c577a4c07dbb8a08252ddc153290 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)