/** @odoo-module **/ import { click, editInput, getFixture, makeDeferred, mockSendBeacon, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; import { FileSelectorControlPanel } from "@web_editor/components/media_dialog/file_selector"; import { FormController } from '@web/views/form/form_controller'; import { HtmlField } from "@web_editor/js/backend/html_field"; import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; import { parseHTML, setSelection } from "@web_editor/js/editor/odoo-editor/src/utils/utils"; import { onRendered, useEffect } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { COLOR_PICKER_TEMPLATE, wysiwygData } from "@web_editor/../tests/test_utils"; import { OdooEditor } from '@web_editor/js/editor/odoo-editor/src/OdooEditor'; import { uploadService } from "@web_editor/components/upload_progress_toast/upload_service"; import { Wysiwyg } from "@web_editor/js/wysiwyg/wysiwyg"; import { insertText } from '@web_editor/js/editor/odoo-editor/test/utils'; 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; beforeEach(() => { serverData = { models: { partner: { fields: { txt: { string: "txt", type: "html", trim: true }, }, records: [], }, }, }; target = getFixture(); setupViewRegistries(); }); QUnit.module("Form view interactions with the HtmlField"); QUnit.test("A new MediaDialog after switching record in a Form view should have the correct resId", async (assert) => { serverData.models.partner.records = [ {id: 1, txt: "

first

"}, {id: 2, txt: "

second

"}, ]; let wysiwyg, mediaDialog; const wysiwygPromise = makeDeferred(); const mediaDialogPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); wysiwyg = this.wysiwyg; wysiwygPromise.resolve(); } }); patchWithCleanup(MediaDialog.prototype, { setup() { mediaDialog = this; mediaDialogPromise.resolve(); this.size = 'xl'; this.contentClass = 'o_select_media_dialog'; this.title = "TEST"; this.tabs = []; this.state = {}; // no call to super to avoid services dependencies // this test only cares about the props given to the dialog } }); await makeView({ type: "form", resId: 1, resIds: [1, 2], resModel: "partner", serverData, arch: `
`, }); await wysiwygPromise; assert.containsOnce(target, ".odoo-editor-editable p:contains(first)"); // click on the pager to switch to the next record await click(target.querySelector(".o_pager_next")); assert.containsOnce(target, ".odoo-editor-editable p:contains(second)"); const paragraph = target.querySelector(".odoo-editor-editable p"); setSelection(paragraph, 0, paragraph, 0); wysiwyg.openMediaDialog(); await mediaDialogPromise; assert.equal(mediaDialog.props.resId, 2); }); QUnit.test("QWeb plugin select t-field", async (assert) => { serverData.models.partner.fields.m2o = { type: "many2one", relation: "partner" }; serverData.models.partner.records = [{ id: 1, txt: `demo` }]; await makeView({ type: "form", resId: 1, resIds: [1, 2], resModel: "partner", serverData, arch: `
`, }); // Manually add a t-field in the form // to be able to test that we don't trigger the same behavior // as if we were inside the html field const form = target.querySelector(".o_form_view"); const span = document.createElement("span"); span.setAttribute("t-field", "bogus"); span.innerHTML = `bogus`; form.appendChild(span); const sel = document.getSelection(); sel.removeAllRanges(); let range = new Range(); range.setStart(target.querySelector(".insideoftfield"), 0); sel.addRange(range); await nextTick(); assert.strictEqual(sel.anchorNode, target.querySelector(".o_field_html p")); sel.removeAllRanges(); range = new Range(); range.setStart(target.querySelector(".insidebogus"), 0); sel.addRange(range); await nextTick(); assert.strictEqual(sel.anchorNode, target.querySelector(".insidebogus")); // Dispatch an invalid event to make sure nothing crashes // It is hard to test this, but the issue was, in firefox, the target // was used in the handler, and was the input which did not have some function // In chrome, it worked fine, as selectionchange's target was always the document const customEvent = new CustomEvent("selectionchange", { bubbles: true }); Object.defineProperties(customEvent, { target: { value: null, }, currentTarget: { value: null, }, }) target.querySelector(".o_field_many2one input").dispatchEvent(customEvent); }) QUnit.test("discard html field changes in form", async (assert) => { serverData.models.partner.records = [{ id: 1, txt: "

first

" }]; let wysiwyg; const wysiwygPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); wysiwyg = this.wysiwyg; wysiwygPromise.resolve(); }, }); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); await wysiwygPromise; const editor = wysiwyg.odooEditor; const editable = editor.editable; editor.testMode = true; assert.strictEqual(editable.innerHTML, `

first

`); const paragraph = editable.querySelector("p"); await setSelection(paragraph, 0); await insertText(editor, "a"); assert.strictEqual(editable.innerHTML, `

afirst

`); // For blur event here to call _onWysiwygBlur function in html_field await editable.dispatchEvent(new Event("blur", { bubbles: true, cancelable: true })); // Wait for the updates to be saved , if we don't wait the update of the value will // be done after the call for discardChanges since it uses some async functions. await new Promise((r) => setTimeout(r, 100)); const discardButton = target.querySelector(".o_form_button_cancel"); assert.ok(discardButton); await click(discardButton); assert.strictEqual(editable.innerHTML, `

first

`); }); QUnit.module('Sandboxed Preview'); QUnit.test("complex html is automatically in sandboxed preview mode", 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 allow-popups allow-popups-to-escape-sandbox"]'); }); QUnit.test("readonly sandboxed preview", async (assert) => { serverData.models.partner.records = [{ id: 1, txt: ` Hello `, }]; await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); const readonlyIframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]'); 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; const togglePromises = [makeDeferred(), makeDeferred()]; let togglePromiseId = 0; const writePromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { setup() { super.setup(...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 === "web_save" && 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 allow-popups allow-popups-to-escape-sandbox"]'); 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 allow-popups allow-popups-to-escape-sandbox"]'); 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 allow-popups allow-popups-to-escape-sandbox"]'); }); QUnit.module('Readonly mode'); QUnit.test("Links should open on a new tab", async (assert) => { assert.expect(6); serverData.models.partner.records = [{ id: 1, txt: ` Relative link Internal link External link `, }]; await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); for (const link of target.querySelectorAll('a')) { assert.strictEqual(link.getAttribute('target'), '_blank'); assert.strictEqual(link.getAttribute('rel'), 'noreferrer'); } }); QUnit.module('Save scenarios'); QUnit.test("Ensure that urgentSave works even with modified image to save", async (assert) => { assert.expect(5); let sendBeaconDef; mockSendBeacon((route, blob) => { blob.text().then((r) => { const { params } = JSON.parse(r); const { args, model } = params; if (route === '/web/dataset/call_kw/partner/web_save' && model === 'partner') { if (writeCount === 0) { // Save normal value without image. assert.equal(args[1].txt, `


`); } else if (writeCount === 1) { // Save image with unfinished modification changes. assert.equal(args[1].txt, imageContainerHTML); } else if (writeCount === 2) { // Save the modified image. assert.equal(args[1].txt, getImageContainerHTML(newImageSrc, false)); } else { // Fail the test if too many write are called. assert.ok(writeCount === 2, "Write should only be called 3 times during this test"); } writeCount += 1; } sendBeaconDef.resolve(); }); return true; }); let formController; // Patch to get the controller instance. patchWithCleanup(FormController.prototype, { setup() { super.setup(...arguments); formController = this; } }); // Patch to get a promise to get the htmlField component instance when // the wysiwyg is instancied. const htmlFieldPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); await nextTick(); htmlFieldPromise.resolve(this); } }); // Add a partner record and ir.attachments model and record. serverData.models.partner.records.push({ id: 1, txt: "


", }); serverData.models["ir.attachment"] = wysiwygData({})["ir.attachment"]; const imageRecord = serverData.models["ir.attachment"].records[0]; // Method to get the html of a cropped image. // Use `data-src` instead of `src` when the SRC is an URL that would // make a call to the server. const getImageContainerHTML = (src, isModified) => { return `


`.replace(/(?:\s|(?:\r\n))+/g, ' ') .replace(/\s?(<|>)\s?/g, '$1'); }; // Promise to resolve when we want the response of the modify_image RPC. const modifyImagePromise = makeDeferred(); let writeCount = 0; let modifyImageCount = 0; // Valid base64 encoded image in its transitory modified state. const imageContainerHTML = getImageContainerHTML( "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII", true ); // New src URL to assign to the image when the modification is // "registered". const newImageSrc = "/web/image/1234/cropped_transparent.png"; const mockRPC = async function (route, args) { if ( route === '/web/dataset/call_kw/partner/web_save' && args.model === 'partner' ) { assert.ok(false, "write should only be called through sendBeacon"); } else if ( route === `/web_editor/modify_image/${imageRecord.id}` ) { if (modifyImageCount === 0) { assert.equal(args.res_model, 'partner'); assert.equal(args.res_id, 1); await modifyImagePromise; return newImageSrc; } else { // Fail the test if too many modify_image are called. assert.ok(modifyImageCount === 0, "The image should only have been modified once during this test"); } modifyImageCount += 1; } }; await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, mockRPC: mockRPC, }); // Let the htmlField be mounted and recover the Component instance. const htmlField = await htmlFieldPromise; const editor = htmlField.wysiwyg.odooEditor; // Simulate an urgent save without any image in the content. sendBeaconDef = makeDeferred(); await formController.beforeUnload(); await sendBeaconDef; // Replace the empty paragraph with a paragrah containing an unsaved // modified image const imageContainerElement = parseHTML(editor.document, imageContainerHTML).firstChild; let paragraph = editor.editable.querySelector(".test_target"); editor.editable.replaceChild(imageContainerElement, paragraph); editor.historyStep(); // Simulate an urgent save before the end of the RPC roundtrip for the // image. sendBeaconDef = makeDeferred(); await formController.beforeUnload(); await sendBeaconDef; // Resolve the image modification (simulate end of RPC roundtrip). modifyImagePromise.resolve(); await modifyImagePromise; await nextTick(); // Simulate the last urgent save, with the modified image. sendBeaconDef = makeDeferred(); await formController.beforeUnload(); await sendBeaconDef; }); QUnit.test("Pasted/dropped images are converted to attachments on save", async (assert) => { assert.expect(6); // Patch to get a promise to get the htmlField component instance when // the wysiwyg is instancied. const htmlFieldPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); await nextTick(); htmlFieldPromise.resolve(this); } }); // Add a partner record serverData.models.partner.records.push({ id: 1, txt: "


", }); const mockRPC = async function (route, args) { if (route === '/web_editor/attachment/add_data') { // Check that the correct record model and id were sent. assert.equal(args.res_model, 'partner'); assert.equal(args.res_id, 1); return { image_src: '/test_image_url.png', access_token: '1234', public: false, } } }; const pasteImage = async (editor) => { // Create image file. const base64ImageData = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII" const binaryImageData = atob(base64ImageData); const uint8Array = new Uint8Array(binaryImageData.length); for (let i = 0; i < binaryImageData.length; i++) { uint8Array[i] = binaryImageData.charCodeAt(i); } const file = new File([uint8Array], "test_image.png", { type: 'image/png' }); // Create a promise to get the created img elements const pasteImagePromise = makeDeferred(); const observer = new MutationObserver(mutations => { mutations .filter(mutation => mutation.type === 'childList') .forEach(mutation => { mutation.addedNodes.forEach(node => { if (node instanceof HTMLElement) { pasteImagePromise.resolve(node); } }); }); }); observer.observe(editor.editable, { subtree: true, childList: true }); // Simulate paste. editor._onPaste({ preventDefault() {}, clipboardData: { getData() {}, items: [{ kind: 'file', type: 'image/png', getAsFile: () => file, }], }, }); const img = await pasteImagePromise; observer.disconnect(); return img; } await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, mockRPC: mockRPC, }); // Let the htmlField be mounted and recover the Component instance. const htmlField = await htmlFieldPromise; const editor = htmlField.wysiwyg.odooEditor; const paragraph = editor.editable.querySelector(".test_target"); Wysiwyg.setRange(paragraph); // Paste image. const img = await pasteImage(editor); // Test environment replaces 'src' by 'data-src'. assert.ok(/^data:image\/png;base64,/.test(img.dataset['src'])); assert.ok(img.classList.contains('o_b64_image_to_save')); // Save changes. // Restore 'src' attribute so that SavePendingImages can do its job. img.src = img.dataset['src']; await htmlField.commitChanges(); assert.equal(img.dataset['src'], '/test_image_url.png?access_token=1234'); assert.ok(!img.classList.contains('o_b64_image_to_save')); }); QUnit.module('Odoo fields synchronisation'); QUnit.test("Synchronise fields when editing.", async (assert) => { serverData.models.partner.records = [{ id: 1, txt: `

a

a

a

`, }]; // Patch to get a promise to get the htmlField component instance when // the wysiwyg is instancied. const htmlFieldPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); await nextTick(); htmlFieldPromise.resolve(this); } }); let historyStepPromise; const newHistoryStepPromise = () => { historyStepPromise = makeDeferred(); }; patchWithCleanup(OdooEditor.prototype, { historyStep() { super.historyStep(...arguments); if (historyStepPromise) { historyStepPromise.resolve(); } } }); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); // Let the htmlField be mounted and recover the Component instance. const htmlField = await htmlFieldPromise; const editor = htmlField.wysiwyg.odooEditor; const node = editor.editable.querySelector('p').childNodes[0]; newHistoryStepPromise(); node.textContent = 'b'; await historyStepPromise; assert.equal(editor.editable.children[0].children[0].innerHTML.trim(), '

b

'); assert.equal(editor.editable.children[0].children[1].innerHTML.trim(), '

b

'); assert.equal(editor.editable.children[0].children[2].innerHTML.trim(), '

b

'); }); QUnit.test("Synchronise fields when editing containing unremovable.", async (assert) => { serverData.models.partner.records = [{ id: 1, txt: `

a

a

a

`, }]; // Patch to get a promise to get the htmlField component instance when // the wysiwyg is instancied. const htmlFieldPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); await nextTick(); htmlFieldPromise.resolve(this); } }); let historyStepPromise; const newHistoryStepPromise = () => { historyStepPromise = makeDeferred(); }; patchWithCleanup(OdooEditor.prototype, { historyStep() { super.historyStep(...arguments); if (historyStepPromise) { historyStepPromise.resolve(); } } }); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); // Let the htmlField be mounted and recover the Component instance. const htmlField = await htmlFieldPromise; const editor = htmlField.wysiwyg.odooEditor; const node = editor.editable.querySelector('p').childNodes[0]; newHistoryStepPromise(); node.textContent = 'b'; await historyStepPromise; assert.equal(editor.editable.children[0].children[0].innerHTML.trim(), '

b

'); assert.equal(editor.editable.children[0].children[1].innerHTML.trim(), '

b

'); assert.equal(editor.editable.children[0].children[2].innerHTML.trim(), '

b

'); }); QUnit.test("Synchronise fields removing an unremovable within a t-field should rollback", async (assert) => { serverData.models.partner.records = [{ id: 1, txt: `

a

a

a

`, }]; // Patch to get a promise to get the htmlField component instance when // the wysiwyg is instancied. const htmlFieldPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); await nextTick(); htmlFieldPromise.resolve(this); } }); let historyStepPromise; const newHistoryStepPromise = () => { historyStepPromise = makeDeferred(); }; patchWithCleanup(OdooEditor.prototype, { historyStep() { super.historyStep(...arguments); if (historyStepPromise) { historyStepPromise.resolve(); } } }); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); // Let the htmlField be mounted and recover the Component instance. const htmlField = await htmlFieldPromise; const editor = htmlField.wysiwyg.odooEditor; const node = editor.editable.querySelector('div.oe_unremovable'); newHistoryStepPromise(); node.textContent = 'b'; await historyStepPromise; assert.equal(editor.editable.children[0].children[0].innerHTML.trim(), '

a

'); assert.equal(editor.editable.children[0].children[1].innerHTML.trim(), '

a

'); assert.equal(editor.editable.children[0].children[2].innerHTML.trim(), '

a

'); }); QUnit.module('Paste'); QUnit.test("Embed video by pasting video URL", async (assert) => { assert.expect(4); serverData.models.partner.records.push({ id: 1, txt: "


", }); const mockRPC = async function (route, args) { if (route === '/web_editor/video_url/data') { return Promise.resolve({ platform: "youtube", embed_url: "//www.youtube.com/embed/qxb74CMR748?rel=0&autoplay=0", }); } }; await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, mockRPC: mockRPC, }); const editable = document.querySelector(".odoo-editor-editable"); const p = editable.firstElementChild; Wysiwyg.setRange(p); // Paste a video URL. const clipboardData = new DataTransfer(); clipboardData.setData('text/plain', 'https://www.youtube.com/watch?v=qxb74CMR748'); p.dispatchEvent(new ClipboardEvent('paste', { clipboardData, bubbles: true })); assert.strictEqual(p.outerHTML, '

https://www.youtube.com/watch?v=qxb74CMR748

', "The URL should be inserted as text"); assert.isVisible($('.oe-powerbox-wrapper:contains("Embed Youtube Video")'), "The powerbox should be opened"); // Press Enter to select first option in the powerbox ("Embed Youtube Video"). document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); await nextTick(); assert.strictEqual(p.outerHTML, '

', "URL insertion should be reverted"); assert.containsOnce( editable, 'div.media_iframe_video iframe[data-src="//www.youtube.com/embed/qxb74CMR748?rel=0&autoplay=0"]', "The video should be embedded as an iframe" ); }); QUnit.module("Link"); QUnit.test("link preview in Link Dialog", async (assert) => { assert.expect(6); serverData.models.partner.records.push({ id: 1, txt: "

This website

", }); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); // Test the popover option to edit the link const a = document.querySelector(".test_target a"); // Wait for the popover to appear await nextTick(); a.click(); await nextTick(); // Click on the edit link icon document.querySelector("a.mx-1.o_we_edit_link.text-dark").click(); // Make sure popover is closed await new Promise(resolve => $(a).on('hidden.bs.popover.link_popover', resolve)); let labelInputField = document.querySelector(".modal input#o_link_dialog_label_input"); let linkPreview = document.querySelector(".modal a#link-preview"); assert.strictEqual(labelInputField.value, 'This website', "The label input field should match the link's content"); assert.strictEqual(linkPreview.innerText.replaceAll("\u200B", ""), "This website", "Link label in preview should match label input field"); // Click on discard await click(document, ".modal .modal-footer button.btn-secondary"); const p = document.querySelector(".test_target"); // Select link label to open the floating toolbar. setSelection(p, 0, p, 1); await nextTick(); // Click on create-link button to open the Link Dialog. document.querySelector("#toolbar #create-link").click(); await nextTick(); labelInputField = document.querySelector(".modal input#o_link_dialog_label_input"); linkPreview = document.querySelector(".modal a#link-preview"); assert.strictEqual(labelInputField.value, 'This website', "The label input field should match the link's content"); assert.strictEqual(linkPreview.innerText, 'This website', "Link label in preview should match label input field"); // Edit link label. await editInput(labelInputField, null, "New label"); assert.strictEqual(linkPreview.innerText, "New label", "Preview should be updated on label input field change"); // Click "Save". await click(document, ".modal .modal-footer button.btn-primary"); assert.strictEqual(p.innerText.replaceAll('\u200B', ''), 'New label', "The link's label should be updated"); }); QUnit.module("isDirty"); QUnit.test("isDirty should be false when the content is being transformed by the wysiwyg", async (assert) => { assert.expect(2); serverData.models.partner.records.push({ id: 1, txt: "

abc

", }); let htmlField; const wysiwygPromise = makeDeferred(); patchWithCleanup(HtmlField.prototype, { async startWysiwyg() { await super.startWysiwyg(...arguments); htmlField = this; wysiwygPromise.resolve(); } }); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, }); await wysiwygPromise; assert.strictEqual(htmlField.wysiwyg.getValue(), '

abc

', 'the value should be sanitized by the wysiwyg'); assert.strictEqual(htmlField._isDirty(), false, 'should not be dirty as the content has not changed'); }); }); export const mediaDialogServices = { upload: uploadService, }; export const uploadTestModule = QUnit.module( "WebEditor.HtmlField.upload", { beforeEach: function () { this.data = wysiwygData({ "mail.compose.message": { fields: { display_name: { string: "Displayed name", type: "char", }, body: { string: "Message Body inline (to send)", type: "html", }, attachment_ids: { string: "Attachments", type: "many2many", relation: "ir.attachment", }, }, records: [ { id: 1, display_name: "Some Composer", body: "Hello", attachment_ids: [], }, ], }, }); }, }, function () { QUnit.test("media dialog: upload", async function (assert) { assert.expect(7); const onAttachmentChangeTriggered = makeDeferred(); patchWithCleanup(HtmlField.prototype, { _onAttachmentChange(event) { super._onAttachmentChange(event); onAttachmentChangeTriggered.resolve(true); }, }); const defFileSelector = makeDeferred(); const onChangeTriggered = makeDeferred(); const webSaveTriggered = makeDeferred(); patchWithCleanup(FileSelectorControlPanel.prototype, { setup() { super.setup(); useEffect( () => { defFileSelector.resolve(true); }, () => [], ); }, async onChangeFileInput() { super.onChangeFileInput(); onChangeTriggered.resolve(true); }, }); patchWithCleanup(Wysiwyg.prototype, { async _getColorpickerTemplate() { return COLOR_PICKER_TEMPLATE; }, }); // create and load form view const serviceRegistry = registry.category("services"); for (const [serviceName, serviceDefinition] of Object.entries(mediaDialogServices)) { serviceRegistry.add(serviceName, serviceDefinition); } const serverData = { models: this.data, }; serverData.actions = { 1: { id: 1, name: "test", res_model: "mail.compose.message", type: "ir.actions.act_window", views: [[false, "form"]], }, }; serverData.views = { "mail.compose.message,false,search": "", "mail.compose.message,false,form": `
`, }; const mockRPC = (route, args) => { if (args.method === "web_save") { const createVals = args.args[1]; assert.ok(createVals && createVals.attachment_ids); assert.equal(createVals.attachment_ids[0][0], 4); // link command assert.equal(createVals.attachment_ids[0][1], 5); // on attachment id "5" webSaveTriggered.resolve(); } if (route === "/web_editor/attachment/add_data") { const attachment = { id: 5, name: "test.jpg", description: false, mimetype: "image/jpeg", checksum: "7951a43bbfb08fd742224ada280913d1897b89ab", url: false, type: "binary", res_id: 0, res_model: "mail.compose.message", public: false, access_token: false, image_src: "/web/image/1-a0e63e61/test.jpg", image_width: 1, image_height: 1, original_id: false, }; serverData.models["ir.attachment"].records.push({ ...attachment }); return Promise.resolve(attachment); } else if (route === "/web/dataset/call_kw/ir.attachment/generate_access_token") { return Promise.resolve(["129a52e1-6bf2-470a-830e-8e368b022e13"]); } }; const webClient = await createWebClient({ serverData, mockRPC }); await doAction(webClient, 1); //trigger wysiwyg mediadialog const fixture = getFixture(); const formField = fixture.querySelector('.o_field_html[name="body"]'); const textInput = formField.querySelector(".note-editable p"); textInput.innerText = "test"; const pText = $(textInput).contents()[0]; Wysiwyg.setRange(pText, 1, pText, 2); await new Promise((resolve) => setTimeout(resolve)); //ensure fully set up const wysiwyg = $(textInput.parentElement).data("wysiwyg"); wysiwyg.openMediaDialog(); assert.ok( await Promise.race([ defFileSelector, new Promise((res, _) => setTimeout(() => res(false), 400)), ]), "File Selector did not mount", ); // upload test const fileInputs = document.querySelectorAll( ".o_select_media_dialog input.d-none.o_file_input", ); const fileB64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q=="; const fileBytes = new Uint8Array( atob(fileB64) .split("") .map((char) => char.charCodeAt(0)), ); // redefine 'files' so we can put mock data in through js fileInputs.forEach((input) => Object.defineProperty(input, "files", { value: [new File(fileBytes, "test.jpg", { type: "image/jpeg" })], }), ); fileInputs.forEach((input) => { input.dispatchEvent(new Event("change", {})); }); assert.ok( await Promise.race([ onChangeTriggered, new Promise((res, _) => setTimeout(() => res(false), 400)), ]), "File change event was not triggered", ); assert.ok( await Promise.race([ onAttachmentChangeTriggered, new Promise((res, _) => setTimeout(() => res(false), 400)), ]), "_onAttachmentChange was not called with the new attachment, necessary for unsused upload cleanup on backend" ); // wait to check that dom is properly updated await new Promise((res, _) => setTimeout(() => res(false), 400)); assert.ok(fixture.querySelector('.o_attachment[title="test.jpg"]')); await click(fixture.querySelector(".o_form_button_save")); await webSaveTriggered; }); }, );