[FIX] web_editor: prevent editing of header

The way the live HTML editor for html_field works means the header
(and other nodes that should only appear once) are removed
and the content inserted inside the body.
This results in values being edited without the user being aware.

For emails, this results in any email using 'style' in the header
to break on GMail and other clients that do not support 'style'
outside of the header.

When an html_field is passed a value containing a header
the html_field appears readonly and the user is able to toggle
the code view. Which enables them to edit the 'raw' HTML and see changes
while preventing the presentation view from affecting the database value

task - 3035163

closes odoo/odoo#118920

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
Signed-off-by: Thiry Renaud (reth) <reth@odoo.com>
This commit is contained in:
Renaud Thiry
2023-04-18 18:29:08 +02:00
parent 76989ab5a7
commit c5bb2d0838
3 changed files with 149 additions and 15 deletions
@@ -74,6 +74,12 @@ 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.readonlyElementRef = useRef("readonlyElement");
this.codeViewRef = useRef("codeView");
this.iframeRef = useRef("iframe");
@@ -326,19 +332,21 @@ export class HtmlField extends Component {
}
/**
* Toggle the code view and update the UI.
*
* @param {JQuery} $codeview
*/
toggleCodeView() {
this.state.showCodeView = !this.state.showCodeView;
this.wysiwyg.odooEditor.observerUnactive('toggleCodeView');
if (this.state.showCodeView) {
this.wysiwyg.odooEditor.toolbarHide();
const value = this.wysiwyg.getValue();
this.props.record.update({ [this.props.name]: value });
} else {
this.wysiwyg.odooEditor.observerActive('toggleCodeView');
if (this.wysiwyg) {
this.wysiwyg.odooEditor.observerUnactive('toggleCodeView');
if (this.state.showCodeView) {
this.wysiwyg.odooEditor.toolbarHide();
const value = this.wysiwyg.getValue();
this.props.record.update({ [this.props.name]: value });
} else {
this.wysiwyg.odooEditor.observerActive('toggleCodeView');
}
}
if (!this.state.showCodeView) {
const $codeview = $(this.codeViewRef.el);
const value = $codeview.val();
this.props.record.update({ [this.props.name]: value });
@@ -2,7 +2,7 @@
<templates id="template" xml:space="preserve">
<t t-name="web_editor.HtmlField" owl="1">
<t t-if="props.readonly || props.notEditable">
<t t-if="props.readonly || props.notEditable || (containsComplexHTML and !state.showCodeView)">
<t t-if="this.showIframe">
<iframe t-ref="iframe" t-att-class="{'d-none': !this.state.iframeVisible, 'o_readonly': true}"></iframe>
</t>
@@ -13,11 +13,6 @@
<div t-else="" class="h-100" t-ref="spellcheck">
<t t-if="state.showCodeView">
<textarea t-ref="codeView" class="o_codeview" t-att-value="markupValue"/>
<div 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>
</div>
</t>
<t t-else="">
<HtmlFieldWysiwygAdapterComponent Component="this.Wysiwyg"
@@ -34,6 +29,11 @@
</span>
</t>
</div>
<div t-if="state.showCodeView || containsComplexHTML" 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>
</div>
</t>
</templates>
@@ -0,0 +1,126 @@
/** @odoo-module **/
import { click, editInput, getFixture, makeDeferred, 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";
QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => {
let serverData;
let target;
beforeEach(() => {
serverData = {
models: {
partner: {
fields: {
txt: { string: "txt", type: "html", trim: true },
},
records: [],
},
},
};
target = getFixture();
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>
`;
serverData.models.partner.records = [{
id: 1,
txt: htmlDocumentTextTemplate('Hello', 'red'),
}];
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>`,
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;
});
});