mirror of
https://github.com/bringout/oca-ocb-web.git
synced 2026-04-19 04:12:01 +02:00
replace stale web_editor with html_editor and html_builder for 19.0
web_editor was removed in Odoo 19.0 and replaced by html_editor
and html_builder. The old web_editor was incorrectly included in
the 19.0 vanilla import.
🤖 assisted by claude
This commit is contained in:
parent
4b94f0abc5
commit
f866779561
1513 changed files with 396049 additions and 358525 deletions
|
|
@ -0,0 +1,544 @@
|
|||
import { Builder } from "@html_builder/builder";
|
||||
import { CORE_PLUGINS } from "@html_builder/core/core_plugins";
|
||||
import { Img } from "@html_builder/core/img";
|
||||
import { SetupEditorPlugin } from "@html_builder/core/setup_editor_plugin";
|
||||
import { unformat } from "@html_editor/../tests/_helpers/format";
|
||||
import { setContent } from "@html_editor/../tests/_helpers/selection";
|
||||
import { insertText } from "@html_editor/../tests/_helpers/user_actions";
|
||||
import { LocalOverlayContainer } from "@html_editor/local_overlay_container";
|
||||
import { Plugin } from "@html_editor/plugin";
|
||||
import { withSequence } from "@html_editor/utils/resource";
|
||||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { after } from "@odoo/hoot";
|
||||
import { animationFrame, waitForNone, queryOne, waitFor, advanceTime, tick } from "@odoo/hoot-dom";
|
||||
import { Component, onMounted, useRef, useState, useSubEnv, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { isBrowserFirefox } from "@web/core/browser/feature_detection";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uniqueId } from "@web/core/utils/functions";
|
||||
|
||||
export function patchWithCleanupImg() {
|
||||
const defaultImg =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z9DwHwAGBQKA3H7sNwAAAABJRU5ErkJggg==";
|
||||
patchWithCleanup(Img, {
|
||||
template: xml`<img t-att-data-src="props.src" t-att-alt="props.alt" t-att-class="props.class" t-att-style="props.style" t-att="props.attrs" src="${defaultImg}"/>`,
|
||||
});
|
||||
patchWithCleanup(Img.prototype, {
|
||||
loadImage: () => {},
|
||||
getSvg: function () {
|
||||
this.isSvg = () => false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getSnippetView(snippets) {
|
||||
const { snippet_groups, snippet_custom, snippet_structure, snippet_content } = snippets;
|
||||
return `
|
||||
<snippets id="snippet_groups" string="Categories">
|
||||
${(snippet_groups || []).join("")}
|
||||
</snippets>
|
||||
<snippets id="snippet_structure" string="Structure">
|
||||
${(snippet_structure || []).join("")}
|
||||
</snippets>
|
||||
<snippets id="snippet_custom" string="Custom">
|
||||
${(snippet_custom || []).join("")}
|
||||
</snippets>
|
||||
<snippets id="snippet_content" string="Inner Content">
|
||||
${(snippet_content || []).join("")}
|
||||
</snippets>`;
|
||||
}
|
||||
|
||||
export function getInnerContent({
|
||||
name,
|
||||
content,
|
||||
keywords = [],
|
||||
imagePreview = "",
|
||||
thumbnail = "",
|
||||
}) {
|
||||
keywords = keywords.join(", ");
|
||||
return `<div name="${name}" data-oe-type="snippet" data-oe-snippet-id="456" data-o-image-preview="${imagePreview}" data-oe-thumbnail="${thumbnail}" data-oe-keywords="${keywords}">${content}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates snippet structure HTML for test fixtures
|
||||
* @param {Object} options - Snippet structure configuration
|
||||
* @param {string} options.name - The display name of the snippet
|
||||
* @param {string} options.content - The HTML content of the snippet
|
||||
* @param {string[]} [options.keywords=[]] - Search keywords for the snippet
|
||||
* @param {string} options.groupName - The snippet group (category) name
|
||||
* @param {string} [options.imagePreview=""] - URL to preview image
|
||||
* @param {string|number} [options.moduleId=""] - Module ID if snippet belongs to a module
|
||||
* @param {string} [options.moduleDisplayName=""] - Human-readable module name
|
||||
* @returns {string} HTML string for the snippet structure
|
||||
*/
|
||||
export function getSnippetStructure({
|
||||
name,
|
||||
content,
|
||||
keywords = [],
|
||||
groupName,
|
||||
imagePreview = "",
|
||||
moduleId = "",
|
||||
moduleDisplayName = "",
|
||||
}) {
|
||||
keywords = keywords.join(", ");
|
||||
return `<div name="${name}" data-oe-snippet-id="123" data-o-image-preview="${imagePreview}" data-oe-keywords="${keywords}" data-o-group="${groupName}" data-module-id="${moduleId}" data-module-display-name="${moduleDisplayName}">${content}</div>`;
|
||||
}
|
||||
|
||||
class BuilderContainer extends Component {
|
||||
static template = xml`
|
||||
<div class="d-flex h-100 w-100" t-ref="container">
|
||||
<div class="o_website_preview flex-grow-1" t-ref="website_preview">
|
||||
<div class="o_iframe_container">
|
||||
<iframe class="h-100 w-100" t-ref="iframe" t-on-load="onLoad"/>
|
||||
<div t-if="this.state.isMobile" class="o_mobile_preview_layout">
|
||||
<img alt="phone" src="/html_builder/static/img/phone.svg"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LocalOverlayContainer localOverlay="overlayRef" identifier="env.localOverlayContainerKey"/>
|
||||
<div t-if="state.isEditing" t-att-class="{'o_builder_sidebar_open': state.isEditing and state.showSidebar}" class="o-website-builder_sidebar border-start border-dark">
|
||||
<Builder t-props="this.getBuilderProps()"/>
|
||||
</div>
|
||||
</div>`;
|
||||
static components = { Builder, LocalOverlayContainer };
|
||||
static props = {
|
||||
content: String,
|
||||
editableSelector: String,
|
||||
headerContent: String,
|
||||
Plugins: Array,
|
||||
onEditorLoad: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({ isMobile: false, isEditing: false, showSidebar: true });
|
||||
this.iframeRef = useRef("iframe");
|
||||
const originalIframeLoaded = new Promise((resolve) => {
|
||||
this._originalIframeLoadedResolve = resolve;
|
||||
});
|
||||
this.iframeLoaded = new Promise((resolve) => {
|
||||
onMounted(async () => {
|
||||
if (isBrowserFirefox()) {
|
||||
await originalIframeLoaded;
|
||||
}
|
||||
|
||||
const el = this.iframeRef.el;
|
||||
el.contentDocument.body.innerHTML = `<div id="wrapwrap">${this.props.headerContent}<div id="wrap" class="oe_structure oe_empty" data-oe-model="ir.ui.view" data-oe-id="539" data-oe-field="arch">${this.props.content}</div></div>`;
|
||||
resolve(el);
|
||||
});
|
||||
});
|
||||
useSubEnv({
|
||||
builderRef: useRef("container"),
|
||||
});
|
||||
}
|
||||
|
||||
onLoad() {
|
||||
this._originalIframeLoadedResolve();
|
||||
}
|
||||
|
||||
getBuilderProps() {
|
||||
return {
|
||||
onEditorLoad: this.props.onEditorLoad,
|
||||
closeEditor: () => {},
|
||||
snippetsName: "",
|
||||
toggleMobile: () => {
|
||||
this.state.isMobile = !this.state.isMobile;
|
||||
},
|
||||
overlayRef: () => {},
|
||||
editableSelector: this.props.editableSelector,
|
||||
iframeLoaded: this.iframeLoaded,
|
||||
isMobile: this.state.isMobile,
|
||||
Plugins: this.props.Plugins,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class IrUiView extends models.Model {
|
||||
_name = "ir.ui.view";
|
||||
render_public_asset() {
|
||||
throw new Error("This should be implemented by some helper");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef { import("@html_editor/editor").Editor } Editor
|
||||
*
|
||||
* @param {String} content
|
||||
* @param {Object} options
|
||||
* @param {String} options.headerContent
|
||||
* @param {*} options.snippetContent
|
||||
* @param {*} options.dropzoneSelectors
|
||||
* @param {*} options.snippets
|
||||
* @param {*} options.styleContent
|
||||
* @returns {Promise<{
|
||||
* getEditor: () => Editor,
|
||||
* getEditableContent: () => HTMLElement,
|
||||
* contentEl: HTMLElement,
|
||||
* builderEl: HTMLElement,
|
||||
* waitDomUpdated: () => Promise<void>
|
||||
* }>}
|
||||
}}
|
||||
*/
|
||||
export async function setupHTMLBuilder(
|
||||
content = "",
|
||||
{
|
||||
editableSelector = "#wrapwrap",
|
||||
headerContent = "",
|
||||
snippetContent,
|
||||
dropzoneSelectors,
|
||||
snippets,
|
||||
styleContent,
|
||||
} = {}
|
||||
) {
|
||||
defineMailModels();
|
||||
defineModels([IrUiView]);
|
||||
|
||||
patchWithCleanupImg();
|
||||
|
||||
if (!snippets) {
|
||||
snippets = {
|
||||
snippet_groups: [
|
||||
'<div name="A" data-oe-thumbnail="a.svg" data-oe-snippet-id="123" data-o-snippet-group="a"><section data-snippet="s_snippet_group"></section></div>',
|
||||
],
|
||||
snippet_structure: [
|
||||
getSnippetStructure({
|
||||
name: "Test",
|
||||
groupName: "a",
|
||||
content: `<section class="s_test" data-snippet="s_test" data-name="Test">
|
||||
<div class="test_a"></div>
|
||||
</section>`,
|
||||
}),
|
||||
],
|
||||
// TODO: maybe we should use the same structure as in the snippets?
|
||||
snippet_content: snippetContent || [
|
||||
`<section class="s_test" data-snippet="s_test" data-name="Test">
|
||||
<div class="test_a"></div>
|
||||
</section>`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
patchWithCleanup(IrUiView.prototype, {
|
||||
render_public_asset: () => getSnippetView(snippets),
|
||||
});
|
||||
|
||||
const Plugins = [...CORE_PLUGINS];
|
||||
|
||||
if (dropzoneSelectors) {
|
||||
const pluginId = uniqueId("test-dropzone-selector");
|
||||
|
||||
class P extends Plugin {
|
||||
static id = pluginId;
|
||||
resources = {
|
||||
dropzone_selector: dropzoneSelectors,
|
||||
};
|
||||
}
|
||||
Plugins.push(P);
|
||||
}
|
||||
|
||||
const BuilderPlugins = registry.category("builder-plugins").getAll();
|
||||
Plugins.push(...BuilderPlugins);
|
||||
|
||||
let lastUpdatePromise;
|
||||
const waitDomUpdated = async () => {
|
||||
// The tick ensures that lastUpdatePromise has correctly been assigned
|
||||
await tick();
|
||||
await lastUpdatePromise;
|
||||
await animationFrame();
|
||||
};
|
||||
patchWithCleanup(Builder.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
patchWithCleanup(this.env.editorBus, {
|
||||
trigger(eventName, detail) {
|
||||
if (eventName === "DOM_UPDATED") {
|
||||
lastUpdatePromise = detail.updatePromise;
|
||||
}
|
||||
return super.trigger(eventName, detail);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let _resolve;
|
||||
const prom = new Promise((resolve) => {
|
||||
_resolve = resolve;
|
||||
});
|
||||
|
||||
let editableContent;
|
||||
// hack to get a promise that resolves when editor is ready
|
||||
patchWithCleanup(SetupEditorPlugin.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
_resolve();
|
||||
editableContent = this.getEditableElements(
|
||||
'.oe_structure.oe_empty, [data-oe-type="html"]'
|
||||
)[0];
|
||||
},
|
||||
});
|
||||
|
||||
let attachedEditor;
|
||||
const comp = await mountWithCleanup(BuilderContainer, {
|
||||
props: {
|
||||
content,
|
||||
editableSelector,
|
||||
headerContent,
|
||||
Plugins,
|
||||
onEditorLoad: (editor) => {
|
||||
attachedEditor = editor;
|
||||
},
|
||||
},
|
||||
});
|
||||
await comp.iframeLoaded;
|
||||
if (styleContent) {
|
||||
const iframeDocument = queryOne(":iframe");
|
||||
const styleEl = iframeDocument.createElement("style");
|
||||
styleEl.textContent = styleContent;
|
||||
iframeDocument.head.appendChild(styleEl);
|
||||
}
|
||||
comp.state.isEditing = true;
|
||||
await prom;
|
||||
await animationFrame();
|
||||
return {
|
||||
getEditor: () => attachedEditor,
|
||||
getEditableContent: () => editableContent,
|
||||
contentEl: comp.iframeRef.el.contentDocument.body.firstChild.firstChild,
|
||||
builderEl: comp.env.builderRef.el.querySelector(".o-website-builder_sidebar"),
|
||||
waitDomUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
export function addBuilderPlugin(Plugin) {
|
||||
registry.category("builder-plugins").add(Plugin.id, Plugin);
|
||||
after(() => {
|
||||
registry.category("builder-plugins").remove(Plugin.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function addBuilderOption({
|
||||
selector,
|
||||
exclude,
|
||||
applyTo,
|
||||
template,
|
||||
Component,
|
||||
sequence,
|
||||
cleanForSave,
|
||||
props,
|
||||
editableOnly,
|
||||
title,
|
||||
reloadTarget,
|
||||
}) {
|
||||
const pluginId = uniqueId("test-option");
|
||||
const option = {
|
||||
pluginId,
|
||||
OptionComponent: Component,
|
||||
template,
|
||||
selector,
|
||||
exclude,
|
||||
applyTo,
|
||||
sequence,
|
||||
cleanForSave,
|
||||
props,
|
||||
editableOnly,
|
||||
title,
|
||||
reloadTarget,
|
||||
};
|
||||
|
||||
const P = {
|
||||
[pluginId]: class extends Plugin {
|
||||
static id = pluginId;
|
||||
resources = {
|
||||
builder_options: sequence ? withSequence(sequence, option) : option,
|
||||
};
|
||||
},
|
||||
}[pluginId];
|
||||
|
||||
addBuilderPlugin(P);
|
||||
}
|
||||
|
||||
export function addBuilderAction(actions = {}) {
|
||||
const pluginId = uniqueId("test-action-plugin");
|
||||
class P extends Plugin {
|
||||
static id = pluginId;
|
||||
resources = {
|
||||
builder_actions: actions,
|
||||
};
|
||||
}
|
||||
addBuilderPlugin(P);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dragged helper when drag and dropping snippets.
|
||||
*/
|
||||
export function getDragHelper() {
|
||||
return document.body.querySelector(".o_draggable_dragging .o_snippet_thumbnail");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dragged helper when drag and dropping elements from the page.
|
||||
*/
|
||||
export function getDragMoveHelper() {
|
||||
return document.body.querySelector(".o_drag_move_helper");
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the loading element added by the mutex to be removed, indicating
|
||||
* that the operation is over.
|
||||
*/
|
||||
export async function waitForEndOfOperation() {
|
||||
await advanceTime(500);
|
||||
await waitForNone(":iframe .o_loading_screen");
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
export function addDropZoneSelector(selector) {
|
||||
const pluginId = uniqueId("test-dropzone-selector");
|
||||
|
||||
class P extends Plugin {
|
||||
static id = pluginId;
|
||||
resources = {
|
||||
dropzone_selector: [selector],
|
||||
};
|
||||
}
|
||||
|
||||
registry.category("builder-plugins").add(pluginId, P);
|
||||
after(() => {
|
||||
registry.category("builder-plugins").remove(P);
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForSnippetDialog() {
|
||||
await animationFrame();
|
||||
await loadBundle("web.assets_frontend", {
|
||||
targetDoc: queryOne("iframe.o_add_snippet_iframe").contentDocument,
|
||||
js: false,
|
||||
});
|
||||
await loadBundle("html_builder.iframe_add_dialog", {
|
||||
targetDoc: queryOne("iframe.o_add_snippet_iframe").contentDocument,
|
||||
js: false,
|
||||
});
|
||||
await waitFor(".o_add_snippet_dialog iframe.show.o_add_snippet_iframe");
|
||||
}
|
||||
|
||||
export async function modifyText(editor, editableContent) {
|
||||
setContent(editableContent, '<h1 class="title">H[]ello</h1>');
|
||||
editor.shared.history.addStep();
|
||||
await insertText(editor, "1");
|
||||
}
|
||||
|
||||
// Snippet Testing Helpers
|
||||
// Use createTestSnippets() for most cases to replace repetitive getSnippetsDescription functions
|
||||
// Use getBasicSection() for simple HTML section generation
|
||||
|
||||
/**
|
||||
* Creates a basic HTML section structure for test snippets
|
||||
* @param {string} content - The content to place inside the section
|
||||
* @param {Object} [options={}] - Configuration options
|
||||
* @param {string} [options.name] - Name attribute for the section (data-name)
|
||||
* @param {string} [options.snippet="s_test"] - Snippet class and data-snippet value
|
||||
* @param {string} [options.additionalClassOnRoot=""] - Additional CSS classes for the root element
|
||||
* @returns {string} Formatted HTML section element
|
||||
*/
|
||||
export function getBasicSection(
|
||||
content,
|
||||
{ name, snippet = "s_test", additionalClassOnRoot = "" } = {}
|
||||
) {
|
||||
let classes = snippet;
|
||||
if (additionalClassOnRoot) {
|
||||
classes += ` ${additionalClassOnRoot}`;
|
||||
}
|
||||
return unformat(
|
||||
`<section class="${classes}" data-snippet="${snippet}" ${
|
||||
name ? `data-name="${name}"` : ""
|
||||
}><div class="test_a o-paragraph">${content}</div></section>`
|
||||
);
|
||||
}
|
||||
|
||||
export function createTestSnippets({ snippets: snippetConfigs = [], withName = false }) {
|
||||
return snippetConfigs.map((snippetConfig) => {
|
||||
const {
|
||||
name,
|
||||
groupName = "a",
|
||||
content,
|
||||
innerHTML,
|
||||
keywords = [],
|
||||
imagePreview = "",
|
||||
moduleId,
|
||||
moduleDisplayName,
|
||||
additionalClassOnRoot,
|
||||
snippet: snippetId,
|
||||
} = snippetConfig;
|
||||
|
||||
const finalContent =
|
||||
content ||
|
||||
getBasicSection(innerHTML || name, {
|
||||
name: withName ? name : "",
|
||||
snippet: snippetId || "s_test",
|
||||
additionalClassOnRoot,
|
||||
});
|
||||
|
||||
const snippet = {
|
||||
name,
|
||||
groupName,
|
||||
content: finalContent,
|
||||
keywords,
|
||||
imagePreview,
|
||||
moduleId,
|
||||
moduleDisplayName,
|
||||
};
|
||||
|
||||
return snippet;
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmAddSnippet(snippetName) {
|
||||
let previewSelector = `.o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap`;
|
||||
if (snippetName) {
|
||||
previewSelector += ":has([data-snippet='" + snippetName + "'])";
|
||||
}
|
||||
await waitForSnippetDialog();
|
||||
await contains(previewSelector).click();
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
export const dummyBase64Img =
|
||||
"data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA\n AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO\n 9TXL0Y4OHwAAAABJRU5ErkJggg==";
|
||||
|
||||
export const exampleContent = '<h1 class="title">Hello</h1>';
|
||||
|
||||
export const wrapExample = `<div id="wrap" data-oe-model="ir.ui.view" data-oe-id="539" data-oe-field="arch">${exampleContent}</div>`;
|
||||
|
||||
export async function setupHTMLBuilderWithDummySnippet(content) {
|
||||
const snippetEl = `<section class="s_test" data-snippet="s_test" data-name="Test">
|
||||
<div class="test_a"></div>
|
||||
</section>`;
|
||||
|
||||
const snippetsDescription = createTestSnippets({
|
||||
snippets: [
|
||||
{
|
||||
name: "Test",
|
||||
groupName: "a",
|
||||
content: snippetEl,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snippetsStructure = {
|
||||
snippets: {
|
||||
snippet_groups: [
|
||||
'<div name="A" data-oe-thumbnail="a.svg" data-oe-snippet-id="123" data-o-snippet-group="a"><section data-snippet="s_snippet_group"></section></div>',
|
||||
],
|
||||
snippet_structure: snippetsDescription.map((snippetDesc) =>
|
||||
getSnippetStructure(snippetDesc)
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return await setupHTMLBuilder(content || "", snippetsStructure);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue