+ `);
+
+ await contains(":iframe .span-1").click();
+ expect("button.btn.dropdown").toHaveCount(1);
+ await contains("button.btn.dropdown").click();
+ await contains("span.o-dropdown-item.dropdown-item").hover();
+ expect(":iframe span.span-1 > span").toHaveText("The Name of 1");
+ expect(":iframe span.span-2 > span").toHaveText("The Address of 1");
+ expect(":iframe span.span-3 > span").toHaveText("The Address of 3"); // author of other post is not changed
+ expect(":iframe span.span-4").toHaveText("Hermit");
+
+ await press("esc"); // This causes the dropdown to close, and thus the preview to be reverted
+ await animationFrame();
+ expect(":iframe span.span-1 > span").toHaveText("The Name of 3");
+ expect(":iframe span.span-2 > span").toHaveText("The Address of 3");
+ expect(":iframe span.span-4").toHaveText("Other");
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/monetary_field.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/monetary_field.test.js
new file mode 100644
index 0000000..25c2476
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/monetary_field.test.js
@@ -0,0 +1,30 @@
+import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
+import { expect, test, describe } from "@odoo/hoot";
+import { click, queryOne } from "@odoo/hoot-dom";
+
+describe.current.tags("desktop");
+
+test("should not allow edition of currency sign of monetary fields", async () => {
+ await setupHTMLBuilder(
+ `
+ $ 750.00
+ `
+ );
+ expect(":iframe span[data-oe-type]").toHaveProperty("isContentEditable", false);
+ expect(":iframe span.oe_currency_value").toHaveProperty("isContentEditable", true);
+});
+
+test("clicking on the monetary field should select the amount", async () => {
+ const { getEditor } = await setupHTMLBuilder(
+ `
+ $ 750.00
+ `
+ );
+ const editor = getEditor();
+ await click(":iframe span.span-in-currency");
+ expect(
+ editor.shared.selection.areNodeContentsFullySelected(
+ queryOne(":iframe span.oe_currency_value")
+ )
+ ).toBe(true, { message: "value of monetary field is selected" });
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/operation.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/operation.test.js
new file mode 100644
index 0000000..80ae7ec
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/operation.test.js
@@ -0,0 +1,286 @@
+import {
+ addBuilderOption,
+ addBuilderAction,
+ setupHTMLBuilder,
+} from "@html_builder/../tests/helpers";
+import { BuilderAction } from "@html_builder/core/builder_action";
+import { Operation } from "@html_builder/core/operation";
+import { HistoryPlugin } from "@html_editor/core/history_plugin";
+import { beforeEach, describe, expect, test } from "@odoo/hoot";
+import { advanceTime, Deferred, delay, hover, press, tick } from "@odoo/hoot-dom";
+import { xml } from "@odoo/owl";
+import { contains, patchWithCleanup } from "@web/../tests/web_test_helpers";
+
+describe.current.tags("desktop");
+
+describe("Operation", () => {
+ test("handle 3 concurrent cancellable operations (with delay)", async () => {
+ const operation = new Operation();
+ function makeCall(data) {
+ let resolve;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ async function load() {
+ expect.step(`load before ${data}`);
+ await promise;
+ expect.step(`load after ${data}`);
+ }
+ function apply() {
+ expect.step(`apply ${data}`);
+ }
+
+ operation.next(apply, { load, cancellable: true });
+ return {
+ resolve,
+ };
+ }
+ const call1 = makeCall(1);
+ await delay();
+ const call2 = makeCall(2);
+ await delay();
+ const call3 = makeCall(3);
+ await delay();
+ call1.resolve();
+ call2.resolve();
+ call3.resolve();
+ await operation.mutex.getUnlockedDef();
+ expect.verifySteps([
+ //
+ "load before 1",
+ "load after 1",
+ "load before 3",
+ "load after 3",
+ "apply 3",
+ ]);
+ });
+ test("handle 3 concurrent cancellable operations (without delay)", async () => {
+ const operation = new Operation();
+ function makeCall(data) {
+ let resolve;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ async function load() {
+ expect.step(`load before ${data}`);
+ await promise;
+ expect.step(`load after ${data}`);
+ }
+ function apply() {
+ expect.step(`apply ${data}`);
+ }
+
+ operation.next(apply, { load, cancellable: true });
+ return {
+ resolve,
+ };
+ }
+ const call1 = makeCall(1);
+ const call2 = makeCall(2);
+ const call3 = makeCall(3);
+ call1.resolve();
+ call2.resolve();
+ call3.resolve();
+ await operation.mutex.getUnlockedDef();
+ expect.verifySteps(["load before 3", "load after 3", "apply 3"]);
+ });
+});
+
+describe("Block editable", () => {
+ test("Doing an operation should block the editable during its execution", async () => {
+ const customActionDef = new Deferred();
+ addBuilderAction({
+ customAction: class extends BuilderAction {
+ static id = "customAction";
+ load() {
+ return customActionDef;
+ }
+ apply({ editingElement }) {
+ editingElement.classList.add("custom-action");
+ }
+ },
+ });
+ addBuilderOption({
+ selector: ".test-options-target",
+ template: xml`
`,
+ });
+ await setupHTMLBuilder(`
TEST
`, {
+ loadIframeBundles: true,
+ });
+
+ await contains(":iframe .test-options-target").click();
+ await contains("[data-action-id='customAction']").click();
+ expect(":iframe .o_loading_screen:not(.o_we_ui_loading)").toHaveCount(1);
+ await advanceTime(50); // cancelTime=50 trigger by the preview
+ await advanceTime(500); // setTimeout in addLoadingElement
+ expect(":iframe .o_loading_screen.o_we_ui_loading").toHaveCount(1);
+
+ customActionDef.resolve();
+ await tick();
+ expect(":iframe .o_loading_screen.o_we_ui_loading").toHaveCount(0);
+ expect(":iframe .test-options-target").toHaveClass("custom-action");
+ });
+});
+
+describe("Async operations", () => {
+ beforeEach(() => {
+ patchWithCleanup(HistoryPlugin.prototype, {
+ makePreviewableAsyncOperation(operation) {
+ const res = super.makePreviewableAsyncOperation(operation);
+ const revert = res.revert;
+ res.revert = async () => {
+ await revert();
+ expect.step("revert");
+ };
+ return res;
+ },
+ });
+ });
+
+ test("In clickable component, revert is awaited before applying the next apply", async () => {
+ const applyDelay = 1000;
+ addBuilderAction({
+ customAction: class extends BuilderAction {
+ static id = "customAction";
+ async apply({ editingElement, value }) {
+ await new Promise((resolve) => setTimeout(resolve, applyDelay));
+ editingElement.classList.add(value);
+ expect.step("apply first");
+ }
+ },
+ customAction2: class extends BuilderAction {
+ static id = "customAction2";
+ apply({ editingElement, value }) {
+ editingElement.classList.add(value);
+ expect.step("apply second");
+ }
+ },
+ });
+ addBuilderOption({
+ selector: ".test-options-target",
+ template: xml`
+
+
+ first
+ second
+
+
+ `,
+ });
+
+ await setupHTMLBuilder(`
TEST
`);
+ await contains(":iframe .test-options-target").click();
+ await contains(".options-container [data-label='Type'] .btn-secondary ").click();
+ await hover(".popover [data-action-value='first']");
+ await hover(".popover [data-action-value='second']");
+ await advanceTime(applyDelay + 50);
+ expect.verifySteps(["apply first", "revert", "apply second"]);
+ expect(":iframe .test-options-target").toHaveClass("second");
+ expect(":iframe .test-options-target").not.toHaveClass("first");
+ // Escape the select to trigger an explicit revert. Otherwise, the test
+ // sometimes fails with an unverified step.
+ await press(["Escape"]);
+ expect.verifySteps(["revert"]);
+ });
+
+ test("In ColorPicker, revert is awaited before applying the next apply", async () => {
+ const applyDelay = 1000;
+ addBuilderAction({
+ customAction: class extends BuilderAction {
+ static id = "customAction";
+ async apply({ editingElement }) {
+ let color =
+ getComputedStyle(editingElement).getPropertyValue("background-color");
+ if (color === "rgb(255, 0, 0)") {
+ color = "red";
+ await new Promise((resolve) => setTimeout(resolve, applyDelay));
+ } else {
+ color = "blue";
+ }
+ editingElement.classList.add(color);
+ expect.step(`apply ${color}`);
+ }
+ },
+ });
+ addBuilderOption({
+ selector: ".test-options-target",
+ template: xml`
+
+ `,
+ });
+
+ await setupHTMLBuilder(`
TEST
`);
+ await contains(":iframe .test-options-target").click();
+
+ await contains(".we-bg-options-container .o_we_color_preview").click();
+ await contains(".o-overlay-item [data-color='#FF0000']").hover();
+ await contains(".o-overlay-item [data-color='#0000FF']").hover();
+ await advanceTime(applyDelay + 50);
+ expect(":iframe .test-options-target").toHaveClass("blue");
+ expect(":iframe .test-options-target").not.toHaveClass("red");
+ expect.verifySteps(["apply red", "revert", "apply blue"]);
+ // Escape the colorpicker to trigger an explicit revert. Otherwise, the
+ // test sometimes fails with an unverified step.
+ await press(["Escape"]);
+ expect.verifySteps(["revert"]);
+ });
+});
+
+describe("Operation that will fail", () => {
+ test("html builder must not be blocked if a preview crashes", async () => {
+ expect.errors(1);
+ class TestAction extends BuilderAction {
+ static id = "testAction";
+ apply({ editingElement }) {
+ editingElement.classList.add("fail");
+ throw new Error("This action should crash");
+ }
+ }
+ addBuilderAction({
+ TestAction,
+ });
+ addBuilderOption({
+ selector: ".test-options-target",
+ template: xml`
+
+
`,
+ });
+ await setupHTMLBuilder(`
b
`);
+ await contains(":iframe .test-options-target").click();
+ await contains("[data-action-id='testAction']").hover();
+ await contains("[data-class-action='test']").click();
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+ expect.verifyErrors(["This action should crash"]);
+ });
+
+ test("html builder must not be blocked when a failed action is commit", async () => {
+ expect.errors(2);
+ class TestAction extends BuilderAction {
+ static id = "testAction";
+ apply({ editingElement }) {
+ editingElement.classList.add("fail");
+ throw new Error("This action should crash");
+ }
+ }
+ addBuilderAction({
+ TestAction,
+ });
+ addBuilderOption({
+ selector: ".test-options-target",
+ template: xml`
+
+
`,
+ });
+ await setupHTMLBuilder(`
b
`);
+ await contains(":iframe .test-options-target").click();
+ await contains("[data-action-id='testAction']").click();
+ await contains("[data-class-action='test']").click();
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+ // preview + commit
+ expect.verifyErrors(["This action should crash", "This action should crash"]);
+ });
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/option_sequence.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/option_sequence.test.js
new file mode 100644
index 0000000..41dc662
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/option_sequence.test.js
@@ -0,0 +1,44 @@
+import {
+ after,
+ before,
+ BEGIN,
+ END,
+ SNIPPET_SPECIFIC,
+ splitBetween,
+} from "@html_builder/utils/option_sequence";
+import { expect, test } from "@odoo/hoot";
+
+const ARBITRARY_FAKE_POSITION = 7777777777;
+
+test("before throws if position doesn't exist", async () => {
+ expect(() => before(ARBITRARY_FAKE_POSITION)).toThrow();
+});
+
+test("before throws if position is BEGIN", async () => {
+ expect(() => before(BEGIN)).toThrow();
+});
+
+test("before returns a smaller position", async () => {
+ expect(before(SNIPPET_SPECIFIC)).toBeLessThan(SNIPPET_SPECIFIC);
+ expect(before(END)).toBeLessThan(END);
+});
+
+test("after throws if position doesn't exist", async () => {
+ expect(() => after(ARBITRARY_FAKE_POSITION)).toThrow();
+});
+
+test("after throws if position is END", async () => {
+ expect(() => after(END)).toThrow();
+});
+
+test("after returns a bigger position", async () => {
+ expect(after(SNIPPET_SPECIFIC)).toBeGreaterThan(SNIPPET_SPECIFIC);
+ expect(after(BEGIN)).toBeGreaterThan(BEGIN);
+});
+
+test("splitBetween correctly splits to the right values", async () => {
+ expect(splitBetween(0, 3, 2)).toMatch([1, 2]);
+ expect(splitBetween(0, 10, 2)).toMatch([10 / 3, (2 * 10) / 3]);
+ expect(splitBetween(0, 8, 7)).toMatch([1, 2, 3, 4, 5, 6, 7]);
+ expect(splitBetween(1, 5, 3)).toMatch([2, 3, 4]);
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/rating_option.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/rating_option.test.js
new file mode 100644
index 0000000..0798650
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/rating_option.test.js
@@ -0,0 +1,83 @@
+import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
+import { expect, test, describe } from "@odoo/hoot";
+import { animationFrame, clear, click, fill, waitFor } from "@odoo/hoot-dom";
+import { contains } from "@web/../tests/web_test_helpers";
+
+describe.current.tags("desktop");
+
+const websiteContent = `
+
+
Quality
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+test("change rating score", async () => {
+ await setupHTMLBuilder(websiteContent);
+ expect(":iframe .s_rating .s_rating_active_icons i").toHaveCount(3);
+ expect(":iframe .s_rating .s_rating_inactive_icons i").toHaveCount(2);
+ await contains(":iframe .s_rating").click();
+ await contains(".options-container [data-action-id='activeIconsNumber'] input").click();
+ await clear();
+ await fill("1");
+ await animationFrame();
+ expect(":iframe .s_rating .s_rating_active_icons i").toHaveCount(1);
+ await contains(".options-container [data-action-id='totalIconsNumber'] input").click();
+ await clear();
+ await fill("4");
+ await animationFrame();
+ expect(":iframe .s_rating .s_rating_inactive_icons i").toHaveCount(3);
+ expect(":iframe .s_rating").toHaveInnerHTML(
+ `
Quality
+
+
+
+ ​
+
+
+
+
+ ​
+
+
+ ​
+
+
+ ​
+
+
+
`
+ );
+});
+test("Ensure order of operations when clicking very fast on two options", async () => {
+ await setupHTMLBuilder(websiteContent);
+ await contains(":iframe .s_rating").click();
+ await waitFor("[data-label='Icon']");
+ expect("[data-label='Icon'] .dropdown-toggle").toHaveText("Stars");
+ expect(":iframe .s_rating").not.toHaveAttribute("data-active-custom-icon");
+ await click(".options-container [data-action-id='customIcon']");
+ await click(".options-container [data-class-action='fa-2x']");
+ await animationFrame();
+ expect(":iframe .s_rating_icons").not.toHaveClass("fa-2x");
+ await contains(".modal-dialog .fa-glass").click();
+ expect(":iframe .s_rating").toHaveAttribute("data-active-custom-icon", "fa fa-glass");
+ expect("[data-label='Icon'] .dropdown-toggle").toHaveText("Custom");
+ expect(":iframe .s_rating_icons").toHaveClass("fa-2x");
+ await contains(".o-snippets-top-actions .fa-undo").click();
+ expect("[data-label='Icon'] .dropdown-toggle").toHaveText("Custom");
+ expect(":iframe .s_rating").toHaveAttribute("data-active-custom-icon", "fa fa-glass");
+ expect(":iframe .s_rating_icons").not.toHaveClass("fa-2x");
+ await contains(".o-snippets-top-actions .fa-undo").click();
+ expect("[data-label='Icon'] .dropdown-toggle").toHaveText("Stars");
+ expect(":iframe .s_rating").not.toHaveAttribute("data-active-custom-icon");
+ expect(":iframe .s_rating_icons").not.toHaveClass("fa-2x");
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/separator_options.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/separator_options.test.js
new file mode 100644
index 0000000..ff98851
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/options/separator_options.test.js
@@ -0,0 +1,18 @@
+import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
+import { expect, test, describe } from "@odoo/hoot";
+import { contains } from "@web/../tests/web_test_helpers";
+
+describe.current.tags("desktop");
+
+test("change width of separator", async () => {
+ await setupHTMLBuilder(`
+
+
+
+ `);
+ await contains(":iframe .s_hr").click();
+ await contains("div:contains('Width') button:contains('100%')").click();
+ expect("[data-class-action='mx-auto']").toHaveCount(0);
+ await contains(".o_popover [data-class-action='w-50']").click();
+ expect("[data-class-action='mx-auto']").toHaveCount(1);
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/section_contenteditable.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/section_contenteditable.test.js
new file mode 100644
index 0000000..1e87871
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/section_contenteditable.test.js
@@ -0,0 +1,18 @@
+import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
+import { expect, test, describe } from "@odoo/hoot";
+
+describe.current.tags("desktop");
+
+test("section with containers should not be contenteditable, but there containers should, unless outside o_editable", async () => {
+ await setupHTMLBuilder(
+ `
`,
+ {
+ headerContent: `
`,
+ }
+ );
+ expect(":iframe section:has(.inside)").toHaveProperty("isContentEditable", false);
+ expect(":iframe .inside").toHaveProperty("isContentEditable", true);
+
+ expect(":iframe section:has(.outside)").toHaveProperty("isContentEditable", false);
+ expect(":iframe .outside").toHaveProperty("isContentEditable", false);
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/shadow_option.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/shadow_option.test.js
new file mode 100644
index 0000000..1a0cc6e
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/shadow_option.test.js
@@ -0,0 +1,75 @@
+import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
+import { expect, test, describe } from "@odoo/hoot";
+import { queryAllTexts, queryAllValues, waitFor } from "@odoo/hoot-dom";
+import { xml } from "@odoo/owl";
+import { contains } from "@web/../tests/web_test_helpers";
+
+describe.current.tags("desktop");
+
+test("edit box-shadow with ShadowOption", async () => {
+ addBuilderOption({
+ selector: ".test-options-target",
+ template: xml`
`,
+ });
+ await setupHTMLBuilder(`
b
`);
+ await contains(":iframe .test-options-target").click();
+ await waitFor(".hb-row");
+ expect(queryAllTexts(".hb-row .hb-row-label")).toEqual(["Shadow"]);
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+
+ await contains('.options-container button[title="Outset"]').click();
+ expect(queryAllTexts(".hb-row .hb-row-label")).toEqual([
+ "Shadow",
+ "Color",
+ "Offset (X, Y)",
+ "Blur",
+ "Spread",
+ ]);
+ expect(queryAllValues('[data-action-id="setShadow"] input')).toEqual(["0", "8", "16", "0"]);
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+
+ await contains('[data-action-param="offsetX"] input').fill(10);
+ await contains('[data-action-param="offsetY"] input').fill(2);
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+
+ await contains('[data-action-param="blur"] input').clear();
+ await contains('[data-action-param="blur"] input').fill(10.5);
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+
+ await contains('[data-action-param="spread"] input').fill(".4");
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+
+ await contains('.options-container button[title="Inset"]').click();
+ expect(queryAllTexts(".hb-row .hb-row-label")).toEqual([
+ "Shadow",
+ "Color",
+ "Offset (X, Y)",
+ "Blur",
+ "Spread",
+ ]);
+ expect(queryAllValues('[data-action-id="setShadow"] input')).toEqual([
+ "10",
+ "82",
+ "10.5",
+ "0.4",
+ ]);
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+
+ await contains(".options-container button:contains(None)").click();
+ expect(queryAllTexts(".hb-row .hb-row-label")).toEqual(["Shadow"]);
+ expect(":iframe .test-options-target").toHaveOuterHTML(
+ '
b
'
+ );
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/utils.test.js b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/utils.test.js
new file mode 100644
index 0000000..fa3024b
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/static/tests/utils.test.js
@@ -0,0 +1,55 @@
+import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
+import { BaseOptionComponent, useDomState } from "@html_builder/core/utils";
+import { describe, expect, test } from "@odoo/hoot";
+import { animationFrame } from "@odoo/hoot-dom";
+import { xml } from "@odoo/owl";
+import { contains } from "@web/../tests/web_test_helpers";
+
+describe.current.tags("desktop");
+
+describe("useDomState", () => {
+ test("Should not update the state of an async useDomState if a new step has been made", async () => {
+ let currentResolve;
+ addBuilderOption({
+ selector: ".test-options-target",
+ Component: class extends BaseOptionComponent {
+ static template = xml`
`;
+ setup() {
+ super.setup(...arguments);
+ this.state = useDomState(async () => {
+ const letter = await new Promise((resolve) => {
+ currentResolve = resolve;
+ });
+ return {
+ delay: `${letter}`,
+ };
+ });
+ }
+ getLetter() {
+ expect.step(`state: ${this.state.delay}`);
+ return this.state.delay;
+ }
+ },
+ });
+ const { getEditor } = await setupHTMLBuilder(`
a
`);
+ await animationFrame();
+ await contains(":iframe .test-options-target").click();
+ const editor = getEditor();
+ const resolve1 = currentResolve;
+ resolve1("x");
+ await animationFrame();
+
+ editor.editable.querySelector(".test-options-target").textContent = "b";
+ editor.shared.history.addStep();
+ const resolve2 = currentResolve;
+ editor.editable.querySelector(".test-options-target").textContent = "c";
+ editor.shared.history.addStep();
+ const resolve3 = currentResolve;
+
+ resolve3("z");
+ await animationFrame();
+ resolve2("y");
+ await animationFrame();
+ expect.verifySteps(["state: x", "state: z"]);
+ });
+});
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/tests/__init__.py b/odoo-bringout-oca-ocb-html_builder/html_builder/tests/__init__.py
new file mode 100644
index 0000000..171a01d
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_html_builder_assets_bundle
diff --git a/odoo-bringout-oca-ocb-html_builder/html_builder/tests/test_html_builder_assets_bundle.py b/odoo-bringout-oca-ocb-html_builder/html_builder/tests/test_html_builder_assets_bundle.py
new file mode 100644
index 0000000..7792507
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_builder/html_builder/tests/test_html_builder_assets_bundle.py
@@ -0,0 +1,19 @@
+
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo.tests
+from odoo.tests.common import HttpCase
+
+
+@odoo.tests.tagged('-at_install', 'post_install')
+class TestHtmlBuilderAssetsBundle(HttpCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.bundle = cls.env["ir.qweb"]._get_asset_bundle("html_builder.assets", True)
+
+ def test_html_builder_assets_bundle_no_edit_scss(self):
+ for file in self.bundle.files:
+ filename = file["filename"]
+ self.assertFalse(filename.endswith("edit.scss"), msg="html_builder.assets must not contain *.edit.scss files. Remove " + filename)
diff --git a/odoo-bringout-oca-ocb-html_editor/html_editor/__init__.py b/odoo-bringout-oca-ocb-html_editor/html_editor/__init__.py
new file mode 100644
index 0000000..f7209b1
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_editor/html_editor/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import controllers
diff --git a/odoo-bringout-oca-ocb-html_editor/html_editor/__manifest__.py b/odoo-bringout-oca-ocb-html_editor/html_editor/__manifest__.py
new file mode 100644
index 0000000..ef84c6d
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_editor/html_editor/__manifest__.py
@@ -0,0 +1,117 @@
+{
+ 'name': "HTML Editor",
+ 'summary': """
+ A Html Editor component and plugin system
+ """,
+ 'description': """
+Html Editor
+==========================
+This addon provides an extensible, maintainable editor.
+ """,
+
+ 'author': "odoo",
+ 'website': "https://www.odoo.com",
+ 'version': '1.0',
+ 'category': 'Hidden',
+ 'depends': ['base', 'bus', 'web'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ ],
+ 'auto_install': True,
+ 'assets': {
+ 'web._assets_primary_variables': [
+ ('after', 'web/static/src/scss/primary_variables.scss', 'html_editor/static/src/scss/html_editor.variables.scss'),
+ ],
+ 'web.assets_frontend': [
+ ('include', 'html_editor.assets_media_dialog'),
+ ('include', 'html_editor.assets_readonly'),
+ 'html_editor/static/src/public/**/*',
+ 'html_editor/static/src/scss/html_editor.common.scss',
+ 'html_editor/static/src/scss/html_editor.frontend.scss',
+ 'html_editor/static/src/scss/base_style.scss',
+ ],
+ 'web.assets_backend': [
+ ('include', 'html_editor.assets_editor'),
+ 'html_editor/static/src/others/dynamic_placeholder_plugin.js',
+ 'html_editor/static/src/backend/**/*',
+ 'html_editor/static/src/fields/**/*',
+ 'html_editor/static/lib/vkbeautify/**/*',
+ 'html_editor/static/src/scss/base_style.scss',
+ 'html_editor/static/src/scss/html_editor.common.scss',
+ 'html_editor/static/src/scss/html_editor.backend.scss',
+ ],
+ 'html_editor.assets_editor': [
+ 'web/static/lib/dompurify/DOMpurify.js',
+ ('include', 'html_editor.assets_media_dialog'),
+ ('include', 'html_editor.assets_readonly'),
+ 'html_editor/static/src/*',
+ 'html_editor/static/src/components/history_dialog/**/*',
+ 'html_editor/static/src/core/**/*',
+ 'html_editor/static/src/main/**/*',
+ 'html_editor/static/src/others/collaboration/**/*',
+ 'html_editor/static/src/others/embedded_components/**/*',
+ 'html_editor/static/src/others/embedded_component*',
+ 'html_editor/static/src/others/qweb_picker*',
+ 'html_editor/static/src/others/qweb_plugin*',
+ 'html_editor/static/src/services/**/*',
+ ('remove', 'html_editor/static/src/**/*.dark.scss'),
+ ],
+ 'html_editor.assets_history_diff': [
+ 'html_editor/static/lib/diff2html/diff2html.min.css',
+ 'html_editor/static/lib/diff2html/diff2html.min.js',
+ ],
+ 'html_editor.assets_media_dialog': [
+ # Bundle to use the media dialog in the backend and the frontend
+ 'html_editor/static/src/components/switch/**/*',
+ 'html_editor/static/src/main/media/media_dialog/**/*',
+ ],
+ 'html_editor.assets_readonly': [
+ 'html_editor/static/src/components/html_viewer/**/*',
+ 'html_editor/static/src/local_overlay_container.*',
+ 'html_editor/static/src/main/local_overlay.scss',
+ 'html_editor/static/src/position_hook.*',
+ 'html_editor/static/src/html_migrations/**/*',
+ 'html_editor/static/src/main/list/list.scss',
+ 'html_editor/static/src/main/media/file.scss',
+ 'html_editor/static/src/others/embedded_component_utils.js',
+ 'html_editor/static/src/others/embedded_components/core/**/*',
+ 'html_editor/static/src/utils/**/*',
+ ],
+ "web.assets_web_dark": [
+ 'html_editor/static/src/**/*.dark.scss',
+ ],
+ 'web.assets_tests': [
+ 'html_editor/static/tests/tours/**/*',
+ ],
+ 'web.assets_unit_tests': [
+ 'html_editor/static/tests/**/*',
+ ],
+ 'web.assets_unit_tests_setup': [
+ 'html_editor/static/src/public/**/*',
+ ],
+ 'html_editor.assets_image_cropper': [
+ 'html_editor/static/lib/cropperjs/cropper.css',
+ 'html_editor/static/lib/cropperjs/cropper.js',
+ 'html_editor/static/lib/webgl-image-filter/webgl-image-filter.js',
+ ],
+ 'web.report_assets_common': [
+ 'html_editor/static/src/scss/base_style.scss',
+ 'html_editor/static/src/scss/bootstrap_overridden.scss',
+ 'html_editor/static/src/scss/html_editor.common.scss',
+ ],
+ 'web._assets_secondary_variables': [
+ 'html_editor/static/src/scss/secondary_variables.scss',
+ ],
+ 'web._assets_backend_helpers': [
+ 'html_editor/static/src/scss/bootstrap_overridden_backend.scss',
+ 'html_editor/static/src/scss/bootstrap_overridden.scss',
+ ],
+ 'web._assets_frontend_helpers': [
+ ('prepend', 'html_editor/static/src/scss/bootstrap_overridden.scss'),
+ ],
+ 'html_editor.assets_prism': [
+ 'web/static/lib/prismjs/prism.js',
+ ],
+ },
+ 'license': 'LGPL-3'
+}
diff --git a/odoo-bringout-oca-ocb-web_editor/web_editor/controllers/__init__.py b/odoo-bringout-oca-ocb-html_editor/html_editor/controllers/__init__.py
similarity index 79%
rename from odoo-bringout-oca-ocb-web_editor/web_editor/controllers/__init__.py
rename to odoo-bringout-oca-ocb-html_editor/html_editor/controllers/__init__.py
index 5d4b25d..80ee4da 100644
--- a/odoo-bringout-oca-ocb-web_editor/web_editor/controllers/__init__.py
+++ b/odoo-bringout-oca-ocb-html_editor/html_editor/controllers/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
diff --git a/odoo-bringout-oca-ocb-html_editor/html_editor/controllers/main.py b/odoo-bringout-oca-ocb-html_editor/html_editor/controllers/main.py
new file mode 100644
index 0000000..1156509
--- /dev/null
+++ b/odoo-bringout-oca-ocb-html_editor/html_editor/controllers/main.py
@@ -0,0 +1,735 @@
+import contextlib
+import re
+import uuid
+from base64 import b64decode, b64encode
+from datetime import datetime
+import werkzeug.exceptions
+import werkzeug.urls
+import requests
+from os.path import join as opj
+from urllib.parse import urlparse
+
+from odoo import _, http, tools, SUPERUSER_ID
+from odoo.addons.html_editor.tools import get_video_url_data
+from odoo.exceptions import UserError, MissingError, AccessError
+from odoo.http import request
+from odoo.tools.image import image_process, image_data_uri, binary_to_image, get_webp_size
+from odoo.tools.mimetypes import guess_mimetype
+from odoo.tools.misc import file_open
+from odoo.addons.iap.tools import iap_tools
+from odoo.addons.mail.tools import link_preview
+from lxml import html, etree
+
+from ..models.ir_attachment import SUPPORTED_IMAGE_MIMETYPES
+
+DEFAULT_LIBRARY_ENDPOINT = 'https://media-api.odoo.com'
+DEFAULT_OLG_ENDPOINT = 'https://olg.api.odoo.com'
+
+# Regex definitions to apply speed modification in SVG files
+# Note : These regex patterns are duplicated on the server side for
+# background images that are part of a CSS rule "background-image: ...". The
+# client-side regex patterns are used for images that are part of an
+# "src" attribute with a base64 encoded svg in the
![]()
tag. Perhaps we should
+# consider finding a solution to define them only once? The issue is that the
+# regex patterns in Python are slightly different from those in JavaScript.
+
+CSS_ANIMATION_RULE_REGEX = (
+ r"(?P
animation(-duration)?: .*?)"
+ + r"(?P(\d+(\.\d+)?)|(\.\d+))"
+ + r"(?Pms|s)"
+ + r"(?P\s|;|\"|$)"
+)
+SVG_DUR_TIMECOUNT_VAL_REGEX = (
+ r"(?P\sdur=\"\s*)"
+ + r"(?P(\d+(\.\d+)?)|(\.\d+))"
+ + r"(?Ph|min|ms|s)?\s*\""
+)
+CSS_ANIMATION_RATIO_REGEX = (
+ r"(--animation_ratio: (?P\d*(\.\d+)?));"
+)
+
+
+def _get_shape_svg(self, module, *segments):
+ shape_path = opj(module, 'static', *segments)
+ try:
+ with file_open(shape_path, 'r', filter_ext=('.svg',)) as file:
+ return file.read()
+ except FileNotFoundError:
+ raise werkzeug.exceptions.NotFound()
+
+
+def get_existing_attachment(IrAttachment, vals):
+ """
+ Check if an attachment already exists for the same vals. Return it if
+ so, None otherwise.
+ """
+ fields = dict(vals)
+ # Falsy res_id defaults to 0 on attachment creation.
+ fields['res_id'] = fields.get('res_id') or 0
+ raw, datas = fields.pop('raw', None), fields.pop('datas', None)
+ domain = [(field, '=', value) for field, value in fields.items()]
+ if fields.get('type') == 'url':
+ if 'url' not in fields:
+ return None
+ domain.append(('checksum', '=', False))
+ else:
+ if not (raw or datas):
+ return None
+ domain.append(('checksum', '=', IrAttachment._compute_checksum(raw or b64decode(datas))))
+ return IrAttachment.search(domain, limit=1) or None
+
+
+class HTML_Editor(http.Controller):
+
+ def _get_shape_svg(self, module, *segments):
+ shape_path = opj(module, 'static', *segments)
+ try:
+ with file_open(shape_path, 'r', filter_ext=('.svg',)) as file:
+ return file.read()
+ except FileNotFoundError:
+ raise werkzeug.exceptions.NotFound()
+
+ def _update_svg_colors(self, options, svg):
+ user_colors = []
+ svg_options = {}
+ default_palette = {
+ '1': '#3AADAA',
+ '2': '#7C6576',
+ '3': '#F6F6F6',
+ '4': '#FFFFFF',
+ '5': '#383E45',
+ }
+ bundle_css = None
+ regex_hex = r'#[0-9A-F]{6,8}'
+ regex_rgba = r'rgba?\(\d{1,3}, ?\d{1,3}, ?\d{1,3}(?:, ?[0-9.]{1,4})?\)'
+ for key, value in options.items():
+ colorMatch = re.match('^c([1-5])$', key)
+ if colorMatch:
+ css_color_value = value
+ # Check that color is hex or rgb(a) to prevent arbitrary injection
+ if not re.match(r'(?i)^%s$|^%s$' % (regex_hex, regex_rgba), css_color_value.replace(' ', '')):
+ if re.match('^o-color-([1-5])$', css_color_value):
+ if not bundle_css:
+ bundle = 'web.assets_frontend'
+ asset = request.env["ir.qweb"]._get_asset_bundle(bundle)
+ bundle_css = asset.css().index_content
+ color_search = re.search(r'(?i)--%s:\s+(%s|%s)' % (css_color_value, regex_hex, regex_rgba), bundle_css)
+ if not color_search:
+ raise werkzeug.exceptions.BadRequest()
+ css_color_value = color_search.group(1)
+ else:
+ raise werkzeug.exceptions.BadRequest()
+ user_colors.append([tools.html_escape(css_color_value), colorMatch.group(1)])
+ else:
+ svg_options[key] = value
+
+ color_mapping = {default_palette[palette_number]: color for color, palette_number in user_colors}
+ # create a case-insensitive regex to match all the colors to replace, eg: '(?i)(#3AADAA)|(#7C6576)'
+ regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys())
+
+ def subber(match):
+ key = match.group().upper()
+ return color_mapping[key] if key in color_mapping else key
+ return re.sub(regex, subber, svg), svg_options
+
+ def replace_animation_duration(self,
+ shape_animation_speed: float,
+ svg: str):
+ """
+ Replace animation durations in SVG and CSS with modified values.
+
+ This function takes a speed value and an SVG string containing
+ animations. It uses regular expressions to find and replace the
+ duration values in both CSS animation rules and SVG duration attributes
+ based on the provided speed.
+
+ Parameters:
+ - speed (float): The speed used to calculate the new animation
+ durations.
+ - svg (str): The SVG string containing animations.
+
+ Returns:
+ str: The modified SVG string with updated animation durations.
+ """
+ ratio = (1 + shape_animation_speed
+ if shape_animation_speed >= 0
+ else 1 / (1 - shape_animation_speed))
+
+ def callback_css_animation_rule(match):
+ # Extracting matched groups.
+ declaration, value, unit, separator = (
+ match.group("declaration"),
+ match.group("value"),
+ match.group("unit"),
+ match.group("separator"),
+ )
+ # Calculating new animation duration based on ratio.
+ value = str(float(value) / (ratio or 1))
+ # Constructing and returning the modified CSS animation rule.
+ return f"{declaration}{value}{unit}{separator}"
+
+ def callback_svg_dur_timecount_val(match):
+ attribute_name, value, unit = (
+ match.group("attribute_name"),
+ match.group("value"),
+ match.group("unit"),
+ )
+ # Calculating new duration based on ratio.
+ value = str(float(value) / (ratio or 1))
+ # Constructing and returning the modified SVG duration attribute.
+ return f'{attribute_name}{value}{unit or "s"}"'
+
+ def callback_css_animation_ratio(match):
+ ratio = match.group("ratio")
+ return f'--animation_ratio: {ratio};'
+
+ # Applying regex substitutions to modify animation speed in the
+ # 'svg' variable.
+ svg = re.sub(
+ CSS_ANIMATION_RULE_REGEX,
+ callback_css_animation_rule,
+ svg
+ )
+ svg = re.sub(
+ SVG_DUR_TIMECOUNT_VAL_REGEX,
+ callback_svg_dur_timecount_val,
+ svg
+ )
+ # Create or modify the css variable --animation_ratio for future
+ # purpose.
+ if re.match(CSS_ANIMATION_RATIO_REGEX, svg):
+ svg = re.sub(
+ CSS_ANIMATION_RATIO_REGEX,
+ callback_css_animation_ratio,
+ svg
+ )
+ else:
+ regex = r"