html_field_tests.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. /** @odoo-module **/
  2. import { click, editInput, getFixture, makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
  3. import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
  4. import { registry } from "@web/core/registry";
  5. import { FormController } from '@web/views/form/form_controller';
  6. import { HtmlField } from "@web_editor/js/backend/html_field";
  7. import { parseHTML } from "@web_editor/js/editor/odoo-editor/src/utils/utils";
  8. import { onRendered } from "@odoo/owl";
  9. import { wysiwygData } from "web_editor.test_utils";
  10. // Legacy
  11. import legacyEnv from 'web.commonEnv';
  12. async function iframeReady(iframe) {
  13. const iframeLoadPromise = makeDeferred();
  14. iframe.addEventListener("load", function () {
  15. iframeLoadPromise.resolve();
  16. });
  17. if (!iframe.contentDocument.body) {
  18. await iframeLoadPromise;
  19. }
  20. await nextTick(); // ensure document is loaded
  21. }
  22. QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => {
  23. let serverData;
  24. let target;
  25. beforeEach(() => {
  26. serverData = {
  27. models: {
  28. partner: {
  29. fields: {
  30. txt: { string: "txt", type: "html", trim: true },
  31. },
  32. records: [],
  33. },
  34. },
  35. };
  36. target = getFixture();
  37. setupViewRegistries();
  38. // Explicitly removed by web_editor, we need to add it back
  39. registry.category("fields").add("html", HtmlField, { force: true });
  40. });
  41. QUnit.module('Sandboxed Preview');
  42. QUnit.test("complex html is automatically in sandboxed preview mode", async (assert) => {
  43. serverData.models.partner.records = [{
  44. id: 1,
  45. txt: `
  46. <!DOCTYPE HTML>
  47. <html xml:lang="en" lang="en">
  48. <head>
  49. <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
  50. <meta name="format-detection" content="telephone=no"/>
  51. <style type="text/css">
  52. body {
  53. color: blue;
  54. }
  55. </style>
  56. </head>
  57. <body>
  58. Hello
  59. </body>
  60. </html>
  61. `,
  62. }];
  63. await makeView({
  64. type: "form",
  65. resId: 1,
  66. resModel: "partner",
  67. serverData,
  68. arch: `
  69. <form>
  70. <field name="txt" widget="html"/>
  71. </form>`,
  72. });
  73. assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
  74. });
  75. QUnit.test("readonly sandboxed preview", async (assert) => {
  76. serverData.models.partner.records = [{
  77. id: 1,
  78. txt: `
  79. <!DOCTYPE HTML>
  80. <html xml:lang="en" lang="en">
  81. <head>
  82. <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
  83. <meta name="format-detection" content="telephone=no"/>
  84. <style type="text/css">
  85. body {
  86. color: blue;
  87. }
  88. </style>
  89. </head>
  90. <body>
  91. Hello
  92. </body>
  93. </html>`,
  94. }];
  95. await makeView({
  96. type: "form",
  97. resId: 1,
  98. resModel: "partner",
  99. serverData,
  100. arch: `
  101. <form string="Partner">
  102. <field name="txt" widget="html" readonly="1" options="{'sandboxedPreview': true}"/>
  103. </form>`,
  104. });
  105. const readonlyIframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
  106. assert.ok(readonlyIframe);
  107. await iframeReady(readonlyIframe);
  108. assert.strictEqual(readonlyIframe.contentDocument.body.innerText, 'Hello');
  109. assert.strictEqual(readonlyIframe.contentWindow.getComputedStyle(readonlyIframe.contentDocument.body).color, 'rgb(0, 0, 255)');
  110. assert.containsN(target, '#codeview-btn-group > button', 0, 'Codeview toggle should not be possible in readonly mode.');
  111. });
  112. QUnit.test("sandboxed preview display and editing", async (assert) => {
  113. let codeViewState = false;
  114. const togglePromises = [makeDeferred(), makeDeferred()];
  115. let togglePromiseId = 0;
  116. const writePromise = makeDeferred();
  117. patchWithCleanup(HtmlField.prototype, {
  118. setup: function () {
  119. this._super(...arguments);
  120. onRendered(() => {
  121. if (codeViewState !== this.state.showCodeView) {
  122. togglePromises[togglePromiseId].resolve();
  123. }
  124. codeViewState = this.state.showCodeView;
  125. });
  126. },
  127. });
  128. const htmlDocumentTextTemplate = (text, color) => `
  129. <html>
  130. <head>
  131. <style>
  132. body {
  133. color: ${color};
  134. }
  135. </style>
  136. </head>
  137. <body>
  138. ${text}
  139. </body>
  140. </html>
  141. `;
  142. serverData.models.partner.records = [{
  143. id: 1,
  144. txt: htmlDocumentTextTemplate('Hello', 'red'),
  145. }];
  146. await makeView({
  147. type: "form",
  148. resId: 1,
  149. resModel: "partner",
  150. serverData,
  151. arch: `
  152. <form>
  153. <sheet>
  154. <notebook>
  155. <page string="Body" name="body">
  156. <field name="txt" widget="html" options="{'sandboxedPreview': true}"/>
  157. </page>
  158. </notebook>
  159. </sheet>
  160. </form>`,
  161. mockRPC(route, args) {
  162. if (args.method === "write" && args.model === 'partner') {
  163. assert.equal(args.args[1].txt, htmlDocumentTextTemplate('Hi', 'blue'));
  164. writePromise.resolve();
  165. }
  166. }
  167. });
  168. // check original displayed content
  169. let iframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
  170. assert.ok(iframe, 'Should use a sanboxed iframe');
  171. await iframeReady(iframe);
  172. assert.strictEqual(iframe.contentDocument.body.textContent.trim(), 'Hello');
  173. assert.strictEqual(iframe.contentDocument.head.querySelector('style').textContent.trim().replace(/\s/g, ''),
  174. 'body{color:red;}', 'Head nodes should remain unaltered in the head');
  175. assert.equal(iframe.contentWindow.getComputedStyle(iframe.contentDocument.body).color, 'rgb(255, 0, 0)');
  176. // check button is there
  177. assert.containsOnce(target, '#codeview-btn-group > button');
  178. // edit in xml editor
  179. await click(target, '#codeview-btn-group > button');
  180. await togglePromises[togglePromiseId];
  181. togglePromiseId++;
  182. assert.containsOnce(target, '.o_field_html[name="txt"] textarea');
  183. await editInput(target, '.o_field_html[name="txt"] textarea', htmlDocumentTextTemplate('Hi', 'blue'));
  184. await click(target, '#codeview-btn-group > button');
  185. await togglePromises[togglePromiseId];
  186. // check dispayed content after edit
  187. iframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
  188. await iframeReady(iframe);
  189. assert.strictEqual(iframe.contentDocument.body.textContent.trim(), 'Hi');
  190. assert.strictEqual(iframe.contentDocument.head.querySelector('style').textContent.trim().replace(/\s/g, ''),
  191. 'body{color:blue;}', 'Head nodes should remain unaltered in the head');
  192. assert.equal(iframe.contentWindow.getComputedStyle(iframe.contentDocument.body).color, 'rgb(0, 0, 255)',
  193. 'Style should be applied inside the iframe.');
  194. const saveButton = target.querySelector('.o_form_button_save');
  195. assert.ok(saveButton);
  196. await click(saveButton);
  197. await writePromise;
  198. });
  199. QUnit.test("sanboxed preview mode not automatically enabled for regular values", async (assert) => {
  200. serverData.models.partner.records = [{
  201. id: 1,
  202. txt: `
  203. <body>
  204. <p>Hello</p>
  205. </body>
  206. `,
  207. }];
  208. await makeView({
  209. type: "form",
  210. resId: 1,
  211. resModel: "partner",
  212. serverData,
  213. arch: `
  214. <form>
  215. <field name="txt" widget="html"/>
  216. </form>`,
  217. });
  218. assert.containsN(target, '.o_field_html[name="txt"] iframe[sandbox]', 0);
  219. assert.containsN(target, '.o_field_html[name="txt"] textarea', 0);
  220. });
  221. QUnit.test("sandboxed preview option applies even for simple text", async (assert) => {
  222. serverData.models.partner.records = [{
  223. id: 1,
  224. txt: `
  225. Hello
  226. `,
  227. }];
  228. await makeView({
  229. type: "form",
  230. resId: 1,
  231. resModel: "partner",
  232. serverData,
  233. arch: `
  234. <form>
  235. <field name="txt" widget="html" options="{'sandboxedPreview': true}"/>
  236. </form>`,
  237. });
  238. assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
  239. });
  240. QUnit.module('Readonly mode');
  241. QUnit.test("Links should open on a new tab", async (assert) => {
  242. assert.expect(6);
  243. serverData.models.partner.records = [{
  244. id: 1,
  245. txt: `
  246. <body>
  247. <a href="/contactus">Relative link</a>
  248. <a href="${location.origin}/contactus">Internal link</a>
  249. <a href="https://google.com">External link</a>
  250. </body>`,
  251. }];
  252. await makeView({
  253. type: "form",
  254. resId: 1,
  255. resModel: "partner",
  256. serverData,
  257. arch: `
  258. <form>
  259. <field name="txt" widget="html" readonly="1"/>
  260. </form>`,
  261. });
  262. for (const link of target.querySelectorAll('a')) {
  263. assert.strictEqual(link.getAttribute('target'), '_blank');
  264. assert.strictEqual(link.getAttribute('rel'), 'noreferrer');
  265. }
  266. });
  267. QUnit.module('Save scenarios');
  268. QUnit.test("Ensure that urgentSave works even with modified image to save", async (assert) => {
  269. assert.expect(5);
  270. let formController;
  271. // Patch to get the controller instance.
  272. patchWithCleanup(FormController.prototype, {
  273. setup() {
  274. this._super(...arguments);
  275. formController = this;
  276. }
  277. });
  278. // Patch to get a promise to get the htmlField component instance when
  279. // the wysiwyg is instancied.
  280. const htmlFieldPromise = makeDeferred();
  281. patchWithCleanup(HtmlField.prototype, {
  282. async startWysiwyg() {
  283. await this._super(...arguments);
  284. await nextTick();
  285. htmlFieldPromise.resolve(this);
  286. }
  287. });
  288. // Add a partner record and ir.attachments model and record.
  289. serverData.models.partner.records.push({
  290. id: 1,
  291. txt: "<p class='test_target'><br></p>",
  292. });
  293. serverData.models["ir.attachment"] = wysiwygData({})["ir.attachment"];
  294. const imageRecord = serverData.models["ir.attachment"].records[0];
  295. // Method to get the html of a cropped image.
  296. // Use `data-src` instead of `src` when the SRC is an URL that would
  297. // make a call to the server.
  298. const getImageContainerHTML = (src, isModified) => {
  299. return `
  300. <p>
  301. <img
  302. class="img img-fluid o_we_custom_image o_we_image_cropped${isModified ? ' o_modified_image_to_save' : ''}"
  303. data-original-id="${imageRecord.id}"
  304. data-original-src="${imageRecord.image_src}"
  305. data-mimetype="image/png"
  306. data-width="50"
  307. data-height="50"
  308. data-scale-x="1"
  309. data-scale-y="1"
  310. data-aspect-ratio="0/0"
  311. ${src.startsWith("/web") ? 'data-src="' : 'src="'}${src}"
  312. >
  313. <br>
  314. </p>
  315. `.replace(/(?:\s|(?:\r\n))+/g, ' ')
  316. .replace(/\s?(<|>)\s?/g, '$1');
  317. };
  318. // Promise to resolve when we want the response of the modify_image RPC.
  319. const modifyImagePromise = makeDeferred();
  320. let writeCount = 0;
  321. let modifyImageCount = 0;
  322. // Valid base64 encoded image in its transitory modified state.
  323. const imageContainerHTML = getImageContainerHTML(
  324. "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII",
  325. true
  326. );
  327. // New src URL to assign to the image when the modification is
  328. // "registered".
  329. const newImageSrc = "/web/image/1234/cropped_transparent.png";
  330. const mockRPC = async function (route, args) {
  331. if (
  332. route === '/web/dataset/call_kw/partner/write' &&
  333. args.model === 'partner'
  334. ) {
  335. if (writeCount === 0) {
  336. // Save normal value without image.
  337. assert.equal(args.args[1].txt, `<p class="test_target"><br></p>`);
  338. } else if (writeCount === 1) {
  339. // Save image with unfinished modification changes.
  340. assert.equal(args.args[1].txt, imageContainerHTML);
  341. } else if (writeCount === 2) {
  342. // Save the modified image.
  343. assert.equal(args.args[1].txt, getImageContainerHTML(newImageSrc, false));
  344. } else {
  345. // Fail the test if too many write are called.
  346. assert.ok(writeCount === 2, "Write should only be called 3 times during this test");
  347. }
  348. writeCount += 1;
  349. } else if (
  350. route === `/web_editor/modify_image/${imageRecord.id}`
  351. ) {
  352. if (modifyImageCount === 0) {
  353. assert.equal(args.res_model, 'partner');
  354. assert.equal(args.res_id, 1);
  355. await modifyImagePromise;
  356. return newImageSrc;
  357. } else {
  358. // Fail the test if too many modify_image are called.
  359. assert.ok(modifyImageCount === 0, "The image should only have been modified once during this test");
  360. }
  361. modifyImageCount += 1;
  362. }
  363. };
  364. // Add the ajax service (legacy), because wysiwyg RPCs use it.
  365. patchWithCleanup(legacyEnv, {
  366. services: {
  367. ...legacyEnv.services,
  368. ajax: {
  369. rpc: mockRPC,
  370. },
  371. }
  372. });
  373. await makeView({
  374. type: "form",
  375. resId: 1,
  376. resModel: "partner",
  377. serverData,
  378. arch: `
  379. <form>
  380. <field name="txt" widget="html"/>
  381. </form>`,
  382. mockRPC: mockRPC,
  383. });
  384. // Let the htmlField be mounted and recover the Component instance.
  385. const htmlField = await htmlFieldPromise;
  386. const editor = htmlField.wysiwyg.odooEditor;
  387. // Simulate an urgent save without any image in the content.
  388. await formController.beforeUnload();
  389. await nextTick();
  390. // Replace the empty paragraph with a paragrah containing an unsaved
  391. // modified image
  392. const imageContainerElement = parseHTML(imageContainerHTML).firstChild;
  393. let paragraph = editor.editable.querySelector(".test_target");
  394. editor.editable.replaceChild(imageContainerElement, paragraph);
  395. editor.historyStep();
  396. // Simulate an urgent save before the end of the RPC roundtrip for the
  397. // image.
  398. await formController.beforeUnload();
  399. await nextTick();
  400. // Resolve the image modification (simulate end of RPC roundtrip).
  401. modifyImagePromise.resolve();
  402. await modifyImagePromise;
  403. await nextTick();
  404. // Simulate the last urgent save, with the modified image.
  405. await formController.beforeUnload();
  406. await nextTick();
  407. });
  408. });