[FIX] mail: allow editing of mail mail

As it holds full HTML documents, mail.mail often encounters issues with
the html editor.

We piggy-back off the intoduction of the switching
behavior between code-view and readonly-view behavior introduced in
a5c0085ce91570c6c961e3293dfe50f4faba6043

The 'sandboxed preview' mode can now be enabled with an option
and will ensure all content can only be viewed in a sandboxed iframe
or in the code editor.

This re-introduces editing, which was mistakenly fully disabled in the
usability fix that introduced body_content here:
5055b374c3bac0126f441dad61afd7e0b68b1370

Some tests are added to ensure the key points of this commit remain
the same. Tests are re-ordered alphabetically to match the order
of execution, making debugging easier.

task-3255777

closes odoo/odoo#121976

X-original-commit: 062569ef042f3b01044c5e4cdbd8b71e96bfd39d
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
Renaud Thiry
2023-05-23 16:32:13 +02:00
parent c3866ca706
commit bb542f8374
6 changed files with 269 additions and 159 deletions
+3 -1
View File
@@ -43,7 +43,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>
@@ -76,11 +76,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.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(
'<!DOCTYPE html><html>' +
'<head>' +
'<meta charset="utf-8"/>' +
'<meta http-equiv="X-UA-Compatible" content="IE=edge"/>\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);
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"/>\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 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,
@@ -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' : false"></iframe>
</t>
<t t-else="">
<div t-ref="readonlyElement" class="o_readonly" t-out="markupValue" />
@@ -29,7 +29,7 @@
</span>
</t>
</div>
<div t-if="state.showCodeView || (containsComplexHTML and !props.readonly and !props.notEditable)" t-ref="codeViewButton" id="codeview-btn-group" class="btn-group" t-on-click="toggleCodeView">
<div t-if="state.showCodeView || (sandboxedPreview and !props.readonly and !props.notEditable)" t-ref="codeViewButton" id="codeview-btn-group" class="btn-group" t-on-click="toggleCodeView">
<button class="o_codeview_btn btn btn-primary">
<i class="fa fa-code" />
</button>
@@ -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 <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);
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) => `
<!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: ${color};
}
</style>
</head>
<body>
${text}
</body>
</html>
`;
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: `
<!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>
`,
}];
const writePromise = makeDeferred();
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form string="Partner">
<sheet>
<notebook>
<page string="Body" name="body">
<field name="txt" widget="html"/>
</page>
</notebook>
</sheet>
<form>
<field name="txt" widget="html"/>
</form>`,
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 <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("readonly sandboxed preview", async (assert) => {
serverData.models.partner.records = [{
id: 1,
txt: `
@@ -156,15 +103,153 @@ QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => {
serverData,
arch: `
<form string="Partner">
<field name="txt" widget="html" readonly="1"/>
<field name="txt" widget="html" readonly="1" options="{'sandboxedPreview': true}"/>
</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)');
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) => `
<html>
<head>
<style>
body {
color: ${color};
}
</style>
</head>
<body>
${text}
</body>
</html>
`;
serverData.models.partner.records = [{
id: 1,
txt: htmlDocumentTextTemplate('Hello', 'red'),
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<sheet>
<notebook>
<page string="Body" name="body">
<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', '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: `
<body>
<p>Hello</p>
</body>
`,
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html"/>
</form>`,
});
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: `
<form>
<field name="txt" widget="html" options="{'sandboxedPreview': true}"/>
</form>`,
});
assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin"]');
});
});
@@ -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"]',
},
]});
@@ -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)