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:
Ernad Husremovic 2026-03-09 15:31:13 +01:00
parent 4b94f0abc5
commit f866779561
1513 changed files with 396049 additions and 358525 deletions

View file

@ -0,0 +1,201 @@
import {
setupHTMLBuilder,
getDragHelper,
waitForEndOfOperation,
} from "@html_builder/../tests/helpers";
import { BuilderOptionsPlugin } from "@html_builder/core/builder_options_plugin";
import { Operation } from "@html_builder/core/operation";
import { describe, expect, test } from "@odoo/hoot";
import {
animationFrame,
click,
Deferred,
queryAll,
queryAllTexts,
queryOne,
waitFor,
} from "@odoo/hoot-dom";
import { contains, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { loadBundle } from "@web/core/assets";
describe.current.tags("desktop");
const snippetContent = [
`<div name="Button A" data-oe-thumbnail="buttonA.svg" data-oe-snippet-id="123">
<a class="btn btn-primary" href="#" data-snippet="s_button">Button A</a>
</div>`,
`<div name="Button B" data-oe-thumbnail="buttonB.svg" data-oe-snippet-id="123">
<a class="btn btn-primary" href="#" data-snippet="s_button">Button B</a>
</div>`,
];
const dropzoneSelectors = [
{
selector: "*",
dropNear: "p",
},
];
test("Display inner content snippet", async () => {
await setupHTMLBuilder("<div><p>Text</p></div>", {
snippetContent,
dropzoneSelectors,
});
const snippetInnerContentSelector = ".o-snippets-menu #snippet_content .o_snippet";
expect(snippetInnerContentSelector).toHaveCount(2);
expect(queryAllTexts(snippetInnerContentSelector)).toEqual(["Button A", "Button B"]);
const thumbnailImgUrls = queryAll(
`${snippetInnerContentSelector} .o_snippet_thumbnail_img`
).map((thumbnail) => thumbnail.style.backgroundImage);
expect(thumbnailImgUrls).toEqual(['url("buttonA.svg")', 'url("buttonB.svg")']);
});
test("Drag & drop inner content block", async () => {
const { contentEl } = await setupHTMLBuilder("<div><p>Text</p></div>", {
snippetContent,
dropzoneSelectors,
});
expect(contentEl).toHaveInnerHTML(`<div><p>Text</p></div>`);
expect(".o-website-builder_sidebar .fa-undo").not.toBeEnabled();
const { moveTo, drop } = await contains(
".o-website-builder_sidebar [name='Button A'] .o_snippet_thumbnail"
).drag();
expect(":iframe .oe_drop_zone:nth-child(1)").toHaveCount(1);
expect(":iframe .oe_drop_zone:nth-child(3)").toHaveCount(1);
expect(".o-website-builder_sidebar .fa-undo").not.toBeEnabled();
await moveTo(":iframe .oe_drop_zone");
expect(":iframe .oe_drop_zone.invisible:nth-child(1)").toHaveCount(1);
expect(".o-website-builder_sidebar .fa-undo").not.toBeEnabled();
await drop(getDragHelper());
await waitForEndOfOperation();
expect(contentEl).toHaveInnerHTML(
`<div>\ufeff<a class="btn btn-primary" href="#" data-snippet="s_button" data-name="Button A">\ufeffButton A\ufeff</a>\ufeff<p>Text</p></div>`
);
expect(".o-website-builder_sidebar .fa-undo").toBeEnabled();
});
test("Drag & drop inner content block + undo/redo", async () => {
const { contentEl } = await setupHTMLBuilder("<div><p>Text</p></div>", {
snippetContent,
dropzoneSelectors,
});
expect(contentEl).toHaveInnerHTML(`<div><p>Text</p></div>`);
expect(".o-website-builder_sidebar .fa-undo").not.toBeEnabled();
expect(".o-website-builder_sidebar .fa-repeat").not.toBeEnabled();
await click(".o-website-builder_sidebar .fa-undo");
const { moveTo, drop } = await contains(
".o-website-builder_sidebar [name='Button A'] .o_snippet_thumbnail"
).drag();
await moveTo(":iframe .oe_drop_zone");
await drop(getDragHelper());
await waitForEndOfOperation();
expect(contentEl).toHaveInnerHTML(
`<div>\ufeff<a class="btn btn-primary" href="#" data-snippet="s_button" data-name="Button A">\ufeffButton A\ufeff</a>\ufeff<p>Text</p></div>`
);
expect(".o-website-builder_sidebar .fa-undo").toBeEnabled();
expect(".o-website-builder_sidebar .fa-repeat").not.toBeEnabled();
await click(".o-website-builder_sidebar .fa-undo");
await animationFrame();
expect(contentEl).toHaveInnerHTML(`<div><p>Text</p></div>`);
expect(".o-website-builder_sidebar .fa-undo").not.toBeEnabled();
expect(".o-website-builder_sidebar .fa-repeat").toBeEnabled();
});
test("Drag inner content and drop it outside of a dropzone", async () => {
const { contentEl, builderEl } = await setupHTMLBuilder("<div><p>Text</p></div>", {
snippetContent,
dropzoneSelectors,
});
expect(contentEl).toHaveInnerHTML(`<div><p>Text</p></div>`);
const { moveTo, drop } = await contains(
".o-website-builder_sidebar [name='Button A'] .o_snippet_thumbnail"
).drag();
expect(":iframe .oe_drop_zone:nth-child(1)").toHaveCount(1);
expect(":iframe .oe_drop_zone:nth-child(3)").toHaveCount(1);
await moveTo(builderEl);
await drop(getDragHelper());
await waitForEndOfOperation();
expect(contentEl).toHaveInnerHTML(`<div><p>Text</p></div>`);
});
test("A snippet should appear disabled if there is nowhere to drop it", async () => {
const { contentEl } = await setupHTMLBuilder("", {
snippetContent,
dropzoneSelectors,
});
expect(contentEl).toHaveInnerHTML("");
expect(".o_block_tab .o_snippet.o_disabled").toHaveCount(2);
});
test.tags("desktop");
test("click just after drop is redispatched in next operation", async () => {
const nextDef = new Deferred();
patchWithCleanup(Operation.prototype, {
next(fn, ...args) {
const originalFn = fn;
fn = async () => {
await originalFn();
nextDef.resolve();
};
expect.step(`next${args[0]?.shouldInterceptClick ? " should intercept" : ""}`);
const res = super.next(fn, ...args);
return res;
},
});
patchWithCleanup(BuilderOptionsPlugin.prototype, {
async onClick(ev) {
expect.step("onClick");
super.onClick(ev);
},
updateContainers(...args) {
expect.step("updateContainers");
super.updateContainers(...args);
},
});
await setupHTMLBuilder("", {
styleContent: /*css*/ `
.o_loading_screen {
position: absolute;
inset: 0;
}
section {
height: 100%; /* to easily target */
}`,
});
// TODO: the next lines replicate website's `insertCategorySnippet` helper.
// It should be moved to html_builder.
await contains(".o-snippets-menu #snippet_groups .o_snippet_thumbnail_area").click();
await animationFrame();
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");
await contains(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap"
).click();
await animationFrame();
expect.verifySteps(["next should intercept"]); // On snippet selected
await waitFor(":iframe .o_loading_screen");
await click(":iframe", { position: { x: 200, y: 50 }, relative: true });
expect.verifySteps(["next"]); // On click
await nextDef;
expect.verifySteps(["updateContainers"]); // End of drop, on addStep()
await animationFrame();
expect.verifySteps(["onClick", "next", "updateContainers"]); // On click redispatched
await animationFrame();
expect(".o-snippets-tabs .o-hb-tab.active").toHaveText("Style");
});

View file

@ -0,0 +1,533 @@
import {
addBuilderPlugin,
addDropZoneSelector,
createTestSnippets,
getDragHelper,
getSnippetStructure,
setupHTMLBuilder,
setupHTMLBuilderWithDummySnippet,
waitForEndOfOperation,
waitForSnippetDialog,
} from "@html_builder/../tests/helpers";
import { Builder } from "@html_builder/builder";
import { Plugin } from "@html_editor/plugin";
import { beforeEach, expect, test, describe } from "@odoo/hoot";
import { animationFrame, click, queryAll, queryAllTexts, queryFirst } from "@odoo/hoot-dom";
import { contains, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
let snippets;
beforeEach(() => {
snippets = {
snippet_groups: [
'<div name="A" data-o-image-preview="" data-oe-thumbnail="a.svg" data-oe-snippet-id="123" data-oe-keywords="" data-o-snippet-group="a"><section class="s_snippet_group" data-snippet="s_snippet_group"></section></div>',
'<div name="B" data-o-image-preview="" data-oe-thumbnail="b.svg" data-oe-snippet-id="123" data-oe-keywords="" data-o-snippet-group="b"><section class="s_snippet_group" data-snippet="s_snippet_group"></section></div>',
'<div name="C" data-o-image-preview="" data-oe-thumbnail="c.svg" data-oe-snippet-id="123" data-oe-keywords="" data-o-snippet-group="c"><section class="s_snippet_group" data-snippet="s_snippet_group"></section></div>',
],
};
addDropZoneSelector({
selector: "*",
dropNear: "section",
});
});
test("display group snippet", async () => {
await setupHTMLBuilder("<div><p>Text</p></div>", {
snippets,
});
const snippetGroupsSelector = ".o-snippets-menu #snippet_groups .o_snippet";
expect(snippetGroupsSelector).toHaveCount(3);
expect(queryAllTexts(snippetGroupsSelector)).toEqual(["A", "B", "C"]);
const thumbnailImgUrls = queryAll(`${snippetGroupsSelector} .o_snippet_thumbnail_img`).map(
(thumbnail) => thumbnail.style.backgroundImage
);
expect(thumbnailImgUrls).toEqual(['url("a.svg")', 'url("b.svg")', 'url("c.svg")']);
});
test("install an app from snippet group", async () => {
patchWithCleanup(Builder.prototype, {
setup() {
this.props.installSnippetModule = ({ moduleId }) => {
expect(moduleId).toEqual("111");
expect.step(`button_immediate_install`);
};
super.setup(...arguments);
},
});
await setupHTMLBuilder("<div><p>Text</p></div>", {
snippets: {
snippet_groups: [
'<div name="A" data-module-id="111" data-module-display-name="module_A" data-oe-thumbnail="a.svg"><section class="s_snippet_group" data-snippet="s_snippet_group"></section></div>',
],
},
});
await click(`.o-snippets-menu #snippet_groups .o_snippet .btn.o_install_btn`);
await animationFrame();
expect(".modal").toHaveCount(1);
expect(".modal-body").toHaveText(
"Do you want to install module_A App?\nMore info about this app."
);
await contains(".modal .btn-primary:contains('Save and Install')").click();
expect.verifySteps([`button_immediate_install`]);
});
test("install an app from snippet structure", async () => {
patchWithCleanup(Builder.prototype, {
setup() {
this.props.installSnippetModule = ({ moduleId }) => {
expect(moduleId).toEqual("111");
expect.step(`button_immediate_install`);
};
super.setup(...arguments);
},
});
const snippetsDescription = createTestSnippets({
snippets: [
{
name: "Test 1",
moduleDisplayName: "Test 1 module",
groupName: "a",
innerHTML: "Yop",
moduleId: 111,
},
{
name: "Test 2",
moduleDisplayName: "Test 2 module",
groupName: "a",
innerHTML: "Hello",
},
],
});
await setupHTMLBuilder("<div><p>Text</p></div>", {
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)
),
},
});
await click(".o-snippets-menu #snippet_groups .o_snippet_thumbnail .o_snippet_thumbnail_area");
await waitForSnippetDialog();
expect(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap"
).toHaveCount(2);
expect(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap .o_snippet_preview_install_btn"
).toHaveCount(1);
expect(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap:has(.o_snippet_preview_install_btn) .s_test"
).toHaveText("Yop");
await click(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap .o_snippet_preview_install_btn"
);
await animationFrame();
expect(".o_dialog:not(:has(.o_inactive_modal)) .modal-body").toHaveText(
"Do you want to install Test 1 module App?\nMore info about this app."
);
await contains(
".o_dialog:not(:has(.o_inactive_modal)) .btn-primary:contains('Save and Install')"
).click();
expect.verifySteps([`button_immediate_install`]);
});
test("open add snippet dialog + switch snippet category", async () => {
const snippets = [
{ name: "Test", groupName: "a", innerHTML: "Yop" },
{ name: "Test", groupName: "a", innerHTML: "Hello" },
{ name: "Test", groupName: "b", innerHTML: "Nice" },
];
await setupHTMLBuilder("<div><p>Text</p></div>", {
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>',
'<div name="B" data-oe-thumbnail="b.svg" data-oe-snippet-id="123" data-o-snippet-group="b"><section data-snippet="s_snippet_group"></section></div>',
],
snippet_structure: createTestSnippets({ snippets, withName: false }).map(
(snippetDesc) => getSnippetStructure(snippetDesc)
),
},
});
expect(queryAllTexts(".o-snippets-menu #snippet_groups .o_snippet")).toEqual(["A", "B"]);
await click(".o-snippets-menu #snippet_groups .o_snippet_thumbnail .o_snippet_thumbnail_area");
await waitForSnippetDialog();
expect(queryAllTexts(".o_add_snippet_dialog aside .list-group .list-group-item")).toEqual([
"A",
"B",
]);
expect(".o_add_snippet_dialog aside .list-group .list-group-item.active").toHaveText("A");
expect(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap"
).toHaveCount(2);
expect(
queryAll(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).map((el) => el.innerHTML)
).toEqual(
createTestSnippets({ snippets, withName: true })
.filter((s) => s.groupName === "a")
.map((s) => s.content)
);
await click(".o_add_snippet_dialog aside .list-group .list-group-item:contains('B')");
await animationFrame();
expect(".o_add_snippet_dialog aside .list-group .list-group-item.active").toHaveText("B");
expect(
queryAll(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).map((el) => el.innerHTML)
).toEqual(
createTestSnippets({ snippets, withName: true })
.filter((s) => s.groupName === "b")
.map((s) => s.content)
);
});
test("search snippet in add snippet dialog", async () => {
const snippets = [
{ name: "gravy", groupName: "a", innerHTML: "content 1", keywords: ["jumper"] },
{ name: "bandage", groupName: "a", innerHTML: "content 2", keywords: ["order"] },
{
name: "banana",
groupName: "b",
innerHTML: "content 3",
keywords: ["grape", "orange"],
},
];
await setupHTMLBuilder("<div><p>Text</p></div>", {
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>',
'<div name="B" data-oe-thumbnail="b.svg" data-oe-snippet-id="123" data-o-snippet-group="b"><section data-snippet="s_snippet_group"></section></div>',
],
snippet_structure: createTestSnippets({ snippets, withName: false }).map(
(snippetDesc) => getSnippetStructure(snippetDesc)
),
},
});
await click(".o-snippets-menu #snippet_groups .o_snippet_thumbnail .o_snippet_thumbnail_area");
await waitForSnippetDialog();
expect("aside .list-group .list-group-item").toHaveCount(2);
const snippetsDescriptionProcessed = createTestSnippets({ snippets, withName: true });
expect(
queryAll(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).map((el) => el.innerHTML)
).toEqual(
snippetsDescriptionProcessed.filter((s) => s.groupName === "a").map((s) => s.content)
);
// Search base on snippet name
await contains(".o_add_snippet_dialog aside input[type='search']").edit("Ban");
expect("aside .list-group .list-group-item").toHaveCount(0);
expect(
queryAll(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).map((el) => el.innerHTML)
).toEqual(
[snippetsDescriptionProcessed[1], snippetsDescriptionProcessed[2]].map((s) => s.content)
);
// Search base on snippet name and keywords
await contains(".o_add_snippet_dialog aside input[type='search']").edit("gra");
expect("aside .list-group .list-group-item").toHaveCount(0);
expect(
queryAll(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).map((el) => el.innerHTML)
).toEqual(
[snippetsDescriptionProcessed[0], snippetsDescriptionProcessed[2]].map((s) => s.content)
);
// Search base on keywords
await contains(".o_add_snippet_dialog aside input[type='search']").edit("or");
expect("aside .list-group .list-group-item").toHaveCount(0);
expect(
queryAll(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).map((el) => el.innerHTML)
).toEqual(
[snippetsDescriptionProcessed[1], snippetsDescriptionProcessed[2]].map((s) => s.content)
);
});
test("search snippet by class", async () => {
const snippets = [
{
name: "foo_bar",
groupName: "a",
innerHTML: "content 1",
snippet: "s_foo_bar",
additionalClassOnRoot: "s_additional_class",
keywords: [],
},
// The second snippet includes a child class selector using innerHTML.
// Using createTestSnippets with withName toggles the data-name automatically.
{
name: "foo",
groupName: "a",
innerHTML: `<div class="s_class_on_child">content 2</div>`,
snippet: "s_foo",
keywords: [],
},
{
name: "bar",
groupName: "a",
innerHTML: "content 3",
snippet: "s_bar",
keywords: [],
},
];
const snippetsForSetup = createTestSnippets({ snippets, withName: false });
const snippetsDescriptionProcessed = createTestSnippets({ snippets, withName: true });
await setupHTMLBuilder("<div><p>Text</p></div>", {
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: snippetsForSetup.map((snippetDesc) =>
getSnippetStructure(snippetDesc)
),
},
});
await click(".o-snippets-menu #snippet_groups .o_snippet_thumbnail .o_snippet_thumbnail_area");
await waitForSnippetDialog();
// Search among classes of root node
await contains(".o_add_snippet_dialog aside input[type='search']").edit("s_bar");
expect(
queryFirst(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).innerHTML
).toEqual(snippetsDescriptionProcessed[2].content);
await contains(".o_add_snippet_dialog aside input[type='search']").edit("s_additional_class");
expect(
queryFirst(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).innerHTML
).toEqual(snippetsDescriptionProcessed[0].content);
// Search among classes of child nodes
await contains(".o_add_snippet_dialog aside input[type='search']").edit("s_class_on_child");
expect(
queryFirst(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap > div"
).innerHTML
).toEqual(snippetsDescriptionProcessed[1].content);
});
test("add snippet dialog with imagePreview", async () => {
const snippets = [
{ name: "gravy", groupName: "a", innerHTML: "content 1" },
{ name: "banana", groupName: "a", innerHTML: "content 2", imagePreview: "banana.png" },
];
await setupHTMLBuilder("<div><p>Text</p></div>", {
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>',
'<div name="B" data-oe-thumbnail="b.svg" data-oe-snippet-id="123" data-o-snippet-group="b"><section data-snippet="s_snippet_group"></section></div>',
],
snippet_structure: createTestSnippets({ snippets, withName: false }).map(
(snippetDesc) => getSnippetStructure(snippetDesc)
),
},
});
await click(".o-snippets-menu #snippet_groups .o_snippet_thumbnail .o_snippet_thumbnail_area");
const previewSnippetIframeSelector =
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap";
await waitForSnippetDialog();
expect(`${previewSnippetIframeSelector}`).toHaveCount(2);
const snippetsDescriptionProcessed = createTestSnippets({ snippets, withName: true });
expect(`${previewSnippetIframeSelector}:first > div`).toHaveInnerHTML(
snippetsDescriptionProcessed[0].content
);
expect(
`${previewSnippetIframeSelector}:nth-child(1) .s_dialog_preview_image img`
).toHaveAttribute("data-src", snippetsDescriptionProcessed[1].imagePreview);
});
test("insert snippet structure", async () => {
const snippets = [{ name: "Test", groupName: "a", innerHTML: "Yop" }];
const { contentEl } = await setupHTMLBuilder("<section><p>Text</p></section>", {
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: createTestSnippets({ snippets, withName: false }).map(
(snippetDesc) => getSnippetStructure(snippetDesc)
),
},
});
expect(contentEl).toHaveInnerHTML(`<section><p>Text</p></section>`);
await click(".o-snippets-menu #snippet_groups .o_snippet_thumbnail .o_snippet_thumbnail_area");
await waitForSnippetDialog();
const previewSelector =
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap";
expect(previewSelector).toHaveCount(1);
await contains(previewSelector).click();
expect(".o_add_snippet_dialog").toHaveCount(0);
await waitForEndOfOperation();
expect(contentEl).toHaveInnerHTML(
`<section><p>Text</p></section>${
createTestSnippets({ snippets, withName: true })[0].content
}`
);
});
test("Drag & drop snippet structure", async () => {
const snippets = [{ name: "Test", groupName: "a", innerHTML: "Yop" }];
const { contentEl } = await setupHTMLBuilder("<section><p>Text</p></section>", {
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: createTestSnippets({ snippets, withName: false }).map(
(snippetDesc) => getSnippetStructure(snippetDesc)
),
},
});
expect(contentEl).toHaveInnerHTML(`<section><p>Text</p></section>`);
const { moveTo, drop } = await contains(
".o-snippets-menu #snippet_groups .o_snippet_thumbnail"
).drag();
expect(":iframe .oe_drop_zone:nth-child(1)").toHaveCount(1);
expect(":iframe .oe_drop_zone:nth-child(3)").toHaveCount(1);
await moveTo(":iframe .oe_drop_zone");
expect(":iframe .oe_drop_zone.o_dropzone_highlighted:nth-child(1)").toHaveCount(1);
await drop(getDragHelper());
expect(":iframe section[data-snippet='s_snippet_group']:nth-child(1)").toHaveCount(1);
expect(".o_add_snippet_dialog").toHaveCount(1);
await waitForSnippetDialog();
const previewSelector =
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_snippet_preview_wrap";
expect(previewSelector).toHaveCount(1);
await contains(previewSelector).click();
expect(".o_add_snippet_dialog").toHaveCount(0);
await waitForEndOfOperation();
expect(contentEl).toHaveInnerHTML(
`${
createTestSnippets({ snippets, withName: true })[0].content
}<section><p>Text</p></section>`
);
});
test("Cancel snippet drag & drop over sidebar", async () => {
const { contentEl } = await setupHTMLBuilderWithDummySnippet();
const { moveTo, drop } = await contains(
".o-snippets-menu #snippet_groups .o_snippet_thumbnail"
).drag();
expect(":iframe .oe_drop_zone").toHaveCount(1);
// Specifying an explicit target should not be needed, but the test
// sometimes fails, probably because the snippet is partially touching the
// iframe. We drop on the "mobile" button to be as far as possible from the
// iframe.
await moveTo(".o-website-builder_sidebar button[data-action=mobile]");
await drop(getDragHelper());
expect(".o_add_snippet_dialog").toHaveCount(0);
await waitForEndOfOperation();
expect(contentEl).toHaveInnerHTML("");
});
test("Renaming custom snippets don't make an orm call", async () => {
class TestSetupEditorPlugin extends Plugin {
static id = "test.setup_editor_plugin";
resources = {
snippet_preview_dialog_bundles: ["web.assets_frontend"],
};
}
addBuilderPlugin(TestSetupEditorPlugin);
// Stub rename_snippet RPC to succeed if it is called
onRpc("ir.ui.view", "rename_snippet", ({ args }) => true);
const customSnippets = createTestSnippets({
snippets: [
{
name: "Dummy Section",
groupName: "custom",
keywords: ["dummy"],
content: `
<section data-snippet="s_dummy">
<div class="container">
<div class="row">
<div class="col-lg-7">
<p>TEST</p>
</div>
</div>
</div>
</section>
`,
},
],
withName: true,
});
const snippets = {
snippet_groups: [
'<div name="Custom" data-oe-snippet-id="123" data-o-snippet-group="custom"><section data-snippet="s_snippet_group"></section></div>',
],
snippet_structure: customSnippets.map((snippetDesc) => getSnippetStructure(snippetDesc)),
snippet_custom: customSnippets.map((snippetDesc) => getSnippetStructure(snippetDesc)),
};
await setupHTMLBuilder(
`<section data-name="Dummy Section" data-snippet="s_dummy">
<div class="container">
<div class="row">
<div class="col-lg-7">
<p>TEST</p>
<p><a class="btn">BUTTON</a></p>
</div>
</div>
</div>
</section>`,
{ snippets }
);
await contains(
".o-website-builder_sidebar .o_snippets_container .o_snippet[name='Custom'] button"
).click();
await animationFrame();
// Throw if any render_public_asset RPC happens during rename
onRpc("ir.ui.view", "render_public_asset", () => {
throw new Error("shouldn't make an rpc call on snippet rename");
});
await contains(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_custom_snippet_edit button > .fa-pencil"
).click();
expect(".o-overlay-item .modal-dialog:contains('Rename the block')").toHaveCount(1);
await contains(".o-overlay-item .modal-dialog input#inputConfirmation").fill("new custom name");
await contains(".o-overlay-item .modal-dialog footer>button:contains('Save')").click();
expect(
".o_add_snippet_dialog .o_add_snippet_iframe:iframe .o_custom_snippet_edit>span:contains('new custom name')"
).toHaveCount(1);
});

View file

@ -0,0 +1,308 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { Builder } from "@html_builder/builder";
import { BuilderAction } from "@html_builder/core/builder_action";
import { SavePlugin } from "@html_builder/core/save_plugin";
import { BaseOptionComponent } from "@html_builder/core/utils";
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-dom";
import { useState, xml } from "@odoo/owl";
import {
contains,
defineModels,
fields,
models,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
class TestAction extends BuilderAction {
static id = "testAction";
isApplied({ editingElement }) {
return editingElement.classList.contains("applied");
}
apply({ editingElement }) {
editingElement.classList.toggle("applied");
expect.step("apply");
}
}
beforeEach(() => {
addBuilderAction({
TestAction,
});
});
test("apply is called if clean is not defined", async () => {
addBuilderOption({
selector: ".s_test",
template: xml`<BuilderButton action="'testAction'">Click</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="s_test">Test</section>`);
await contains(":iframe .s_test").click();
await contains("[data-action-id='testAction']").click();
expect("[data-action-id='testAction']").toHaveClass("active");
expect.verifySteps(["apply", "apply"]); // preview, apply
await contains("[data-action-id='testAction']").click();
expect("[data-action-id='testAction']").not.toHaveClass("active");
expect.verifySteps(["apply"]); // clean
});
test("custom action and shorthand action: clean actions are independent, apply is called on custom action if clean is not defined", async () => {
addBuilderOption({
selector: ".s_test",
template: xml`<BuilderButton action="'testAction'" classAction="'custom-class'">Click</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="s_test">Test</section>`);
await contains(":iframe .s_test").click();
await contains("[data-action-id='testAction']").click();
expect("[data-action-id='testAction']").toHaveClass("active");
expect.verifySteps(["apply", "apply"]); // preview, apply
await contains("[data-action-id='testAction']").click();
expect("[data-action-id='testAction']").not.toHaveClass("active");
expect.verifySteps(["apply"]); // clean
});
test("Prepare is triggered on props updated", async () => {
const newPropDeferred = new Deferred();
let prepareDeferred = new Promise((r) => r());
class TestOption extends BaseOptionComponent {
static template = xml`<BuilderCheckbox action="'customAction'" actionParam="state.param"/>`;
static props = {};
setup() {
super.setup();
this.state = useState({ param: "old param" });
newPropDeferred.then(() => {
this.state.param = "new param";
});
}
}
class CustomAction extends BuilderAction {
static id = "customAction";
async prepare() {
await prepareDeferred;
expect.step("prepare");
}
apply() {}
}
addBuilderAction({
CustomAction,
});
addBuilderOption({
Component: TestOption,
selector: ".test-options-target",
});
await setupHTMLBuilder(`<section class="test-options-target">Homepage</section>`);
await contains(":iframe .test-options-target").click();
expect.verifySteps(["prepare"]);
prepareDeferred = new Deferred();
// Update prop
newPropDeferred.resolve();
await animationFrame();
expect.verifySteps([]);
prepareDeferred.resolve();
await animationFrame();
expect.verifySteps(["prepare"]);
});
test("Data Attribute action works with non string values", async () => {
addBuilderOption({
selector: ".s_test",
template: xml`<BuilderButton dataAttributeAction="'customerOrderIds'" dataAttributeActionValue="[100, 200]">Click</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="s_test">Test</section>`);
await contains(":iframe .s_test").click();
await contains(".we-bg-options-container button:contains('Click')").click();
expect(".we-bg-options-container button:contains('Click')").toHaveClass("active");
expect(":iframe .s_test").toHaveAttribute("data-customer-order-ids", "100,200");
});
describe("isPreviewing is passed to action's apply and clean", () => {
beforeEach(async () => {
addBuilderAction({
IsPreviewingAction: class extends BuilderAction {
static id = "isPreviewing";
isApplied({ editingElement }) {
return editingElement.classList.contains("o_applied");
}
getValue({ editingElement }) {
return editingElement.dataset.value;
}
apply({ isPreviewing, editingElement, value }) {
expect.step(`apply ${isPreviewing}`);
editingElement.classList.add("o_applied");
editingElement.dataset.value = value;
}
clean({ isPreviewing, editingElement }) {
expect.step(`clean ${isPreviewing}`);
editingElement.classList.remove("o_applied");
delete editingElement.dataset.value;
}
},
});
});
test("useClickableBuilderComponent", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'isPreviewing'" actionValue="true">Toggle</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="test-options-target">Homepage</section>`);
await contains(":iframe .test-options-target").click();
// apply
await contains("[data-action-id='isPreviewing']").click();
expect.verifySteps(["apply true", "apply false"]);
// Hover something else, making sure we have a preview on next click
await contains(":iframe .test-options-target").click();
// clean
await contains("[data-action-id='isPreviewing']").click();
expect.verifySteps(["clean true", "clean false"]);
});
test("useInputBuilderComponent", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderTextInput action="'isPreviewing'"/>`,
});
await setupHTMLBuilder(`<section class="test-options-target">Homepage</section>`);
await contains(":iframe .test-options-target").click();
// apply
await contains("[data-action-id='isPreviewing'] input").edit("truthy");
expect.verifySteps(["apply true", "apply false"]);
});
test("useColorPickerBuilderComponent", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker action="'isPreviewing'"/>`,
});
await setupHTMLBuilder(`<section class="test-options-target">Homepage</section>`);
await contains(":iframe .test-options-target").click();
// apply
await contains(".o_we_color_preview").click();
await contains("button:contains(Custom)").click();
await contains("button[data-color='600']").click();
expect.verifySteps(["apply true", "apply false"]);
});
test("BuilderMany2One", async () => {
class Test extends models.Model {
_name = "test";
_records = [
{ id: 1, name: "First" },
{ id: 2, name: "Second" },
{ id: 3, name: "Third" },
];
name = fields.Char();
}
onRpc("test", "name_search", () => [
[1, "First"],
[2, "Second"],
[3, "Third"],
]);
defineModels([Test]);
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderMany2One action="'isPreviewing'" model="'test'" limit="10" allowUnselect="true"/>`,
});
await setupHTMLBuilder(`<section class="test-options-target">Homepage</section>`);
await contains(":iframe .test-options-target").click();
// apply
await contains(".o_select_menu button").click();
await contains(".o_select_menu button").click(); // issue with select menu + builder many2one in tests: does not load on first open
await contains(".o_select_menu button").click();
await contains(".o_select_menu_item[data-choice-index='0']").click();
expect.verifySteps(["apply true", "apply false"]);
// clean
await contains(".o_select_menu + button > .oi-close").click();
expect.verifySteps(["clean true", "clean false"]);
});
});
test("reload action: apply, clean save and reload are called in the right order (async)", async () => {
let reloadDef, applyDef, cleanDef;
patchWithCleanup(SavePlugin.prototype, {
async save() {
expect.step("save sync");
await super.save();
expect.step("save async");
},
async saveView() {
return new Promise((resolve) => setTimeout(resolve, 10));
},
});
patchWithCleanup(Builder.prototype, {
setup() {
super.setup();
this.editor.config.reloadEditor = async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
expect.step("reload");
reloadDef.resolve();
};
},
});
addBuilderAction({
TestReloadAction: class extends BuilderAction {
static id = "testReload";
setup() {
this.reload = {};
}
isApplied({ editingElement }) {
return editingElement.dataset.applied === "true";
}
async apply({ editingElement }) {
expect.step("apply sync");
await applyDef;
expect.step("apply async");
editingElement.dataset.applied = "true";
}
async clean({ editingElement }) {
expect.step("clean sync");
await cleanDef;
expect.step("clean async");
editingElement.dataset.applied = "false";
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'testReload'">Click</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="test-options-target">Test</section>`);
await contains(":iframe .test-options-target").click();
// Apply
reloadDef = new Deferred();
applyDef = new Deferred();
await contains("[data-action-id='testReload']").click();
expect.verifySteps(["apply sync"]);
applyDef.resolve();
await reloadDef;
expect.verifySteps(["apply async", "save sync", "save async", "reload"]);
// Clean
reloadDef = new Deferred();
cleanDef = new Deferred();
await contains("[data-action-id='testReload']").click();
expect.verifySteps(["clean sync"]);
cleanDef.resolve();
await reloadDef;
expect.verifySteps(["clean async", "save sync", "save async", "reload"]);
});

View file

@ -0,0 +1,280 @@
import {
addBuilderPlugin,
addBuilderOption,
addBuilderAction,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { Plugin } from "@html_editor/plugin";
import { expect, test, describe } from "@odoo/hoot";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("Undo/Redo correctly restores the stored container target", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
editingElement.remove();
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'">Test</BuilderButton>`,
});
await setupHTMLBuilder(`
<div data-name="Target 1" class="test-options-target target1">
Homepage
</div>
<div data-name="Target 2" class="test-options-target target2">
Homepage2
</div>
`);
await contains(":iframe .target1").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains("[data-action-id='customAction']").click();
await contains(":iframe .target2").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
await contains(".o-snippets-top-actions .fa-undo").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains(".o-snippets-top-actions .fa-repeat").click();
expect(".options-container").toHaveCount(0);
});
test("Undo/Redo multiple actions always restores the action container target", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
editingElement.classList.add("test");
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'">Test</BuilderButton>`,
});
await setupHTMLBuilder(`
<div data-name="Target 1" class="test-options-target target1">
Homepage
</div>
<div data-name="Target 2" class="test-options-target target2">
Homepage2
</div>
`);
await contains(":iframe .target1").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains("[data-action-id='customAction']").click();
await contains(":iframe .target2").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
await contains("[data-action-id='customAction']").click();
expect(":iframe .test-options-target.test").toHaveCount(2);
// Undo everything.
await contains(".o-snippets-top-actions .fa-undo").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
await contains(".o-snippets-top-actions .fa-undo").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
expect(":iframe .test-options-target.test").toHaveCount(0);
// Redo everything.
await contains(".o-snippets-top-actions .fa-repeat").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains(".o-snippets-top-actions .fa-repeat").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
expect(":iframe .test-options-target.test").toHaveCount(2);
});
test("Undo/Redo an action that activates another target restores the old one on undo and the new one on redo", async () => {
let editor;
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
editingElement.classList.add("test");
editor.shared.builderOptions.setNextTarget(editingElement.nextElementSibling);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'">Test</BuilderButton>`,
});
const { getEditor } = await setupHTMLBuilder(`
<div data-name="Target 1" class="test-options-target target1">
Homepage
</div>
<div data-name="Target 2" class="test-options-target target2">
Homepage2
</div>
`);
editor = getEditor();
await contains(":iframe .target1").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains("[data-action-id='customAction']").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
// Undo everything.
await contains(".o-snippets-top-actions .fa-undo").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains(".o-snippets-top-actions .fa-repeat").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
});
test("Undo/Redo an action that deactivates the containers restores the old one on undo and deactivates again on redo", async () => {
let editor;
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
editingElement.classList.add("test");
editor.shared.builderOptions.setNextTarget(false);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'">Test</BuilderButton>`,
});
const { getEditor } = await setupHTMLBuilder(`
<div data-name="Target 1" class="test-options-target target1">
Homepage
</div>
`);
editor = getEditor();
await contains(":iframe .target1").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains("[data-action-id='customAction']").click();
expect(".options-container").toHaveCount(0);
expect("button[data-name='blocks']").toHaveClass("active");
// Undo everything.
await contains(".o-snippets-top-actions .fa-undo").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 1");
await contains(".o-snippets-top-actions .fa-repeat").click();
expect(".options-container").toHaveCount(0);
expect("button[data-name='blocks']").toHaveClass("active");
});
test("Containers fallback to a valid ancestor if the target disappears and restore it on undo", async () => {
addBuilderAction({
targetAction: class extends BuilderAction {
static id = "targetAction";
apply({ editingElement }) {
editingElement.remove();
}
},
ancestorAction: class extends BuilderAction {
static id = "ancestorAction";
apply({ editingElement }) {
editingElement.remove();
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'targetAction'">Test</BuilderButton>`,
});
addBuilderOption({
selector: ".test-ancestor",
template: xml`<BuilderButton action="'ancestorAction'">Ancestor selected</BuilderButton>`,
});
await setupHTMLBuilder(`
<div data-name="Ancestor" class="test-ancestor">
Hey I'm an ancestor
<div data-name="Target 1" class="test-options-target target1">
Homepage
</div>
</div>
`);
await contains(":iframe .target1").click();
expect(".options-container[data-container-title='Ancestor']").toHaveCount(1);
expect(".options-container[data-container-title='Target 1']").toHaveCount(1);
await contains("[data-action-id='targetAction']").click();
expect(".options-container[data-container-title='Ancestor']").toHaveCount(1);
expect(".options-container[data-container-title='Target 1']").toHaveCount(0);
expect("[data-action-id='ancestorAction']").toHaveCount(1);
await contains(".o-snippets-top-actions .fa-undo").click();
expect(".options-container[data-container-title='Ancestor']").toHaveCount(1);
expect(".options-container[data-container-title='Target 1']").toHaveCount(1);
});
test("Do not activate/update containers if the element clicked is excluded", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton classAction="'test'">Test</BuilderButton>`,
});
await setupHTMLBuilder(`
<div data-name="Target 1" class="test-options-target target1 o_we_no_overlay">
Homepage
</div>
<div data-name="Target 2" class="test-options-target target2">
Homepage2
</div>
`);
await contains(":iframe .target1").click();
expect(".options-container").toHaveCount(0);
await contains(":iframe .target2").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
expect(".options-container [data-class-action='test']").toHaveCount(1);
await contains(":iframe .target1").click();
expect(".options-container").toHaveAttribute("data-container-title", "Target 2");
});
test("Do not show parent container for no_parent_containers targets", async () => {
class TestPlugin extends Plugin {
static id = "test";
resources = {
no_parent_containers: ".test-child-target",
};
}
addBuilderPlugin(TestPlugin);
addBuilderOption({
selector: ".test-parent-target",
template: xml`<BuilderButton classAction="'test'">Test</BuilderButton>`,
});
addBuilderOption({
selector: ".test-child-target",
template: xml`<BuilderButton classAction="'test'">Test</BuilderButton>`,
});
addBuilderOption({
selector: ".test-grand-child-target",
template: xml`<BuilderButton classAction="'test'">Test</BuilderButton>`,
});
await setupHTMLBuilder(`
<div data-name="Parent" class="test-parent-target">
Parent
<div data-name="Child" class="test-child-target">
Child
<div data-name="Grand-child" class="test-grand-child-target">
Grand-Child
</div>
</div>
</div>
`);
await contains(":iframe .test-child-target").click();
expect(".options-container").toHaveCount(1);
expect(".options-container").toHaveAttribute("data-container-title", "Child");
// Try with several layers
await contains(":iframe .test-grand-child-target").click();
expect(".options-container").toHaveCount(2);
expect(".options-container:eq(0)").toHaveAttribute("data-container-title", "Child");
expect(".options-container:eq(1)").toHaveAttribute("data-container-title", "Grand-child");
// Make sure the parent's options still appear for itself.
await contains(":iframe .test-parent-target").click();
expect(".options-container").toHaveCount(1);
expect(".options-container").toHaveAttribute("data-container-title", "Parent");
});

View file

@ -0,0 +1,63 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { expect, test, describe } from "@odoo/hoot";
import { xml } from "@odoo/owl";
import { contains, onRpc } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("clean for save of option with selector that matches an element on the page", async () => {
onRpc("ir.ui.view", "save", ({ args }) => true);
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="'x'"/>
</BuilderButtonGroup>
`,
cleanForSave: (_) => {
expect.step("clean for save option");
},
});
const { getEditor } = await setupHTMLBuilder(`<div class="test-options-target">a</div>`);
const editor = getEditor();
await contains(":iframe .test-options-target").click();
// Add an option to mark the document as 'dirty' and trigger a "clean for
// save" at the save of the page.
await contains("[data-class-action='x']").click();
await editor.shared.savePlugin.save();
expect.verifySteps(["clean for save option"]);
});
test("clean for save of option with selector and exclude that matches an element on the page", async () => {
onRpc("ir.ui.view", "save", ({ args }) => true);
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="'x'"/>
</BuilderButtonGroup>
`,
exclude: "div",
cleanForSave: (_) => {
expect.step("clean for save option");
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="'y'"/>
</BuilderButtonGroup>
`,
});
const { getEditor } = await setupHTMLBuilder(`<div class="test-options-target">a</div>`);
const editor = getEditor();
await contains(":iframe .test-options-target").click();
// Add an option to mark the document as 'dirty' and trigger a "clean for
// save" at the save of the page.
await contains("[data-class-action='y']").click();
await editor.shared.savePlugin.save();
// Do not expect for a clean for save as the element on the page matches the
// 'exclude' of the option having the 'cleanForSave'.
expect.verifySteps([]);
});

View file

@ -0,0 +1,177 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { describe, expect, test } from "@odoo/hoot";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
// TODO: test composite with each spec: prepare, load, getValue
// TODO: test reloadComposite
describe.current.tags("desktop");
test("can call 2 separate actions with composite action", async () => {
class Action1 extends BuilderAction {
static id = "action1";
isApplied({ editingElement, params: { mainParam: cls } }) {
return editingElement.classList.contains(cls);
}
apply({ editingElement, params: { mainParam: cls } }) {
editingElement.classList.toggle(cls);
expect.step(`action1: ${cls}`);
}
}
class Action2 extends BuilderAction {
static id = "action2";
isApplied({ editingElement, params: { mainParam: cls } }) {
return editingElement.classList.contains(cls);
}
apply({ editingElement, params: { mainParam: cls } }) {
editingElement.classList.toggle(cls);
expect.step(`action2: ${cls}`);
}
}
addBuilderAction({
Action1,
Action2,
});
addBuilderOption({
selector: ".s_test",
template: xml`
<BuilderButton
action="'composite'"
actionParam="[
{ action: 'action1', actionParam: { mainParam: 'class1' } },
{ action: 'action2', actionParam: { mainParam: 'class2' } },
]">
Click
</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="s_test">Test</section>`);
await contains(":iframe .s_test").click();
await contains("[data-action-id='composite']").click();
expect(":iframe .s_test").toHaveClass("class1 class2");
expect.verifySteps([
"action1: class1", // preview
"action2: class2", // preview
"action1: class1", // apply
"action2: class2", // apply
]);
await contains("[data-action-id='composite']").click();
expect.verifySteps(["action1: class1", "action2: class2"]); // clean
});
test("can call the same action twice with composite action", async () => {
class Action1 extends BuilderAction {
static id = "action1";
isApplied({ editingElement, params: { mainParam: cls } }) {
return editingElement.classList.contains(cls);
}
apply({ editingElement, params: { mainParam: cls } }) {
editingElement.classList.toggle(cls);
expect.step(`action1: ${cls}`);
}
}
addBuilderAction({
Action1,
});
addBuilderOption({
selector: ".s_test",
template: xml`
<BuilderButton
action="'composite'"
actionParam="[
{ action: 'action1', actionParam: { mainParam: 'class1' } },
{ action: 'action1', actionParam: { mainParam: 'class2' } },
]">
Click
</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="s_test">Test</section>`);
await contains(":iframe .s_test").click();
await contains("[data-action-id='composite']").click();
expect(":iframe .s_test").toHaveClass("class1 class2");
expect.verifySteps([
"action1: class1", // preview
"action1: class2", // preview
"action1: class1", // apply
"action1: class2", // apply
]);
await contains("[data-action-id='composite']").click();
expect.verifySteps(["action1: class1", "action1: class2"]); // clean
});
test("composite action's isApplied returns false if no action defined it", async () => {
class Action1 extends BuilderAction {
static id = "action1";
apply({ params: { mainParam: cls } }) {
expect.step(`action: ${cls}`);
}
}
addBuilderAction({
Action1,
});
addBuilderOption({
selector: ".s_test",
template: xml`
<BuilderButton
action="'composite'"
actionParam="[
{ action: 'action1', actionParam: { mainParam: 'class1' } },
{ action: 'action1', actionParam: { mainParam: 'class2' } },
]">
Click
</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="s_test">Test</section>`);
await contains(":iframe .s_test").click();
expect("[data-action-id='composite']").not.toHaveClass("active");
await contains("[data-action-id='composite']").click();
expect("[data-action-id='composite']").not.toHaveClass("active");
expect.verifySteps([
"action: class1", // preview
"action: class2", // preview
"action: class1", // apply
"action: class2", // apply
]);
});
test("composite action's isApplied returns true if at least one action defined it", async () => {
class Action1 extends BuilderAction {
static id = "action1";
apply() {}
}
class Action2 extends BuilderAction {
static id = "action2";
isApplied({ editingElement, params: { mainParam: cls } }) {
return editingElement.classList.contains(cls);
}
apply({ editingElement, params: { mainParam: cls } }) {
editingElement.classList.add(cls);
}
}
addBuilderAction({
Action1,
Action2,
});
addBuilderOption({
selector: ".s_test",
template: xml`
<BuilderButton
action="'composite'"
actionParam="[
{ action: 'action1', actionParam: { mainParam: 'class1' } },
{ action: 'action2', actionParam: { mainParam: 'class2' } },
]">
Click
</BuilderButton>`,
});
await setupHTMLBuilder(`<section class="s_test">Test</section>`);
await contains(":iframe .s_test").click();
expect("[data-action-id='composite']").not.toHaveClass("active");
await contains("[data-action-id='composite']").click();
expect("[data-action-id='composite']").toHaveClass("active");
});

View file

@ -0,0 +1,80 @@
import {
addBuilderOption,
addBuilderPlugin,
setupHTMLBuilder,
dummyBase64Img,
} from "@html_builder/../tests/helpers";
import { Plugin } from "@html_editor/plugin";
import { expect, test, describe } from "@odoo/hoot";
import { xml } from "@odoo/owl";
import { contains, onRpc } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("Do not set contenteditable to true on elements inside o_not_editable", async () => {
class TestPlugin extends Plugin {
static id = "testPlugin";
resources = {
force_editable_selector: ".target",
};
}
addBuilderPlugin(TestPlugin);
await setupHTMLBuilder(`
<section>
<div class="o_not_editable">
<div class="target">
Hello
</div>
</div>
</section>
`);
expect(":iframe .target").not.toHaveAttribute("contenteditable", "true");
});
test("Media should not be replaceable if not inside a savable zone", async () => {
await setupHTMLBuilder("", {
headerContent: `
<header id="top" data-anchor="true" data-name="Header">
<i class="fa fa-shopping-cart fa-stack target" data-oe-model="ir.ui.view" data-oe-id="786" data-oe-field="arch" data-oe-xpath="/data/xpath/li[1]/a[1]"/>
</header>`,
styleContent: `
.fa {
display: flex;
justify-content: center;
align-items: center;
width: 0.75rem;
height: 0.75rem;
}
`,
});
expect(":iframe .target").toHaveClass("o_editable_media");
await contains(":iframe #wrapwrap .target").click();
expect("span:contains('Double-click to edit')").toHaveCount(0);
await contains(":iframe #wrapwrap .target").dblclick();
expect(".modal-content:contains(Select a media)").toHaveCount(0);
});
test("clone of editable media inside not editable area should be editable", async () => {
onRpc("/html_editor/get_image_info", () => ({}));
addBuilderOption({
selector: "section",
template: xml`<BuilderButton classAction="'test'">Test</BuilderButton>`,
});
addBuilderOption({
selector: "img",
template: xml`<BuilderButton classAction="'test'">Test Image</BuilderButton>`,
});
const { waitDomUpdated } = await setupHTMLBuilder(`
<section>
<div class="o_not_editable">
<img class="o_editable_media" src="${dummyBase64Img}"/>
</div>
</section>
`);
await contains(":iframe img").click();
await waitDomUpdated();
expect(".options-container[data-container-title='Image']").toBeDisplayed();
await contains(".oe_snippet_clone").click();
await contains(":iframe section:last-of-type img").click();
expect(".options-container[data-container-title='Image']").toBeDisplayed();
});

View file

@ -0,0 +1,82 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { BaseOptionComponent } from "@html_builder/core/utils";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { reactive, xml } from "@odoo/owl";
import { contains, defineModels, fields, models, onRpc } from "@web/../tests/web_test_helpers";
import { delay } from "@web/core/utils/concurrency";
class Test extends models.Model {
_name = "test";
_records = [
{ id: 1, name: "First" },
{ id: 2, name: "Second" },
{ id: 3, name: "Third" },
];
name = fields.Char();
}
describe.current.tags("desktop");
defineModels([Test]);
test.tags("focus required");
test("basic many2many: find tag, select tag, unselect tag", async () => {
onRpc("test", "name_search", () => [
[1, "First"],
[2, "Second"],
[3, "Third"],
]);
class TestComponent extends BaseOptionComponent {
static template = xml`<BasicMany2Many selection="props.selection" model="'test'" setSelection="props.setSelection.bind(this)"/>`;
static props = {
selection: Array,
setSelection: Function,
};
}
const selection = reactive([]);
addBuilderOption({
selector: ".test-options-target",
Component: TestComponent,
props: {
selection: selection,
setSelection(newSelection) {
selection.length = 0;
for (const item of newSelection) {
selection.push(item);
}
},
},
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
expect("table tr").toHaveCount(0);
expect(selection).toEqual([]);
await contains(".btn.o-dropdown").click();
expect("input").toHaveCount(1);
await contains("input").click();
await delay(300); // debounce
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(3);
await contains("span.o-dropdown-item").click();
expect(selection).toEqual([{ id: 1, name: "First", display_name: "First" }]);
expect("table tr").toHaveCount(1);
await contains(".btn.o-dropdown").click();
await delay(300); // debounce
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(2);
await contains("span.o-dropdown-item").click();
expect(selection).toEqual([
{ id: 1, name: "First", display_name: "First" },
{ id: 2, name: "Second", display_name: "Second" },
]);
expect("table tr").toHaveCount(2);
await contains("button.fa-minus").click();
expect(selection).toEqual([{ id: 2, name: "Second", display_name: "Second" }]);
expect("table tr").toHaveCount(1);
expect("table input").toHaveValue("Second");
});

View file

@ -0,0 +1,853 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { BaseOptionComponent, useDomState } from "@html_builder/core/utils";
import { undo } from "@html_editor/../tests/_helpers/user_actions";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame, click, Deferred, hover, runAllTimers } from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
const falsy = () => false;
test("call a specific action with some params and value", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ params: { mainParam: testParam }, value }) {
expect.step(`customAction ${testParam} ${value}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'" actionParam="'myParam'" actionValue="'myValue'">MyAction</BuilderButton>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
expect("[data-action-id='customAction']").toHaveText("MyAction");
await click("[data-action-id='customAction']");
await animationFrame();
// The function `apply` should be called twice (on hover (for preview), then, on click).
expect.verifySteps(["customAction myParam myValue", "customAction myParam myValue"]);
});
test("call a shorthand action", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton classAction="'my-custom-class'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click("[data-class-action='my-custom-class']");
await animationFrame();
expect(":iframe .test-options-target").toHaveClass("my-custom-class");
});
test("call a shorthand action and a specific action", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
expect.step(`customAction`);
editingElement.innerHTML = "c";
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'" classAction="'my-custom-class'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click("[data-action-id='customAction'][data-class-action='my-custom-class']");
await animationFrame();
expect(":iframe .test-options-target").toHaveClass("my-custom-class");
// The function `apply` should be called twice (on hover (for preview), then, on click).
expect.verifySteps(["customAction", "customAction"]);
expect(":iframe .test-options-target").toHaveInnerHTML("c");
});
test("preview a shorthand action and a specific action", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
expect.step(`customAction`);
editingElement.innerHTML = "c";
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'" classAction="'my-custom-class'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await hover("[data-action-id='customAction'][data-class-action='my-custom-class']");
expect(":iframe .test-options-target").toHaveClass("my-custom-class");
expect.verifySteps(["customAction"]);
expect(":iframe .test-options-target").toHaveInnerHTML("c");
await hover(":iframe .test-options-target");
expect(":iframe .test-options-target").toHaveInnerHTML("b");
expect.verifySteps([]);
});
test("prevent preview of a specific action", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply() {
expect.step(`customAction`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'" preview="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains("[data-action-id='customAction']").hover();
expect.verifySteps([]);
await contains("[data-action-id='customAction']").click();
expect.verifySteps(["customAction"]);
});
test("prevent preview of a specific action (2)", async () => {
class CustomAction extends BuilderAction {
static id = "customAction";
setup() {
this.preview = false;
}
apply() {
expect.step(`customAction`);
}
}
addBuilderAction({
CustomAction,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains("[data-action-id='customAction']").hover();
expect.verifySteps([]);
await contains("[data-action-id='customAction']").click();
expect.verifySteps(["customAction"]);
});
test("should toggle when not in a BuilderButtonGroup", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton classAction="'c1'" preview="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-class-action='c1']").click();
expect(":iframe .test-options-target").toHaveClass("test-options-target c1");
await contains("[data-class-action='c1']").click();
expect(":iframe .test-options-target").not.toHaveClass("test-options-target c1");
});
test("should call apply when the button is active and none of its actions have a clean method", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply() {
expect.step(`customAction apply`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'" preview="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-action-id='customAction']").click();
expect.verifySteps(["customAction apply"]);
await contains("[data-action-id='customAction']").click();
expect.verifySteps(["customAction apply"]);
});
test("should not toggle when in a BuilderButtonGroup", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="'c1'" preview="false"/>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-class-action='c1']").click();
expect(":iframe .test-options-target").toHaveClass("test-options-target c1");
await contains("[data-class-action='c1']").click();
expect(":iframe .test-options-target").toHaveClass("test-options-target c1");
});
test("clean another action", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="'my-custom-class1'"/>
<BuilderButton classAction="'my-custom-class2'"/>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click("[data-class-action='my-custom-class1']");
await animationFrame();
expect(":iframe .test-options-target").toHaveAttribute(
"class",
"test-options-target o-paragraph my-custom-class1"
);
await click("[data-class-action='my-custom-class2']");
await animationFrame();
expect(":iframe .test-options-target").toHaveAttribute(
"class",
"test-options-target o-paragraph my-custom-class2"
);
});
test("clean should provide the next action value", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
clean({ nextAction }) {
expect.step(
`customAction clean ${nextAction.params.mainParam} ${nextAction.value}`
);
}
apply() {
expect.step(`customAction apply`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="'c1'" action="'customAction'"/>
<BuilderButton classAction="'c2'" action="'customAction'" actionParam="'param2'" actionValue="'value2'"/>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click("[data-class-action='c1']");
await click("[data-class-action='c2']");
await animationFrame();
expect.verifySteps([
"customAction apply",
"customAction apply",
"customAction clean param2 value2",
"customAction apply",
"customAction clean param2 value2",
"customAction apply",
]);
});
test("clean should only be called on the currently selected item", async () => {
function makeAction(n) {
const action = class extends BuilderAction {
static id = `customAction${n}`;
clean() {
expect.step(`customAction${n} clean`);
}
apply() {
expect.step(`customAction${n} apply`);
}
};
return { action };
}
addBuilderAction({
customAction1: makeAction(1).action,
customAction2: makeAction(2).action,
customAction3: makeAction(3).action,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton action="'customAction1'" classAction="'c1'" />
<BuilderButton action="'customAction2'" classAction="'c2'" />
<BuilderButton action="'customAction3'" classAction="'c3'" />
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await click("[data-action-id='customAction1']");
await animationFrame();
expect(":iframe .test-options-target").toHaveClass("c1");
await click("[data-action-id='customAction2']");
await animationFrame();
expect(":iframe .test-options-target").toHaveClass("c2");
expect.verifySteps([
"customAction1 apply",
"customAction1 apply",
"customAction1 clean",
"customAction2 apply",
"customAction1 clean",
"customAction2 apply",
]);
});
test("clean should be async", async () => {
function makeAction(n) {
const { promise, resolve } = Promise.withResolvers();
const action = class extends BuilderAction {
static id = `customAction${n}`;
async clean() {
expect.step(`customAction${n} clean before promise`);
await promise;
expect.step(`customAction${n} clean after promise`);
}
apply() {
expect.step(`customAction${n} apply`);
}
};
return { action, resolve };
}
const action1 = makeAction(1);
addBuilderAction({
customAction1: action1.action,
customAction2: makeAction(2).action,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup preview="false">
<BuilderButton action="'customAction1'" classAction="'c1'"/>
<BuilderButton action="'customAction2'" />
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await click("[data-action-id='customAction1']");
await animationFrame();
await click("[data-action-id='customAction2']");
await animationFrame();
action1.resolve();
await animationFrame();
expect.verifySteps([
"customAction1 apply",
"customAction1 clean before promise",
"customAction1 clean after promise",
"customAction2 apply",
]);
});
test("add the active class if the condition is met", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton classAction="'my-custom-class1'"/>
<BuilderButton classAction="'my-custom-class2'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target my-custom-class1">b</div>`);
await contains(":iframe .test-options-target").click();
expect("[data-class-action='my-custom-class1']").toHaveClass("active");
expect("[data-class-action='my-custom-class2']").not.toHaveClass("active");
});
test("add classActive to class when active", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton classAction="'my-custom-class1'"
className="'base-class btn1'"
classActive="'active-class'"/>
<BuilderButton classAction="'my-custom-class2'"
className="'base-class btn2'"
classActive="'active-class'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target my-custom-class1">b</div>`);
await contains(":iframe .test-options-target").click();
const permanentClass = "base-class";
const activeClass = "active-class";
expect(".btn1").toHaveClass([permanentClass, activeClass]);
expect(".btn2").toHaveClass(permanentClass);
expect(".btn2").not.toHaveClass(activeClass);
await contains(".btn2").click();
expect(".btn2").toHaveClass([permanentClass, activeClass]);
await contains(".btn2").click();
expect(".btn2").toHaveClass(permanentClass);
expect(".btn2").not.toHaveClass(activeClass);
});
test("apply classAction on multi elements", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton applyTo="'.target-apply'" classAction="'my-custom-class'"/>`,
});
const { getEditableContent } = await setupHTMLBuilder(`
<div class="test-options-target">
<div class="target-apply">a</div>
<div class="target-apply">b</div>
</div>`);
const editableContent = getEditableContent();
await contains(":iframe .test-options-target").click();
expect(editableContent).toHaveInnerHTML(`
<div class="test-options-target">
<div class="target-apply o-paragraph">a</div>
<div class="target-apply o-paragraph">b</div>
</div>`);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(`
<div class="test-options-target">
<div class="target-apply o-paragraph my-custom-class">a</div>
<div class="target-apply o-paragraph my-custom-class">b</div>
</div>`);
});
test("hide/display base on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'my label'">
<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'my label'">
<BuilderButton applyTo="'.my-custom-class'" classAction="'test'"/>
</BuilderRow>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="parent-target o-paragraph"><div class="child-target">b</div></div>`
);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target o-paragraph"><div class="child-target">b</div></div>`
);
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
expect("[data-class-action='test']").toHaveCount(0);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target o-paragraph"><div class="child-target my-custom-class">b</div></div>`
);
expect("[data-class-action='my-custom-class']").toHaveClass("active");
expect("[data-class-action='test']").toHaveCount(1);
expect("[data-class-action='test']").not.toHaveClass("active");
});
describe("inherited actions", () => {
function makeAction(n, { async, isApplied } = {}) {
const action = class extends BuilderAction {
static id = `customAction${n}`;
isApplied() {
return isApplied?.();
}
clean({ params: { mainParam: testParam }, value }) {
expect.step(`customAction${n} clean ${testParam} ${value}`);
}
apply({ params: { mainParam: testParam }, value }) {
expect.step(`customAction${n} apply ${testParam} ${value}`);
}
};
if (async) {
let resolve;
const promise = new Promise((r) => {
resolve = r;
});
action.prototype.load = async ({ params: { mainParam: testParam }, value }) => {
expect.step(`customAction${n} load ${testParam} ${value}`);
return promise;
};
return { action, resolve };
}
return { action };
}
test("inherit actions for another button", async () => {
addBuilderAction({
customAction1: makeAction(1).action,
customAction2: makeAction(2).action,
customAction3: makeAction(3, { isApplied: falsy }).action,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton action="'customAction1'" actionParam="'myParam1'" actionValue="'myValue1'" classAction="'class1'" id="'c1'">MyAction1</BuilderButton>
<BuilderButton action="'customAction2'" actionParam="'myParam2'" actionValue="'myValue2'">MyAction2</BuilderButton>
</BuilderButtonGroup>
<BuilderButton action="'customAction3'" actionParam="'myParam3'" actionValue="'myValue3'" inheritedActions="['c1']" >MyAction2</BuilderButton>
`,
});
await setupHTMLBuilder(`<div class="test-options-target class1">a</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-action-id='customAction3']").hover();
expect.verifySteps([
"customAction1 clean myParam1 myValue1",
"customAction3 apply myParam3 myValue3",
"customAction1 apply myParam1 myValue1",
]);
});
test("inherit actions for another button (with async)", async () => {
const action1 = makeAction(1, { async: true });
const action2 = makeAction(2, { async: true });
const action3 = makeAction(3, { async: true });
const action4 = makeAction(4, { async: true, isApplied: falsy });
addBuilderAction({
customAction1: action1.action,
customAction2: action2.action,
customAction3: action3.action,
customAction4: action4.action,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton action="'customAction1'" actionParam="'myParam1'" actionValue="'myValue1'" classAction="'class1'" id="'c1'">MyAction1</BuilderButton>
<BuilderButton action="'customAction2'" actionParam="'myParam2'" actionValue="'myValue2'">MyAction2</BuilderButton>
</BuilderButtonGroup>
<BuilderButton action="'customAction3'" actionParam="'myParam3'" actionValue="'myValue3'" id="'c3'">MyAction1</BuilderButton>
<BuilderButton action="'customAction4'" actionParam="'myParam4'" actionValue="'myValue4'" inheritedActions="['c1', 'c3']" >MyAction2</BuilderButton>
`,
});
await setupHTMLBuilder(`<div class="test-options-target class1">a</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-action-id='customAction4']").hover();
action4.resolve();
action3.resolve();
action1.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect.verifySteps([
"customAction4 load myParam4 myValue4",
"customAction1 load myParam1 myValue1",
"customAction3 load myParam3 myValue3",
"customAction1 clean myParam1 myValue1",
"customAction4 apply myParam4 myValue4",
"customAction1 apply myParam1 myValue1",
"customAction3 apply myParam3 myValue3",
]);
});
test("inherit actions for another button (from the context)", async () => {
addBuilderAction({
customAction1: makeAction(1).action,
customAction2: makeAction(2).action,
customAction3: makeAction(3, { isApplied: falsy }).action,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton action="'customAction1'" actionParam="'myParam1'" actionValue="'myValue1'" classAction="'class1'" id="'c1'">MyAction1</BuilderButton>
<BuilderButton action="'customAction2'" actionParam="'myParam2'" actionValue="'myValue2'">MyAction2</BuilderButton>
</BuilderButtonGroup>
<BuilderContext inheritedActions="['c1']">
<BuilderButton action="'customAction3'" actionParam="'myParam3'" actionValue="'myValue3'">MyAction2</BuilderButton>
</BuilderContext>
`,
});
await setupHTMLBuilder(`<div class="test-options-target class1">a</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-action-id='customAction3']").hover();
expect.verifySteps([
"customAction1 clean myParam1 myValue1",
"customAction3 apply myParam3 myValue3",
"customAction1 apply myParam1 myValue1",
]);
});
});
describe("Operation", () => {
function makeAsyncActionItem(actionName) {
const item = {};
const promise = new Promise((resolve) => {
item.resolve = resolve;
});
addBuilderAction({
[actionName]: class extends BuilderAction {
static id = actionName;
async load() {
expect.step(`load ${actionName}`);
await promise;
}
async apply({ editingElement }) {
expect.step(`apply ${actionName}`);
editingElement.innerText = editingElement.innerText + `-${actionName}`;
}
},
});
return item;
}
function makeActionItem(actionName) {
addBuilderAction({
[actionName]: class extends BuilderAction {
static id = actionName;
apply({ editingElement }) {
expect.step(actionName);
editingElement.innerText = editingElement.innerText + `-${actionName}`;
}
},
});
}
test("handle async actions with commit and preview (2 quick consecutive hovers)", async () => {
const asyncAction1 = makeAsyncActionItem("asyncAction1");
const asyncAction2 = makeAsyncActionItem("asyncAction2");
const asyncAction3 = makeAsyncActionItem("asyncAction3");
makeActionItem("action1");
makeActionItem("action2");
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRow label="'my label'">
<BuilderButton action="'asyncAction1'"/>
<BuilderButton action="'asyncAction2'"/>
<BuilderButton action="'asyncAction3'"/>
<BuilderButton action="'action1'"/>
<BuilderButton action="'action2'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target">a</div>`);
await contains(":iframe .test-options-target").click();
await hover("[data-action-id='asyncAction1']");
await animationFrame();
hover("[data-action-id='asyncAction2']");
hover("[data-action-id='asyncAction3']");
await runAllTimers();
// we check here that the action2 load operation has been cancelled by
// the action 3.
expect.verifySteps(["load asyncAction1", "load asyncAction3"]);
await animationFrame();
await contains("[data-action-id='asyncAction3']").click();
await hover("[data-action-id='action1']");
await animationFrame();
asyncAction1.resolve();
asyncAction2.resolve();
asyncAction3.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect.verifySteps(["load asyncAction3", "apply asyncAction3", "action1"]);
expect(":iframe .test-options-target").toHaveInnerHTML("a-asyncAction3-action1");
// If the code is not working properly, hovering on another action at
// this moment could revert the changes made by asyncAction3 through the
// revert of the preview. In order to test this case, we hover action2.
await hover("[data-action-id='action2']");
await animationFrame();
expect(":iframe .test-options-target").toHaveInnerHTML("a-asyncAction3-action2");
expect.verifySteps(["action2"]);
});
test("handle async actions with commit and preview (separated by running all timers)", async () => {
const asyncAction1 = makeAsyncActionItem("asyncAction1");
const asyncAction2 = makeAsyncActionItem("asyncAction2");
const asyncAction3 = makeAsyncActionItem("asyncAction3");
makeActionItem("action1");
makeActionItem("action2");
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRow label="'my label'">
<BuilderButton action="'asyncAction1'"/>
<BuilderButton action="'asyncAction2'"/>
<BuilderButton action="'asyncAction3'"/>
<BuilderButton action="'action1'"/>
<BuilderButton action="'action2'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target">a</div>`);
await contains(":iframe .test-options-target").click();
await hover("[data-action-id='asyncAction1']");
await runAllTimers();
await hover("[data-action-id='asyncAction2']");
await runAllTimers();
await hover("[data-action-id='asyncAction3']");
await runAllTimers();
await contains("[data-action-id='asyncAction3']").click();
await hover("[data-action-id='action1']");
await runAllTimers();
asyncAction1.resolve();
asyncAction2.resolve();
asyncAction3.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect.verifySteps([
"load asyncAction1",
"load asyncAction2",
"load asyncAction3",
"load asyncAction3",
"apply asyncAction3",
"action1",
]);
expect(":iframe .test-options-target").toHaveInnerHTML("a-asyncAction3-action1");
// If the code is not working properly, hovering on another action at
// this moment could revert the changes made by asyncAction3 through the
// revert of the preview. In order to test this case, we hover action2.
await hover("[data-action-id='action2']");
await animationFrame();
expect(":iframe .test-options-target").toHaveInnerHTML("a-asyncAction3-action2");
expect.verifySteps(["action2"]);
});
});
test("click on BuilderButton with inverseAction", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton classAction="'my-custom-class'" inverseAction="true"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(":iframe .test-options-target").not.toHaveClass("my-custom-class");
expect("[data-class-action='my-custom-class']").toHaveClass("active");
await contains("[data-class-action='my-custom-class']").click();
expect(":iframe .test-options-target").toHaveClass("my-custom-class");
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
});
test("do not load when an operation is cleaned", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
isApplied({ editingElement }) {
return editingElement.classList.contains("applied");
}
clean() {
expect.step("clean");
}
async load() {
expect.step("load");
}
apply({ editingElement }) {
expect.step("apply");
editingElement.classList.add("applied");
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButton action="'customAction'" preview="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-action-id='customAction']").click();
await contains("[data-action-id='customAction']").click();
expect.verifySteps(["load", "apply", "clean"]);
});
test("click on BuilderButton with async action", async () => {
const def = new Deferred();
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
isApplied({ editingElement }) {
return editingElement.classList.contains("applied");
}
async apply({ editingElement }) {
await def;
editingElement.classList.add("applied");
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton action="'customAction'" preview="false"/>
<BuilderButton classAction="'test'" preview="false"/>
`,
});
const { getEditor } = await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
const editor = getEditor();
await contains(":iframe .test-options-target").click();
await contains("[data-action-id='customAction']").click();
await contains("[data-class-action='test']").click();
expect(":iframe .test-options-target").not.toHaveClass("test");
expect(":iframe .test-options-target").not.toHaveClass("applied");
def.resolve();
await animationFrame();
expect(":iframe .test-options-target").toHaveClass("test");
expect(":iframe .test-options-target").toHaveClass("applied");
undo(editor);
expect(":iframe .test-options-target").not.toHaveClass("test");
expect(":iframe .test-options-target").toHaveClass("applied");
undo(editor);
expect(":iframe .test-options-target").not.toHaveClass("test");
expect(":iframe .test-options-target").not.toHaveClass("applied");
});
class SubTestOption extends BaseOptionComponent {
static template = xml`
<BuilderContext applyTo="this.domState.applyTo">
<BuilderButton classAction="'actionClass'">actionClass</BuilderButton>
</BuilderContext>
`;
static props = {};
setup() {
super.setup();
this.domState = useDomState((el) => ({
applyTo: el.matches(".first") ? ".a" : ".b",
}));
}
}
class TestOption extends BaseOptionComponent {
static template = xml`
<BuilderButton classAction="'secondCase'">secondCase</BuilderButton>
<BuilderContext applyTo="this.domState.applyTo">
<SubTestOption/>
</BuilderContext>
`;
static props = {};
static components = {
SubTestOption,
};
setup() {
super.setup();
this.domState = useDomState((el) => ({
applyTo: el.matches(".secondCase") ? ".second" : ".first",
}));
}
}
test("consecutive dynamic applyTo", async () => {
addBuilderOption({
selector: ".selector",
Component: TestOption,
});
await setupHTMLBuilder(`
<div class="selector">
<div class="first">
<div class="a">a</div>
<div class="b">b</div>
</div>
<div class="second">
<div class="a">a</div>
<div class="b">b</div>
</div>
</div>
`);
await contains(":iframe .selector").click();
await contains("[data-class-action='actionClass']").click();
expect(":iframe .first .a").toHaveClass("actionClass");
expect(":iframe .first .b").not.toHaveClass("actionClass");
await contains("[data-class-action='secondCase']").click();
await contains("[data-class-action='actionClass']").click();
expect(":iframe .second .a").not.toHaveClass("actionClass");
expect(":iframe .second .b").toHaveClass("actionClass");
});

View file

@ -0,0 +1,212 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { describe, expect, test } from "@odoo/hoot";
import { hover } from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("change the editingElement of sub widget through `applyTo` prop", async () => {
class CustomAction extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
expect.step(`customAction ${editingElement.className}`);
}
}
addBuilderAction({
CustomAction,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup applyTo="'.a'">
<BuilderButton action="'customAction'"/>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">
<div class="a">b</div>
</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await hover("[data-action-id='customAction']");
expect.verifySteps(["customAction a o-paragraph"]);
});
test("should propagate actionParam in the context", async () => {
class CustomAction extends BuilderAction {
static id = "customAction";
apply({ params: { mainParam: testParam } }) {
expect.step(`customAction ${testParam}`);
}
}
addBuilderAction({
CustomAction,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup actionParam="'myParam'">
<BuilderButton action="'customAction'"/>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">
<div class="a">b</div>
</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await hover("[data-action-id='customAction']");
expect.verifySteps(["customAction myParam"]);
});
test("prevent preview of all buttons", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup preview="false">
<BuilderButton action="'customAction1'"/>
<BuilderButton action="'customAction2'" preview="true"/>
</BuilderButtonGroup>
<BuilderButtonGroup preview="true">
<BuilderButton action="'customAction3'"/>
</BuilderButtonGroup>
<BuilderButtonGroup>
<BuilderButton action="'customAction4'"/>
</BuilderButtonGroup>`,
});
class CustomAction1 extends BuilderAction {
static id = "customAction1";
apply() {
return expect.step(`customAction1`);
}
}
class CustomAction2 extends BuilderAction {
static id = "customAction2";
apply() {
return expect.step(`customAction2`);
}
}
class CustomAction3 extends BuilderAction {
static id = "customAction3";
apply() {
return expect.step(`customAction3`);
}
}
class CustomAction4 extends BuilderAction {
static id = "customAction4";
apply() {
return expect.step(`customAction4`);
}
}
addBuilderAction({
CustomAction1,
CustomAction2,
CustomAction3,
CustomAction4,
});
await setupHTMLBuilder(`
<div class="test-options-target">
<div class="a">b</div>
</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains("[data-action-id='customAction1']").hover();
expect.verifySteps([]);
await contains("[data-action-id='customAction2']").hover();
expect.verifySteps(["customAction2"]);
await contains("[data-action-id='customAction3']").hover();
expect.verifySteps(["customAction3"]);
await contains("[data-action-id='customAction4']").hover();
expect.verifySteps(["customAction4"]);
});
test("hide/display base on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`
<BuilderButtonGroup applyTo="'.my-custom-class'">
<BuilderButton classAction="'test'">Test</BuilderButton>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(
`<div class="parent-target o-paragraph"><div class="child-target">b</div></div>`
);
await contains(":iframe .parent-target").click();
expect(".options-container .btn-group").toHaveCount(0);
await contains("[data-class-action='my-custom-class']").click();
expect(".options-container .btn-group").toHaveCount(1);
});
test("hide/display base on applyTo - 2", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton applyTo="'.my-custom-class'" classAction="'test'">Test</BuilderButton>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(
`<div class="parent-target o-paragraph"><div class="child-target">b</div></div>`
);
await contains(":iframe .parent-target").click();
expect(".options-container .btn-group").not.toBeVisible();
await contains("[data-class-action='my-custom-class']").click();
expect(".options-container .btn-group").toBeVisible();
});
test("click on BuilderButton with empty value should remove styleAction", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButtonGroup>
<BuilderButton styleAction="'width'" styleActionValue="''"/>
<BuilderButton styleAction="'width'" styleActionValue="'25%'"/>
</BuilderButtonGroup>`,
});
const { contentEl } = await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-style-action='width'][data-style-action-value='25%']").click();
expect(contentEl).toHaveInnerHTML(
`<div class="test-options-target o-paragraph" style="width: 25% !important;">b</div>`
);
await contains("[data-style-action='width'][data-style-action-value='']").click();
expect(contentEl).toHaveInnerHTML(
`<div class="test-options-target o-paragraph" style="">b</div>`
);
});
test("button that matches with the highest priority should be active", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderButtonGroup>
<BuilderButton classAction="'a'" >a</BuilderButton>
<BuilderButton classAction="'a b'">a b</BuilderButton>
<BuilderButton classAction="'a b c'">a b c</BuilderButton>
</BuilderButtonGroup>`,
});
await setupHTMLBuilder(`<div class="test-options-target a b">b</div>`);
await contains(":iframe .test-options-target").click();
expect("[data-class-action='a']").not.toHaveClass("active");
expect("[data-class-action='a b']").toHaveClass("active");
expect("[data-class-action='a b c']").not.toHaveClass("active");
});

View file

@ -0,0 +1,75 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { expect, test, describe } from "@odoo/hoot";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("Click on checkbox", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderCheckbox classAction="'checkbox-action'"/>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="test-options-target o-paragraph">b</div>`
);
const editableContent = getEditableContent();
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
expect(".o-checkbox .form-check-input:checked").toHaveCount(0);
expect(editableContent).toHaveInnerHTML(`<div class="test-options-target o-paragraph">b</div>`);
await contains(".o-checkbox").click();
expect(".o-checkbox .form-check-input:checked").toHaveCount(1);
expect(editableContent).toHaveInnerHTML(
`<div class="test-options-target o-paragraph checkbox-action">b</div>`
);
await contains(".o-checkbox").click();
expect(".o-checkbox .form-check-input:checked").toHaveCount(0);
expect(editableContent).toHaveInnerHTML(`<div class="test-options-target o-paragraph">b</div>`);
});
test("hide/display base on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderCheckbox classAction="'checkbox-action'" applyTo="'.my-custom-class'"/>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="parent-target"><div class="child-target b">b</div></div>`
);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target b o-paragraph">b</div></div>`
);
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
expect(".options-container .o-checkbox").toHaveCount(0);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target b o-paragraph my-custom-class">b</div></div>`
);
expect("[data-class-action='my-custom-class']").toHaveClass("active");
expect(".options-container .o-checkbox").toHaveCount(1);
});
test("click on BuilderCheckbox with inverseAction", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderCheckbox classAction="'my-custom-class'" inverseAction="true"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(":iframe .test-options-target").not.toHaveClass("my-custom-class");
expect(".o-checkbox .form-check-input:checked").toHaveCount(1);
await contains(".o-checkbox").click();
expect(":iframe .test-options-target").toHaveClass("my-custom-class");
expect(".o-checkbox .form-check-input:checked").toHaveCount(0);
});

View file

@ -0,0 +1,346 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { undo } from "@html_editor/../tests/_helpers/user_actions";
import { before, describe, expect, test } from "@odoo/hoot";
import { animationFrame, click, Deferred, hover, press, tick, waitFor } from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("should apply backgroundColor to the editing element", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker enabledTabs="['solid']" styleAction="'background-color'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await click(".o-overlay-item [data-color='o-color-1']");
await animationFrame();
expect(":iframe .test-options-target").toHaveClass("test-options-target bg-o-color-1");
});
test("should apply color to the editing element", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker enabledTabs="['solid']" styleAction="'color'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await click(".o-overlay-item [data-color='o-color-1']");
await animationFrame();
expect(":iframe .test-options-target").toHaveClass("test-options-target text-o-color-1");
});
test("hide/display base on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderColorPicker applyTo="'.my-custom-class'" styleAction="'background-color'"/>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="parent-target"><div class="child-target b">b</div></div>`
);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target b o-paragraph">b</div></div>`
);
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
expect(".options-container .o_we_color_preview").toHaveCount(0);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target b o-paragraph my-custom-class">b</div></div>`
);
expect("[data-class-action='my-custom-class']").toHaveClass("active");
expect(".options-container .o_we_color_preview").toHaveCount(1);
});
test("apply color to a different style than color or backgroundColor", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker enabledTabs="['solid']" styleAction="'border-top-color'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await contains(".o-overlay-item [data-color='#FF0000']").click();
expect(":iframe .test-options-target").toHaveStyle({
borderTopColor: "rgb(255, 0, 0)",
});
expect(".we-bg-options-container .o_we_color_preview").toHaveStyle({
"background-color": "rgb(255, 0, 0)",
});
});
test("apply custom action", async () => {
const styleName = "border-top-color";
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
async load() {
expect.step("load");
}
async apply({ editingElement }) {
expect.step(
`apply ${getComputedStyle(editingElement).getPropertyValue(styleName)}`
);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker enabledTabs="['solid']" styleAction="'${styleName}'" action="'customAction'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
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']").click();
// Applied twice for hover (preview) and click (commit).
expect.verifySteps(["load", "apply rgb(255, 0, 0)", "load", "apply rgb(255, 0, 0)"]);
});
test("apply custom async action", async () => {
const def = new Deferred();
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue() {
return "";
}
async apply({ editingElement }) {
await def;
editingElement.classList.add("applied");
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderColorPicker action="'customAction'" enabledTabs="['solid']"/>
<BuilderButton classAction="'test'" preview="false"/>
`,
});
const { getEditor } = await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
const editor = getEditor();
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']").click();
await contains("[data-class-action='test']").click();
expect(":iframe .test-options-target").not.toHaveClass("test");
expect(":iframe .test-options-target").not.toHaveClass("applied");
def.resolve();
await tick();
expect(":iframe .test-options-target").toHaveClass("test");
expect(":iframe .test-options-target").toHaveClass("applied");
undo(editor);
expect(":iframe .test-options-target").not.toHaveClass("test");
expect(":iframe .test-options-target").toHaveClass("applied");
undo(editor);
expect(":iframe .test-options-target").not.toHaveClass("test");
expect(":iframe .test-options-target").not.toHaveClass("applied");
});
test("should revert preview on escape", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker enabledTabs="['solid']" styleAction="'background-color'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(":iframe .test-options-target").toHaveStyle({ "background-color": "rgba(0, 0, 0, 0)" });
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await hover(".o-overlay-item [data-color='#FF0000']");
expect(":iframe .test-options-target").toHaveStyle({ "background-color": "rgb(255, 0, 0)" });
await press("escape");
expect(":iframe .test-options-target").toHaveStyle({ "background-color": "rgba(0, 0, 0, 0)" });
});
test("should mark default color as selected when it is selected", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker enabledTabs="['custom']" styleAction="'background-color'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await contains(".o-overlay-item [data-color='900']").click();
expect(":iframe .test-options-target").toHaveClass("bg-900");
await contains(".we-bg-options-container .o_we_color_preview").click();
expect(".o-overlay-item [data-color='900']").toHaveClass("selected");
});
test("should apply transparent color if no color is defined", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
expect.step("getValue");
return editingElement.dataset.color;
}
apply({ editingElement, value }) {
expect.step("apply");
editingElement.dataset.color = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker action="'customAction'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await contains(".o-overlay-item button:contains('Custom')").click();
expect.verifySteps(["getValue"]);
expect(".o-overlay-item .o_hex_input").toHaveValue("#FFFFFF00");
expect(":iframe .test-options-target").not.toHaveAttribute("data-color");
await contains(".o-overlay-item .o_color_pick_area").click({ top: "50%", left: "50%" });
expect(".o-overlay-item .o_hex_input").not.toHaveValue("#FFFFFF00");
expect(":iframe .test-options-target").toHaveAttribute("data-color");
expect.verifySteps(["apply"]); // Preview
await contains(".options-container-header").click(); // Close the popover by clicking outside.
expect.verifySteps(["apply", "getValue"]); // Commit
});
describe("Custom colorpicker: preview and commit", () => {
before(() => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.dataset.color;
}
apply({ editingElement, value }) {
expect.step("apply");
editingElement.dataset.color = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderColorPicker action="'customAction'"/>`,
});
});
/****************************************
*************** POINTER ***************
***************************************/
test("should preview while modifying custom pickers with mouse", async () => {
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await contains(".o-overlay-item button:contains('Custom')").click();
expect(":iframe .test-options-target").not.toHaveAttribute("data-color");
await contains(".o-overlay-item .o_color_pick_area").click({ top: "50%", left: "50%" });
expect(":iframe .test-options-target").toHaveAttribute("data-color");
expect.verifySteps(["apply"]); // Only once: preview
await contains(".o-overlay-item .o_color_slider").click({ top: "50%", left: "50%" });
expect.verifySteps(["apply"]); // Only once: preview
await contains(".o-overlay-item .o_opacity_slider").click({ top: "50%", left: "50%" });
expect.verifySteps(["apply"]); // Only once: preview
expect(":iframe .test-options-target").toHaveAttribute("data-color");
// Make sure it was just a preview: close with escape
await press("escape"); // Undo preview
await animationFrame();
expect(":iframe .test-options-target").not.toHaveAttribute("data-color");
});
test("should commit when popover is closed by clicking outside", async () => {
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await contains(".o-overlay-item button:contains('Custom')").click();
expect(":iframe .test-options-target").not.toHaveAttribute("data-color");
await contains(".o-overlay-item .o_color_pick_area").click({ top: "50%", left: "50%" });
expect(":iframe .test-options-target").toHaveAttribute("data-color");
expect.verifySteps(["apply"]); // Only once: preview
await contains(".options-container-header").click(); // Close the popover by clicking outside.
expect.verifySteps(["apply"]); // Commit
expect(":iframe .test-options-target").toHaveAttribute("data-color");
});
/****************************************
*************** KEYBOARD ***************
***************************************/
const prepareKeyboardSetup = async () => {
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains(".we-bg-options-container .o_we_color_preview").click();
await waitFor(".o-overlay-item button:contains('Custom')");
await press("Tab");
await press("Enter");
await waitFor(".o-overlay-item .o_color_pick_area");
expect(":iframe .test-options-target").not.toHaveAttribute("data-color");
// Press shift+tab until it gets to the colorpicker area.
for (let i = 0; i < 5; i++) {
await press("Tab", { shiftKey: true });
}
expect(".o-overlay-item .o_color_pick_area .o_picker_pointer").toBeFocused();
};
test("should preview while modifying custom pickers with keyboard", async () => {
await prepareKeyboardSetup();
await press("ArrowRight");
await animationFrame();
expect.verifySteps(["apply"]); // Preview
await press("ArrowDown");
await animationFrame();
expect.verifySteps(["apply"]); // Preview
expect(":iframe .test-options-target").toHaveAttribute("data-color");
await press("Tab"); // focus color slider
expect(".o-overlay-item .o_color_slider .o_slider_pointer").toBeFocused();
await press("ArrowUp");
await animationFrame();
expect.verifySteps(["apply"]); // Preview
await press("Tab"); // focus opacity slider
expect(".o-overlay-item .o_opacity_slider .o_opacity_pointer").toBeFocused();
await press("ArrowDown");
await animationFrame();
expect.verifySteps(["apply"]); // Preview
// Make sure it was just a preview: close with escape
await press("escape");
await animationFrame();
expect(":iframe .test-options-target").not.toHaveAttribute("data-color");
});
test("should commit when validating with 'Enter'", async () => {
await prepareKeyboardSetup();
await press("ArrowRight");
await animationFrame();
expect.verifySteps(["apply"]); // Preview
await press("ArrowDown");
await animationFrame();
expect.verifySteps(["apply"]); // Preview
expect(":iframe .test-options-target").toHaveAttribute("data-color");
await press("Enter"); // Validate
await animationFrame();
expect.verifySteps(["apply"]); // Commit
await press("escape");
await animationFrame();
expect(":iframe .test-options-target").toHaveAttribute("data-color");
});
});

View file

@ -0,0 +1,35 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { expect, test, describe } from "@odoo/hoot";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("should pass the context", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ params: { mainParam: testParam }, value }) {
expect.step(`customAction ${testParam} ${value}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderContext action="'customAction'" actionParam="'myParam'">
<BuilderButton actionValue="'myValue'">MyAction</BuilderButton>
</BuilderContext>
`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container button").click();
// The function `apply` should be called twice (on hover (for preview), then, on click).
expect.verifySteps(["customAction myParam myValue", "customAction myParam myValue"]);
});

View file

@ -0,0 +1,192 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { expect, test, describe } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
const { DateTime } = luxon;
describe.current.tags("desktop");
const TIME_TOLERANCE = 2;
// To avoid indeterminism in tests, we use a tolerance
function isExpectedDateTime({
dateString,
expectedDateTime = DateTime.now(),
tolerance = TIME_TOLERANCE,
}) {
const actualTimestamp = DateTime.fromFormat(dateString, "MM/dd/yyyy HH:mm:ss").toUnixInteger();
const expectedTimestamp = expectedDateTime.toUnixInteger();
const difference = Math.abs(actualTimestamp - expectedTimestamp);
return difference <= tolerance;
}
test("opens DateTimePicker on focus, closes on blur", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container input").click();
expect(".o_datetime_picker").toBeDisplayed();
await contains(".options-container").click();
expect(".o_datetime_picker").not.toHaveCount();
});
test("defaults to now if undefined", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'" acceptEmptyDate="false"/>`,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderCheckbox classAction="'checkbox-action'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
let dateString = queryOne(".we-bg-options-container input.o-hb-input-base").value;
expect(isExpectedDateTime({ dateString })).toBe(true);
await contains(".we-bg-options-container input.form-check-input").click();
dateString = queryOne(".we-bg-options-container input.o-hb-input-base").value;
expect(isExpectedDateTime({ dateString })).toBe(true);
});
test("defaults to last one when invalid date provided", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target" data-date="1554219400">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".we-bg-options-container input").toHaveValue("04/02/2019 16:36:40");
await contains(".we-bg-options-container input").edit("INVALID DATE");
expect(".we-bg-options-container input").toHaveValue("04/02/2019 16:36:40");
await contains(".we-bg-options-container input").edit("04/01/2019 10:00:00");
expect(".we-bg-options-container input").toHaveValue("04/01/2019 10:00:00");
await contains(".we-bg-options-container input").edit("INVALID DATE");
expect(".we-bg-options-container input").toHaveValue("04/01/2019 10:00:00");
});
test("defaults to last one when invalid date provided (date)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker type="'date'" dataAttributeAction="'date'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target" data-date="1554219400">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".we-bg-options-container input").toHaveValue("04/02/2019");
await contains(".we-bg-options-container input").edit("INVALID DATE");
expect(".we-bg-options-container input").toHaveValue("04/02/2019");
await contains(".we-bg-options-container input").edit("04/01/2019 10:00:00");
expect(".we-bg-options-container input").toHaveValue("04/01/2019");
await contains(".we-bg-options-container input").edit("INVALID DATE");
expect(".we-bg-options-container input").toHaveValue("04/01/2019");
});
test("defaults to now when no date is selected", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'" acceptEmptyDate="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container input").edit("04/01/2019 10:00:00");
expect(".we-bg-options-container input").toHaveValue("04/01/2019 10:00:00");
await contains(".we-bg-options-container input").edit("");
const dateString = queryOne(".we-bg-options-container input").value;
expect(isExpectedDateTime({ dateString })).toBe(true);
});
test("defaults to now when clicking on clear button", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'" acceptEmptyDate="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container input").edit("04/01/2019 10:00:00");
expect(".we-bg-options-container input").toHaveValue("04/01/2019 10:00:00");
for (let i = 0; i < 3; i++) {
await contains(".we-bg-options-container input").click();
await contains(".o_datetime_buttons button .fa-eraser").click();
await contains(".options-container").click();
const dateString = queryOne(".we-bg-options-container input").value;
expect(isExpectedDateTime({ dateString })).toBe(true);
}
});
test("selects a date and properly applies it", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'" acceptEmptyDate="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container input").click();
await contains(".o_date_item_cell.o_today + .o_date_item_cell").click();
await contains(".options-container").click();
const dateString = queryOne(".we-bg-options-container input").value;
const expectedDateTime = DateTime.now().plus({ days: 1 });
expect(isExpectedDateTime({ dateString, expectedDateTime })).toBe(true);
const expectedDateTimestamp = expectedDateTime.toUnixInteger();
const dateTimestamp = parseFloat(queryOne(":iframe .test-options-target").dataset.date);
expect(Math.abs(expectedDateTimestamp - dateTimestamp)).toBeLessThan(TIME_TOLERANCE);
});
test("selects a date and synchronize the input field, while still in preview", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'" acceptEmptyDate="false"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container input").click();
await contains(".o_date_item_cell.o_today + .o_date_item_cell").click();
const dateString = queryOne(".we-bg-options-container input").value;
const expectedDateTime = DateTime.now().plus({ days: 1 });
expect(isExpectedDateTime({ dateString, expectedDateTime })).toBe(true);
const expectedDateTimestamp = expectedDateTime.toUnixInteger();
const dateTimestamp = parseFloat(queryOne(":iframe .test-options-target").dataset.date);
expect(Math.abs(expectedDateTimestamp - dateTimestamp)).toBeLessThan(TIME_TOLERANCE);
});
test("edit a date with the datetime picker should correctly apply the mutation", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderDateTimePicker dataAttributeAction="'date'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-date="1554219400">b</div>
<div class="another-target">c</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container input").click();
await contains(".o_date_item_cell:contains('9')").click();
expect(".we-bg-options-container input").toHaveValue("04/09/2019 16:36:40");
await contains(".o_datetime_buttons .btn:contains('apply')").click();
expect(".we-bg-options-container input").toHaveValue("04/09/2019 16:36:40");
expect(":iframe .test-options-target").toHaveAttribute("data-date", "1554824200");
// refresh the Edit tab
await contains(":iframe .another-target").click();
await contains(":iframe .test-options-target").click();
expect(".we-bg-options-container input").toHaveValue("04/09/2019 16:36:40");
expect(":iframe .test-options-target").toHaveAttribute("data-date", "1554824200");
});

View file

@ -0,0 +1,332 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { BuilderList } from "@html_builder/core/building_blocks/builder_list";
import { expect, test, describe } from "@odoo/hoot";
import { Component, onError, xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
const defaultValue = { value: "75", title: "default title" };
const defaultValueStr = JSON.stringify(defaultValue).replaceAll('"', "'");
function defaultValueWithIds(ids) {
return ids.map((id) => ({
...defaultValue,
_id: id.toString(),
}));
}
test("writes a list of numbers to a data attribute", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderList
dataAttributeAction="'list'"
itemShape="{ value: 'number', title: 'text' }"
default="${defaultValueStr}"
/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
await contains(".we-bg-options-container input[type=number]").edit("35");
await contains(".we-bg-options-container input[type=text]").edit("a thing");
await contains(".we-bg-options-container .builder_list_add_item").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify([
{
value: "35",
title: "a thing",
_id: "0",
id: "a thing",
},
...defaultValueWithIds([1, 2]),
])
);
});
test("supports arbitrary number of text and number inputs on entries", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderList
dataAttributeAction="'list'"
itemShape="{ a: 'number', b: 'text', c: 'text', d: 'number' }"
default="{ a: '4', b: '3', c: '2', d: '1' }"
/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
expect(".we-bg-options-container input[type=number]").toHaveCount(2);
expect(".we-bg-options-container input[type=text]").toHaveCount(2);
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify([
{
a: "4",
b: "3",
c: "2",
d: "1",
_id: "0",
},
])
);
});
test("delete an item", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderList
dataAttributeAction="'list'"
itemShape="{ value: 'number', title: 'text' }"
default="${defaultValueStr}"
/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify(defaultValueWithIds([0]))
);
await contains(".we-bg-options-container .builder_list_remove_item").click();
expect(":iframe .test-options-target").toHaveAttribute("data-list", JSON.stringify([]));
});
test("reorder items", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderList
dataAttributeAction="'list'"
itemShape="{ value: 'number', title: 'text' }"
default="${defaultValueStr}"
/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
function expectOrder(ids) {
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify(defaultValueWithIds(ids))
);
}
expectOrder([0, 1, 2]);
const rowSelector = (id) => `.we-bg-options-container .o_row_draggable[data-id="${id}"]`;
const rowHandleSelector = (id) => `${rowSelector(id)} .o_handle_cell`;
await contains(rowHandleSelector(0)).dragAndDrop(rowSelector(1));
expectOrder([1, 0, 2]);
await contains(rowHandleSelector(1)).dragAndDrop(rowSelector(2));
expectOrder([0, 2, 1]);
await contains(rowHandleSelector(1)).dragAndDrop(rowSelector(0));
expectOrder([1, 0, 2]);
await contains(rowHandleSelector(2)).dragAndDrop(rowSelector(0));
expectOrder([1, 2, 0]);
await contains(rowHandleSelector(2)).dragAndDrop(rowSelector(0));
expectOrder([1, 0, 2]);
await contains(rowHandleSelector(0)).dragAndDrop(rowSelector(1));
expectOrder([0, 1, 2]);
});
async function testBuilderListFaultyProps(template) {
class Test extends Component {
static template = xml`${template}`;
static components = { BuilderList };
static props = ["*"];
setup() {
onError(() => {
expect.step("threw");
});
}
}
addBuilderOption({
selector: ".test-options-target",
Component: Test,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect.verifySteps(["threw"]);
}
test("throws error on empty shape", async () => {
await testBuilderListFaultyProps(`
<BuilderList
dataAttributeAction="'list'"
itemShape="{}"
default="{}"
/>
`);
});
test("throws error on wrong item shape types", async () => {
await testBuilderListFaultyProps(`
<BuilderList
dataAttributeAction="'list'"
itemShape="{ a: 'doesnotexist' }"
default="{ a: '1' }"
/>
`);
});
test("throws error on wrong properties default value", async () => {
await testBuilderListFaultyProps(`
<BuilderList
dataAttributeAction="'list'"
itemShape="{ a: 'number' }"
default="{ b: '1' }"
/>
`);
});
test("throws error on missing default value with a custom itemShape", async () => {
await testBuilderListFaultyProps(`
<BuilderList
dataAttributeAction="'list'"
itemShape="{ a: 'number', b: 'text' }"
/>
`);
});
test("throws error if itemShape contains reserved key '_id'", async () => {
await testBuilderListFaultyProps(`
<BuilderList
dataAttributeAction="'list'"
itemShape="{ _id: 'number' }"
default="{ _id: '1' }"
/>
`);
});
test("hides hiddenProperties from options", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderList
dataAttributeAction="'list'"
itemShape="{ a: 'number', b: 'text', c: 'number', d: 'text' }"
default="{ a: '4', b: 'three', c: '2', d: 'one' }"
hiddenProperties="['b', 'c']"
/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container .builder_list_add_item").click();
expect(".we-bg-options-container input[type=number]").toHaveCount(1);
expect(".we-bg-options-container input[type=text]").toHaveCount(1);
await contains(".we-bg-options-container input[type=number]").edit("35");
await contains(".we-bg-options-container input[type=text]").edit("a thing");
await contains(".we-bg-options-container .builder_list_add_item").click();
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify([
{
a: "35",
b: "three",
c: "2",
d: "a thing",
_id: "0",
id: "a thing",
},
{
a: "4",
b: "three",
c: "2",
d: "one",
_id: "1",
},
])
);
});
test("do not lose id when adjusting 'selected'", async () => {
class Test extends Component {
static template = xml`
<BuilderList
dataAttributeAction="'list'"
addItemTitle="'Add'"
itemShape="{ display_name: 'text', selected: 'boolean' }"
default="{ display_name: 'Extra', selected: false }"
records="availableRecords" />`;
static components = { BuilderList };
static props = ["*"];
setup() {
this.availableRecords = JSON.stringify([
{ id: 1, display_name: "A" },
{ id: 2, display_name: "B" },
]);
}
}
addBuilderOption({
selector: ".test-options-target",
Component: Test,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".we-bg-options-container .bl-dropdown-toggle").click();
await contains(".o_popover .o-hb-select-dropdown-item").click();
await contains(".we-bg-options-container .bl-dropdown-toggle").click();
await contains(".o_popover .o-hb-select-dropdown-item").click();
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify([
{
id: 1,
display_name: "A",
_id: "0",
},
{
id: 2,
display_name: "B",
_id: "1",
},
])
);
await contains(".we-bg-options-container .o-hb-checkbox input").click();
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify([
{
id: 1,
display_name: "A",
_id: "0",
selected: true,
},
{
id: 2,
display_name: "B",
_id: "1",
},
])
);
await contains(".we-bg-options-container .o-hb-checkbox input").click();
expect(":iframe .test-options-target").toHaveAttribute(
"data-list",
JSON.stringify([
{
id: 1,
display_name: "A",
_id: "0",
selected: false,
},
{
id: 2,
display_name: "B",
_id: "1",
},
])
);
});

View file

@ -0,0 +1,125 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { xml } from "@odoo/owl";
import { contains, defineModels, fields, models, onRpc } from "@web/../tests/web_test_helpers";
import { delay } from "@web/core/utils/concurrency";
class Test extends models.Model {
_name = "test";
_records = [
{ id: 1, name: "First" },
{ id: 2, name: "Second" },
{ id: 3, name: "Third" },
];
name = fields.Char();
}
describe.current.tags("desktop");
defineModels([Test]);
test("many2many: find tag, select tag, unselect tag", async () => {
onRpc("test", "name_search", () => [
[1, "First"],
[2, "Second"],
[3, "Third"],
]);
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderMany2Many dataAttributeAction="'test'" model="'test'" limit="10"/>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="test-options-target">b</div>`
);
const editableContent = getEditableContent();
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
expect("table tr").toHaveCount(0);
expect(editableContent).toHaveInnerHTML(`<div class="test-options-target o-paragraph">b</div>`);
await contains(".btn.o-dropdown").click();
expect("input").toHaveCount(1);
await contains("input").click();
await delay(300); // debounce
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(3);
await contains("span.o-dropdown-item").click();
expect(editableContent).toHaveInnerHTML(
`<div class="test-options-target o-paragraph" data-test="[{&quot;id&quot;:1,&quot;display_name&quot;:&quot;First&quot;,&quot;name&quot;:&quot;First&quot;}]">b</div>`
);
expect("table tr").toHaveCount(1);
await contains(".btn.o-dropdown").click();
await delay(300); // debounce
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(2);
await contains("span.o-dropdown-item").click();
expect(editableContent).toHaveInnerHTML(
`<div class="test-options-target o-paragraph" data-test="[{&quot;id&quot;:1,&quot;display_name&quot;:&quot;First&quot;,&quot;name&quot;:&quot;First&quot;},{&quot;id&quot;:2,&quot;display_name&quot;:&quot;Second&quot;,&quot;name&quot;:&quot;Second&quot;}]">b</div>`
);
expect("table tr").toHaveCount(2);
await contains("button.fa-minus").click();
expect(editableContent).toHaveInnerHTML(
`<div class="test-options-target o-paragraph" data-test="[{&quot;id&quot;:2,&quot;display_name&quot;:&quot;Second&quot;,&quot;name&quot;:&quot;Second&quot;}]">b</div>`
);
expect("table tr").toHaveCount(1);
expect("table input").toHaveValue("Second");
});
test("many2many: async load", async () => {
const defWillLoad = new Deferred();
const defDidApply = new Deferred();
onRpc("test", "name_search", () => [
[1, "First"],
[2, "Second"],
[3, "Third"],
]);
addBuilderAction({
testAction: class extends BuilderAction {
static id = "testAction";
async load({ value }) {
expect.step("load");
await defWillLoad;
return value;
}
apply({ editingElement, value }) {
editingElement.dataset.test = value;
defDidApply.resolve();
}
getValue({ editingElement }) {
return editingElement.dataset.test;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderMany2Many action="'testAction'" model="'test'" limit="10"/>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="test-options-target">b</div>`
);
const editableContent = getEditableContent();
await contains(":iframe .test-options-target").click();
await contains(".btn.o-dropdown").click();
expect("input").toHaveCount(1);
await contains("input").click();
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(3);
await contains("span.o-dropdown-item").click();
expect.verifySteps(["load"]);
expect(editableContent).toHaveInnerHTML(`<div class="test-options-target o-paragraph">b</div>`);
defWillLoad.resolve();
await defDidApply;
expect(editableContent).toHaveInnerHTML(
`<div class="test-options-target o-paragraph" data-test="[{&quot;id&quot;:1,&quot;display_name&quot;:&quot;First&quot;,&quot;name&quot;:&quot;First&quot;}]">b</div>`
);
});

View file

@ -0,0 +1,130 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { BaseOptionComponent, useGetItemValue } from "@html_builder/core/utils";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { xml } from "@odoo/owl";
import { contains, defineModels, fields, models, onRpc } from "@web/../tests/web_test_helpers";
class Test extends models.Model {
_name = "test";
_records = [
{ id: 1, name: "First" },
{ id: 2, name: "Second" },
{ id: 3, name: "Third" },
];
name = fields.Char();
}
describe.current.tags("desktop");
defineModels([Test]);
test("many2one: async load", async () => {
const defWillLoad = new Deferred();
const defDidApply = new Deferred();
onRpc("test", "name_search", () => [
[1, "First"],
[2, "Second"],
[3, "Third"],
]);
addBuilderAction({
testAction: class extends BuilderAction {
static id = "testAction";
setup() {
this.preview = false;
}
async load({ value }) {
expect.step("load");
await defWillLoad;
return value;
}
apply({ editingElement, value }) {
editingElement.dataset.test = value;
defDidApply.resolve();
}
getValue({ editingElement }) {
return editingElement.dataset.test;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderMany2One action="'testAction'" model="'test'" limit="10"/>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="test-options-target">b</div>`
);
const editableContent = getEditableContent();
await contains(":iframe .test-options-target").click();
await contains(".btn.o-dropdown").click();
expect("input").toHaveCount(1);
await contains("input").click();
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(3);
await contains("span.o-dropdown-item").click();
expect.verifySteps(["load"]);
expect(editableContent).toHaveInnerHTML(`<div class="test-options-target o-paragraph">b</div>`);
defWillLoad.resolve();
await defDidApply;
expect(editableContent).toHaveInnerHTML(
`<div class="test-options-target o-paragraph" data-test="{&quot;id&quot;:1,&quot;display_name&quot;:&quot;First&quot;,&quot;name&quot;:&quot;First&quot;}">b</div>`
);
});
test("dependency definition should not be outdated", async () => {
onRpc("test", "name_search", () => [
[1, "First"],
[2, "Second"],
[3, "Third"],
]);
addBuilderAction({
testAction: class extends BuilderAction {
static id = "testAction";
apply({ editingElement, value }) {
editingElement.dataset.test = value;
}
getValue({ editingElement }) {
return editingElement.dataset.test;
}
},
});
class TestMany2One extends BaseOptionComponent {
static template = xml`
<BuilderMany2One action="'testAction'" model="'test'" limit="10" id="'test_many2one_opt'"/>
<BuilderRow t-if="getItemValueJSON('test_many2one_opt')?.id === 2"><span>Dependant</span></BuilderRow>
`;
setup() {
super.setup();
this.getItemValue = useGetItemValue();
}
getItemValueJSON(id) {
const value = this.getItemValue(id);
return value && JSON.parse(value);
}
}
addBuilderOption({
selector: ".test-options-target",
Component: TestMany2One,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await contains(".btn.o-dropdown").click();
await contains("span.o-dropdown-item:contains(First)").click();
expect("span:contains(Dependant)").toHaveCount(0);
await contains(".btn.o-dropdown").click();
await contains("span.o-dropdown-item:contains(Second)").click();
expect("span:contains(Dependant)").toHaveCount(1);
await contains(".btn.o-dropdown").click();
await contains("span.o-dropdown-item:contains(Third)").click();
expect("span:contains(Dependant)").toHaveCount(0);
});

View file

@ -0,0 +1,952 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { describe, expect, test } from "@odoo/hoot";
import {
advanceTime,
animationFrame,
clear,
click,
fill,
freezeTime,
queryFirst,
} from "@odoo/hoot-dom";
import { Deferred } from "@odoo/hoot-mock";
import { xml } from "@odoo/owl";
import { contains, defineModels, models } from "@web/../tests/web_test_helpers";
import { delay } from "@web/core/utils/concurrency";
describe.current.tags("desktop");
test("should get the initial value of the input", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ params }) {
expect.step(`customAction ${params}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
const input = queryFirst(".options-container input");
expect(input).toHaveValue("10");
});
test("hide/display base on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderNumberInput applyTo="'.my-custom-class'" action="'customAction'"/>`,
});
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue() {
return "10";
}
},
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="parent-target"><div class="child-target">b</div></div>`
);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target o-paragraph">b</div></div>`
);
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
expect("[data-action-id='customAction']").toHaveCount(0);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target o-paragraph my-custom-class">b</div></div>`
);
expect("[data-class-action='my-custom-class']").toHaveClass("active");
expect("[data-action-id='customAction']").toHaveCount(1);
expect("[data-action-id='customAction'] input").toHaveValue("10");
});
test("input with classAction and styleAction", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput classAction="'testAction'" styleAction="'--custom-property'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("2");
expect(":iframe .test-options-target").toHaveStyle({
"--custom-property": "2",
});
});
test("input kept on async action", async () => {
const def = new Deferred();
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.dataset.test;
}
async apply({ editingElement, value }) {
await def;
editingElement.dataset.test = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target" data-test="1">Hello</div>`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("2");
await contains(".options-container input").fill(3, { confirm: false });
def.resolve();
await animationFrame();
expect(".options-container input").toHaveValue("23");
});
test("input should remove invalid char", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
setup() {
this.preview = false;
}
getValue({ editingElement }) {
return editingElement.dataset.test;
}
async apply({ editingElement, value }) {
editingElement.dataset.test = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
addBuilderOption({
selector: ".test-options-target-composable",
template: xml`<BuilderNumberInput action="'customAction'" composable="true"/>`,
});
await setupHTMLBuilder(
`<div class="test-options-target" data-test="1">Hello</div><div class="test-options-target-composable" data-test="2">World</div>`
);
// Single
await contains(":iframe .test-options-target").click();
await contains(".options-container:first input").edit("-1-2-");
await animationFrame();
expect(".options-container:first input").toHaveValue("-12");
await contains(".options-container:first input").edit("3-4-5");
await animationFrame();
expect(".options-container:first input").toHaveValue("345");
await contains(".options-container:first input").edit(" .$a?,6.b$?,7.$?c, ");
await animationFrame();
expect(".options-container:first input").toHaveValue("0.67");
// Composable
await contains(":iframe .test-options-target-composable").click();
await contains(".options-container:last input").edit("-12 12 -12 12");
await animationFrame();
expect(".options-container:last input").toHaveValue("-12 12 -12 12");
await contains(".options-container:last input").edit("3?/4.5 34,/?5");
await animationFrame();
expect(".options-container:last input").toHaveValue("34.5 34.5");
await contains(".options-container:last input").edit(" 6bc7 6//7 6$a7 6d7 ");
await animationFrame();
expect(".options-container:last input").toHaveValue("67 67 67 67");
});
describe("default value", () => {
test("should use the default value when there is no value onChange", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ value }) {
expect.step(`customAction ${value}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" default="20"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
const input = queryFirst(".options-container input");
input.value = "";
input.dispatchEvent(new Event("input"));
await delay();
input.dispatchEvent(new Event("change"));
await delay();
expect.verifySteps(["customAction 20", "customAction 20"]);
expect(input).toHaveValue("20");
});
test("clear BuilderNumberInput without default value", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" />`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await click("[data-action-id='customAction'] input");
expect("[data-action-id='customAction'] input").toHaveValue("10");
await clear();
expect("[data-action-id='customAction'] input").toHaveValue("");
expect(":iframe .test-options-target").toHaveInnerHTML("0"); //Check that default value is used during preview
await click(".options-container");
expect("[data-action-id='customAction'] input").toHaveValue("0");
expect(":iframe .test-options-target").toHaveInnerHTML("0");
});
test("clear BuilderNumberInput with default value", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" default="1"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await click("[data-action-id='customAction'] input");
expect("[data-action-id='customAction'] input").toHaveValue("10");
await clear();
await click(".options-container");
expect("[data-action-id='customAction'] input").toHaveValue("1");
expect(":iframe .test-options-target").toHaveInnerHTML("1");
});
test("clear BuilderNumberInput with null default value", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerText;
}
apply({ editingElement, value }) {
editingElement.innerText = value;
if (value === null) {
editingElement.innerText = "10";
}
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" default="null"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await click("[data-action-id='customAction'] input");
expect("[data-action-id='customAction'] input").toHaveValue(10);
await contains("[data-action-id='customAction'] input").edit("5");
expect(":iframe .test-options-target").toHaveInnerHTML("5");
await clear();
await click(".options-container");
await animationFrame();
expect(":iframe .test-options-target").toHaveInnerHTML("10");
expect("[data-action-id='customAction'] input").toHaveValue("10");
});
});
describe("operations", () => {
test("should preview changes", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click(".options-container input");
await fill("2");
expect.verifySteps(["customAction 102"]);
expect(":iframe .test-options-target").toHaveInnerHTML("102");
expect(".o-snippets-top-actions .fa-undo").not.toBeEnabled();
expect(".o-snippets-top-actions .fa-repeat").not.toBeEnabled();
});
test("should commit changes", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click(".options-container input");
await fill("2");
expect.verifySteps(["customAction 102"]);
expect(":iframe .test-options-target").toHaveInnerHTML("102");
await click(document.body);
await animationFrame();
expect.verifySteps(["customAction 102"]);
expect(".o-snippets-top-actions .fa-undo").toBeEnabled();
expect(".o-snippets-top-actions .fa-repeat").not.toBeEnabled();
});
test("should commit changes after an undo", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await click(".options-container input");
await fill(2);
expect(":iframe .test-options-target").toHaveInnerHTML("102");
await click(document.body);
expect.verifySteps(["customAction 102", "customAction 102"]);
await animationFrame();
click(".o-snippets-top-actions .fa-undo");
await animationFrame();
expect(":iframe .test-options-target").toHaveInnerHTML("10");
await click(".options-container input");
await fill("2");
expect(":iframe .test-options-target").toHaveInnerHTML("102");
await click(document.body);
expect.verifySteps(["customAction 102", "customAction 102"]);
});
test("should not commit on input if no preview", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" preview="false"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await click(".options-container input");
await fill(2);
expect(":iframe .test-options-target").toHaveInnerHTML("10");
await click(document.body);
expect.verifySteps(["customAction 102"]);
expect(":iframe .test-options-target").toHaveInnerHTML("102");
});
});
describe("keyboard triggers", () => {
test("input should step up or down from by the step prop", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" step="2"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
// simulate arrow up
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime();
expect(":iframe .test-options-target").toHaveInnerHTML("12");
// simulate arrow down
await contains(".options-container input").keyDown("ArrowDown");
await advanceTime();
expect(":iframe .test-options-target").toHaveInnerHTML("10");
expect.verifySteps(["customAction 12", "customAction 10"]);
});
test("multi values: apply change on each value with up or down", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" composable="true"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10 4 0</div>
`);
await contains(":iframe .test-options-target").click();
// simulate arrow up
await contains(".options-container input").focus();
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime();
expect(":iframe .test-options-target").toHaveInnerHTML("11 5 1");
// simulate arrow down
await contains(".options-container input").keyDown("ArrowDown");
await advanceTime();
expect(":iframe .test-options-target").toHaveInnerHTML("10 4 0");
expect.verifySteps(["customAction 11 5 1", "customAction 10 4 0"]);
});
test("up on empty BuilderNumberInput gives 1", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.dataset.number;
}
apply({ editingElement, value }) {
editingElement.dataset.number = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" />`,
});
await setupHTMLBuilder(`<div class="test-options-target">Non empty div.</div>`);
await contains(":iframe .test-options-target").click();
await click("[data-action-id='customAction'] input");
await clear();
expect("[data-action-id='customAction'] input").toHaveValue("");
await contains("[data-action-id='customAction'] input").keyDown("ArrowUp");
expect("[data-action-id='customAction'] input").toHaveValue("1");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "1");
});
test("down on empty BuilderNumberInput gives -1", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.dataset.number;
}
apply({ editingElement, value }) {
editingElement.dataset.number = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" />`,
});
await setupHTMLBuilder(`<div class="test-options-target">Non empty div.</div>`);
await contains(":iframe .test-options-target").click();
await click("[data-action-id='customAction'] input");
await clear();
expect("[data-action-id='customAction'] input").toHaveValue("");
await contains("[data-action-id='customAction'] input").keyDown("ArrowDown");
await animationFrame();
expect("[data-action-id='customAction'] input").toHaveValue("-1");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "-1");
});
test("apply preview on keydown and debounce commit operation", async () => {
freezeTime();
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").focus();
// Simulate a single keydown hold down for a while.
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime(500); // Default browser delay between 1st & 2nd keydown.
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime(50);
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime(50);
expect(":iframe .test-options-target").toHaveInnerHTML("13");
// 3 previews
expect.verifySteps(["customAction 11", "customAction 12", "customAction 13"]);
await advanceTime(560); // Debounce = 550
// 1 commit
expect.verifySteps(["customAction 13"]);
});
});
describe("unit & saveUnit", () => {
test("should handle unit", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" unit="'px'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">5px</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click(".options-container input");
const input = queryFirst(".options-container input");
expect(input).toHaveValue("5");
await fill(1);
expect.verifySteps(["customAction 51px"]);
expect(":iframe .test-options-target").toHaveInnerHTML("51px");
});
test("should handle saveUnit", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" unit="'s'" saveUnit="'ms'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">5000ms</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click(".options-container input");
const input = queryFirst(".options-container input");
expect(input).toHaveValue("5");
await fill("7");
expect.verifySteps(["customAction 57000ms"]);
expect(":iframe .test-options-target").toHaveInnerHTML("57000ms");
});
test("should handle saveUnit even without explicit unit", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" unit="'s'" saveUnit="'ms'"/>`,
});
// note that 5000 has no unit of measure
await setupHTMLBuilder(`
<div class="test-options-target">5000</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click(".options-container input");
const input = queryFirst(".options-container input");
expect(input).toHaveValue("5");
});
test("should handle empty saveUnit", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" unit="'px'" saveUnit="''"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">5</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click(".options-container input");
const input = queryFirst(".options-container input");
expect(input).toHaveValue("5");
await fill(1);
expect.verifySteps(["customAction 51"]);
expect(":iframe .test-options-target").toHaveInnerHTML("51");
});
test("should handle savedUnit", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerText;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerText = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" unit="'s'" saveUnit="'ms'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">5s</div>
`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await click(".options-container input");
const input = queryFirst(".options-container input");
expect(input).toHaveValue("5");
await fill("7");
expect.verifySteps(["customAction 57000ms"]);
expect(":iframe .test-options-target").toHaveInnerHTML("57000ms");
});
});
describe("sanitized values", () => {
test("don't allow multi values by default", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("33 4 0", { instantly: true });
expect(".options-container input").toHaveValue("33");
expect(":iframe .test-options-target").toHaveInnerHTML("33");
});
test("use min when the given value is smaller", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ value }) {
expect.step(`customAction ${value}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" min="0"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("-1", { instantly: true });
expect.verifySteps(["customAction 0", "customAction 0"]); // input, change
expect(".options-container input").toHaveValue("0");
});
test("use max when the given value is bigger", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ value }) {
expect.step(`customAction ${value}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" max="10"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">3</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("11", { instantly: true });
await animationFrame();
expect.verifySteps(["customAction 0", "customAction 10"]); // input, change
expect(".options-container input").toHaveValue("10");
});
test("multi values: trailing space in BuilderNumberInput is ignored", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ value }) {
expect.step(`customAction ${value}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput action="'customAction'" composable="true"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").fill("3 4 5 ", { instantly: true });
expect.verifySteps(["customAction 3 4 5", "customAction 3 4 5"]); // input, change
expect(".options-container input").toHaveValue("3 4 5");
});
test("after input, displayed value is cleaned to match only numbers", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-number="10">Test</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit(" a&$*+>");
expect(".options-container input").toHaveValue("0");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "0");
});
test("after input, displayed value is cleaned to match only numbers (default=null)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'" default="null"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-number="10">Test</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit(" a&$*+>");
expect(".options-container input").toHaveValue("");
expect(":iframe .test-options-target").not.toHaveAttribute("data-number");
});
test("after copy / pasting, displayed value is cleaned to match only numbers", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-number="10">Test</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit(" a&$*-3+>", { instantly: true });
expect(".options-container input").toHaveValue("-3");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "-3");
});
test("accept decimal numbers", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-number="10">Test</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("3.3");
expect(".options-container input").toHaveValue("3.3");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "3.3");
});
test("BuilderNumberInput transforms , into .", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-number="10">Test</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("3,3");
expect(".options-container input").toHaveValue("3.3");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "3.3");
});
test("displays the correct value (no floating point precision error)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'" step="0.1"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-number="10">Test</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("0.2");
expect(".options-container input").toHaveValue("0.2");
// simulate arrow keys
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime();
expect(".options-container input").toHaveValue("0.3");
await contains(".options-container input").keyDown("ArrowDown");
await advanceTime();
expect(".options-container input").toHaveValue("0.2");
});
test("rounds the number to 3 decimals", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target" data-number="10">Test</div>
`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("3.33333333333");
expect(".options-container input").toHaveValue("3.333");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "3.333");
await contains(".options-container input").edit("1.284778323");
expect(".options-container input").toHaveValue("1.285");
expect(":iframe .test-options-target").toHaveAttribute("data-number", "1.285");
});
test("should save font with full precision in rem and display to correct value in px", async () => {
class WebEditorAssets extends models.Model {
_name = "web_editor.assets";
make_scss_customization() {}
}
defineModels([WebEditorAssets]);
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderNumberInput dataAttributeAction="'number'" unit="'px'" saveUnit="'rem'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">Test</div>`);
await contains(":iframe .test-options-target").click();
await contains(".options-container input").edit("19");
expect(".options-container input").toHaveValue("19");
});
});

View file

@ -0,0 +1,145 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { HistoryPlugin } from "@html_editor/core/history_plugin";
import { expect, test, describe } from "@odoo/hoot";
import { advanceTime, animationFrame, click, freezeTime, waitFor } from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { delay } from "@web/core/utils/concurrency";
describe.current.tags("desktop");
test("should commit changes", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.innerHTML;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.innerHTML = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRange action="'customAction'" displayRangeValue="true"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
const input = await waitFor(".options-container input");
input.value = 50;
input.dispatchEvent(new Event("input"));
await delay();
input.dispatchEvent(new Event("change"));
await delay();
expect.verifySteps(["customAction 50", "customAction 50"]);
expect(":iframe .test-options-target").toHaveInnerHTML("50");
await click(document.body);
await animationFrame();
expect(".o-snippets-top-actions .fa-undo").toBeEnabled();
expect(".o-snippets-top-actions .fa-repeat").not.toBeEnabled();
});
test("range input should step up or down with arrow keys", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.textContent;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.textContent = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRange action="'customAction'" step="2" displayRangeValue="true"/>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
// Simulate ArrowUp
await contains(".options-container input").keyDown("ArrowUp");
expect(":iframe .test-options-target").toHaveInnerHTML("12");
// Simulate ArrowRight
await contains(".options-container input").keyDown("ArrowRight");
expect(":iframe .test-options-target").toHaveInnerHTML("14");
// Simulate ArrowDown
await contains(".options-container input").keyDown("ArrowDown");
expect(":iframe .test-options-target").toHaveInnerHTML("12");
// Simulate ArrowLeft
await contains(".options-container input").keyDown("ArrowLeft");
expect(":iframe .test-options-target").toHaveInnerHTML("10");
expect.verifySteps([
"customAction 12",
"customAction 14",
"customAction 12",
"customAction 10",
]);
});
test("keeping an arrow key pressed should commit only once", async () => {
patchWithCleanup(HistoryPlugin.prototype, {
makePreviewableAsyncOperation(...args) {
const res = super.makePreviewableAsyncOperation(...args);
const commit = res.commit;
res.commit = async (...args) => {
expect.step(`commit ${args[0][0].actionValue}`);
commit(...args);
};
return res;
},
});
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue({ editingElement }) {
return editingElement.textContent;
}
apply({ editingElement, value }) {
expect.step(`customAction ${value}`);
editingElement.textContent = value;
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRange action="'customAction'" step="2" displayRangeValue="true"/>`,
});
freezeTime();
await setupHTMLBuilder(`
<div class="test-options-target">10</div>
`);
await contains(":iframe .test-options-target").click();
// Simulate a long press on ArrowUp
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime(500);
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime(50);
await contains(".options-container input").keyDown("ArrowUp");
await advanceTime(50);
await contains(".options-container input").keyDown("ArrowUp");
expect(":iframe .test-options-target").toHaveInnerHTML("18");
expect.verifySteps([
"customAction 12",
"customAction 14",
"customAction 16",
"customAction 18",
]);
await advanceTime(550);
expect.verifySteps(["commit 18", "customAction 18"]);
});

View file

@ -0,0 +1,318 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { describe, expect, test } from "@odoo/hoot";
import {
advanceTime,
animationFrame,
hover,
queryAllTexts,
queryOne,
waitFor,
} from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains, defineStyle } from "@web/../tests/web_test_helpers";
import { OPEN_DELAY } from "@web/core/tooltip/tooltip_service";
function reapplyCollapseTransition() {
defineStyle(/* css */ `
hoot-fixture:not(.allow-transitions) * .hb-collapse-content {
transition: height 0.35s ease !important;
}
`);
}
describe.current.tags("desktop");
test("show row title", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRow label="'my label'">row text</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".hb-row .text-nowrap").toHaveText("my label");
});
test("show row tooltip", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRow label="'my label'" tooltip="'my tooltip'">row text</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".hb-row .text-nowrap").toHaveText("my label");
expect(".o-tooltip").not.toHaveCount();
await hover(".hb-row .text-nowrap");
await advanceTime(OPEN_DELAY);
await waitFor(".o-tooltip");
expect(".o-tooltip").toHaveText("my tooltip");
await contains(":iframe .test-options-target").hover();
expect(".o-tooltip").not.toHaveCount();
});
test("hide empty row and display row with content", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 2'">
<BuilderButton applyTo="':not(.my-custom-class)'" classAction="'test'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 3'">
<BuilderButton applyTo="'.my-custom-class'" classAction="'test'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="parent-target"><div class="child-target">b</div></div>`);
const selectorRowLabel = ".options-container .hb-row:not(.d-none) .hb-row-label";
await contains(":iframe .parent-target").click();
expect(queryAllTexts(selectorRowLabel)).toEqual(["Row 1", "Row 2"]);
await contains("[data-class-action='my-custom-class']").click();
expect(queryAllTexts(selectorRowLabel)).toEqual(["Row 1", "Row 3"]);
});
/* ================= Collapse template ================= */
const collapseOptionTemplate = ({
dependency = false,
expand = false,
observeCollapseContent = false,
} = {}) => xml`
<BuilderRow label="'Test Collapse'" expand="${expand}" observeCollapseContent="${observeCollapseContent}">
<BuilderButton classAction="'a'" ${
dependency ? "id=\"'test_opt'\"" : ""
}>A</BuilderButton>
<t t-set-slot="collapse">
<BuilderRow level="1" label="'B'" ${
dependency ? "t-if=\"isActiveItem('test_opt')\"" : ""
}>
<BuilderButton classAction="'b'">B</BuilderButton>
</BuilderRow>
</t>
</BuilderRow>`;
describe("BuilderRow with collapse content", () => {
test("expand=false is collapsed by default", async () => {
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate(),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
});
test("expand=true is expanded by default", async () => {
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate({ dependency: false, expand: true }),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveClass("active");
});
test("Toggler button is not visible if no dependency is active", async () => {
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate({
dependency: true,
observeCollapseContent: true,
}),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveCount(0);
});
test("expand=true works when a dependency becomes active", async () => {
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate({ dependency: true, expand: true }),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
await contains(".options-container button[data-class-action='a']").click();
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveCount(1);
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveClass("active");
expect(".options-container button[data-class-action='b']").toBeVisible();
});
test("Collapse works with several dependencies", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderRow label="'Test Collapse'" expand="true">
<BuilderSelect>
<BuilderSelectItem classAction="'a'" id="'test_opt'">A</BuilderSelectItem>
<BuilderSelectItem classAction="'c'" id="'random_opt'">C</BuilderSelectItem>
</BuilderSelect>
<t t-set-slot="collapse">
<BuilderRow level="1" t-if="isActiveItem('test_opt')" label="'B'">
<BuilderButton classAction="'b'">B</BuilderButton>
</BuilderRow>
<BuilderRow level="1" t-if="isActiveItem('random_opt')" label="'D'">
<BuilderButton classAction="'d'">D</BuilderButton>
</BuilderRow>
</t>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveCount(0);
await contains(".options-container .dropdown-toggle").click();
await contains(".dropdown-menu [data-class-action='a']").click();
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveCount(1);
expect(".options-container button[data-class-action='b']").toBeVisible();
expect(".options-container button[data-class-action='d']").not.toHaveCount();
await contains(".options-container .dropdown-toggle").click();
await contains(".dropdown-menu [data-class-action='c']").click();
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveCount(1);
expect(".options-container button[data-class-action='b']").not.toHaveCount();
expect(".options-container button[data-class-action='d']").toBeVisible();
});
test("Click on toggler collapses / expands the BuilderRow", async () => {
reapplyCollapseTransition();
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate(),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
expect(".options-container button[data-class-action='b']").toHaveCount(0);
await contains(".o_hb_collapse_toggler:not(.d-none)").click();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveClass("active");
expect(".options-container button[data-class-action='b']").toBeVisible();
await contains(".o_hb_collapse_toggler:not(.d-none)").click();
advanceTime(400); // wait for the collapse transition to be over
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
expect(".options-container button[data-class-action='b']").toHaveCount(0);
});
test("Click on toggler collapses / expands the BuilderRow (with observeCollapseContent)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate({ observeCollapseContent: true }),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
expect(".options-container button[data-class-action='b']").not.toBeVisible();
await contains(".o_hb_collapse_toggler:not(.d-none)").click();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveClass("active");
expect(".options-container button[data-class-action='b']").toBeVisible();
await contains(".o_hb_collapse_toggler:not(.d-none)").click();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
advanceTime(400); // wait for the collapse transition to be over
await animationFrame();
expect(".options-container button[data-class-action='b']").not.toBeVisible();
});
test("Click header row's label collapses / expands the BuilderRow", async () => {
reapplyCollapseTransition();
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate(),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
expect(".options-container button[data-class-action='b']").toHaveCount(0);
await contains("[data-label='Test Collapse'] span:contains('Test Collapse')").click();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveClass("active");
expect(".options-container button[data-class-action='b']").toBeVisible();
await contains("[data-label='Test Collapse'] span:contains('Test Collapse')").click();
advanceTime(400); // wait for the collapse transition to be over
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
expect(".options-container button[data-class-action='b']").toHaveCount(0);
});
test("Click header row's label collapses / expands the BuilderRow (with observeCollapseContent)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate({ observeCollapseContent: true }),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
expect(".options-container button[data-class-action='b']").not.toBeVisible();
await contains("[data-label='Test Collapse'] span:contains('Test Collapse')").click();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveClass("active");
expect(".options-container button[data-class-action='b']").toBeVisible();
await contains("[data-label='Test Collapse'] span:contains('Test Collapse')").click();
expect(".o_hb_collapse_toggler:not(.d-none)").not.toHaveClass("active");
advanceTime(400); // wait for the collapse transition to be over
await animationFrame();
expect(".options-container button[data-class-action='b']").not.toBeVisible();
});
test("Two BuilderRows with collapse content on the same option are toggled independently", async () => {
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate({ dependency: true, expand: true }),
});
addBuilderOption({
selector: ".test-options-target",
template: collapseOptionTemplate(),
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveCount(1);
await contains(".options-container [data-class-action='a']:first").click();
await animationFrame();
expect(".o_hb_collapse_toggler:not(.d-none)").toHaveCount(2);
expect(".o_hb_collapse_toggler:not(.d-none):first").toHaveClass("active");
expect(".o_hb_collapse_toggler:not(.d-none):not(.d-none):last").not.toHaveClass("active");
await contains(".options-container .o_hb_collapse_toggler:not(.d-none):last").click();
expect(".o_hb_collapse_toggler:not(.d-none):first").toHaveClass("active");
expect(".o_hb_collapse_toggler:not(.d-none):last").toHaveClass("active");
await contains(".options-container .o_hb_collapse_toggler:not(.d-none):first").click();
expect(".o_hb_collapse_toggler:not(.d-none):first").not.toHaveClass("active");
expect(".o_hb_collapse_toggler:not(.d-none):last").toHaveClass("active");
});
});
describe.tags("desktop");
describe("HTML builder tests", () => {
test("add tooltip when label is too long", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRow label="'Supercalifragilisticexpalidocious'">Palais chatouille</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
await hover("[data-label='Supercalifragilisticexpalidocious'] .text-truncate");
await advanceTime(OPEN_DELAY);
await waitFor(".o-tooltip");
const label = queryOne("[data-label='Supercalifragilisticexpalidocious'] .text-truncate");
expect(label.scrollWidth).toBeGreaterThan(label.clientWidth); // the text is longer than the available width.
expect(".o-tooltip").toHaveText("Supercalifragilisticexpalidocious");
await contains(":iframe .test-options-target").hover();
expect(".o-tooltip").toHaveCount(0);
});
});

View file

@ -0,0 +1,336 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { setSelection } from "@html_editor/../tests/_helpers/selection";
import { expect, test, describe } from "@odoo/hoot";
import {
animationFrame,
click,
press,
queryAllTexts,
queryFirst,
runAllTimers,
tick,
} from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("call a specific action with some params and value (BuilderSelectItem)", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ params: { mainParam: testParam }, value }) {
expect.step(`customAction ${testParam} ${value}`);
}
},
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderSelect>
<BuilderSelectItem action="'customAction'" actionParam="'myParam'" actionValue="'myValue'">MyAction</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
await click(".we-bg-options-container .dropdown");
await animationFrame();
expect(".popover [data-action-id='customAction']").toHaveText("MyAction");
await click(".popover [data-action-id='customAction']");
await animationFrame();
// The function `apply` should be called twice (on hover (for preview), then, on click).
expect.verifySteps(["customAction myParam myValue", "customAction myParam myValue"]);
});
test("set the label of the select from the active select item and be updated on undo/redo", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderSelect attributeAction="'customAttribute'">
<BuilderSelectItem attributeActionValue="null">None</BuilderSelectItem>
<BuilderSelectItem attributeActionValue="'a'">A</BuilderSelectItem>
<BuilderSelectItem attributeActionValue="'b'">B</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test-options-target" customAttribute="a">x</div>`);
setSelection({
anchorNode: queryFirst(":iframe .test-options-target").childNodes[0],
anchorOffset: 0,
});
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".we-bg-options-container .dropdown").toHaveText("A");
await contains(".we-bg-options-container .dropdown").click();
await contains(".o-overlay-item [data-attribute-action-value='b']").click();
expect(".we-bg-options-container .dropdown").toHaveText("B");
await animationFrame();
expect(".o-overlay-item [data-attribute-action-value='b']").not.toHaveCount();
await contains(".o-snippets-top-actions .fa-undo").click();
expect(".we-bg-options-container .dropdown").toHaveText("A");
await contains(".o-snippets-top-actions .fa-repeat").click();
expect(".we-bg-options-container .dropdown").toHaveText("B");
});
test("consider the priority of the select item", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderSelect>
<BuilderSelectItem classAction="''">None</BuilderSelectItem>
<BuilderSelectItem classAction="'a'">A</BuilderSelectItem>
<BuilderSelectItem classAction="'a b'">A B</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test-options-target a">x</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(".we-bg-options-container .dropdown").toHaveText("A");
await contains(".we-bg-options-container .dropdown").click();
await contains(".o-overlay-item [data-class-action='']").click();
expect(".we-bg-options-container .dropdown").toHaveText("None");
await contains(".we-bg-options-container .dropdown").click();
await contains(".o-overlay-item [data-class-action='a b']").click();
expect(".we-bg-options-container .dropdown").toHaveText("A B");
});
test("hide/display BuilderSelect based on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`
<BuilderSelect applyTo="'.my-custom-class'">
<BuilderSelectItem classAction="'a'">A</BuilderSelectItem>
<BuilderSelectItem classAction="'b'">B</BuilderSelectItem>
</BuilderSelect>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="parent-target"><div class="child-target b">b</div></div>`
);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target b o-paragraph">b</div></div>`
);
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
expect(".options-container button.dropdown-toggle").toHaveCount(0);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target b o-paragraph my-custom-class">b</div></div>`
);
expect("[data-class-action='my-custom-class']").toHaveClass("active");
expect(".options-container button.dropdown-toggle").toHaveCount(1);
await runAllTimers();
expect(".options-container button.dropdown-toggle").toHaveText("B");
});
test("hide/display BuilderSelectItem base on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`
<BuilderSelect>
<BuilderSelectItem classAction="'a'">A</BuilderSelectItem>
<BuilderSelectItem applyTo="'.my-custom-class'" classAction="'b'">B</BuilderSelectItem>
<BuilderSelectItem classAction="'c'">C</BuilderSelectItem>
</BuilderSelect>`,
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="parent-target"><div class="child-target o-paragraph">b</div></div>`
);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target o-paragraph">b</div></div>`
);
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
expect(".options-container button.dropdown-toggle").toHaveCount(1);
await contains(".options-container button.dropdown-toggle").click();
expect(queryAllTexts(".o-dropdown--menu div.o-dropdown-item")).toEqual(["A", "C"]);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target o-paragraph my-custom-class">b</div></div>`
);
expect("[data-class-action='my-custom-class']").toHaveClass("active");
await contains(".options-container button.dropdown-toggle").click();
expect(queryAllTexts(".o-dropdown--menu div.o-dropdown-item")).toEqual(["A", "B", "C"]);
});
test("hide/display BuilderSelect base on applyTo in BuilderSelectItem", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`
<BuilderSelect>
<BuilderSelectItem applyTo="'.my-custom-class'" classAction="'a'">A</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="parent-target"><div class="child-target b">b</div></div>`);
await contains(":iframe .parent-target").click();
expect(".options-container button.dropdown-toggle").not.toBeVisible();
await contains("[data-class-action='my-custom-class']").click();
expect(".options-container button.dropdown-toggle").toBeVisible();
});
test("use BuilderSelect with styleAction", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`
<BuilderSelect styleAction="'border-style'">
<BuilderSelectItem styleActionValue="'dotted'">dotted</BuilderSelectItem>
<BuilderSelectItem styleActionValue="'inset'">inset</BuilderSelectItem>
<BuilderSelectItem styleActionValue="'none'">none</BuilderSelectItem>
</BuilderSelect>`,
});
const { getEditableContent } = await setupHTMLBuilder(`<div class="parent-target">b</div>`);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(".we-bg-options-container .dropdown").toHaveText("none");
await contains(".options-container button.dropdown-toggle").click();
expect(queryAllTexts(".o-dropdown--menu div.o-dropdown-item")).toEqual([
"dotted",
"inset",
"none",
]);
await contains(".o-dropdown--menu div.o-dropdown-item:contains(dotted)").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target o-paragraph" style="border-style: dotted;">b</div>`
);
expect(".we-bg-options-container .dropdown").toHaveText("dotted");
});
test("do not put inline style on an element which already has this style through css stylesheets", async () => {
addBuilderOption({
selector: ".test",
template: xml`
<BuilderSelect applyTo="'hr'" styleAction="'border-top-style'">
<BuilderSelectItem styleActionValue="'dotted'">dotted</BuilderSelectItem>
<BuilderSelectItem styleActionValue="'inset'">inset</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`
<div class="test">
<hr class="w-100">
</div>
`);
await contains(":iframe .test").click();
expect(".we-bg-options-container .dropdown").toHaveText("inset");
await contains(".we-bg-options-container .dropdown").click();
await contains(".o-dropdown--menu div.o-dropdown-item:contains('dotted')").click();
expect(":iframe hr").toHaveStyle({ "border-top-style": "dotted" });
await contains(".we-bg-options-container .dropdown").click();
await contains(".o-dropdown--menu div.o-dropdown-item:contains('inset')").click();
expect(":iframe hr").not.toHaveStyle("border-top-style", { inline: true });
});
test("revert a preview when cancelling a BuilderSelect by clicking outside of it", async () => {
addBuilderOption({
selector: ".test",
template: xml`
<BuilderSelect dataAttributeAction="'choice'">
<BuilderSelectItem dataAttributeActionValue="'0'">0</BuilderSelectItem>
<BuilderSelectItem dataAttributeActionValue="'1'">1</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test">Test</div>`);
await contains(":iframe .test").click();
expect(":iframe .test").not.toHaveAttribute("data-choice");
await contains(".we-bg-options-container .dropdown").click();
await contains(".o-dropdown--menu div.o-dropdown-item:contains('0')").hover();
expect(":iframe .test").toHaveAttribute("data-choice", "0");
await click(".we-bg-options-container");
expect(":iframe .test").not.toHaveAttribute("data-choice");
});
test("revert a preview when cancelling a BuilderSelect with escape", async () => {
addBuilderOption({
selector: ".test",
template: xml`
<BuilderSelect dataAttributeAction="'choice'">
<BuilderSelectItem dataAttributeActionValue="'0'">0</BuilderSelectItem>
<BuilderSelectItem dataAttributeActionValue="'1'">1</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test">Test</div>`);
await contains(":iframe .test").click();
expect(":iframe .test").not.toHaveAttribute("data-choice");
await contains(".we-bg-options-container .dropdown").click();
await contains(".o-dropdown--menu div.o-dropdown-item:contains('0')").hover();
expect(":iframe .test").toHaveAttribute("data-choice", "0");
await press("escape");
expect(":iframe .test").not.toHaveAttribute("data-choice");
});
test("preview when cycling through options with the keyboard", async () => {
addBuilderOption({
selector: ".test",
template: xml`
<BuilderSelect dataAttributeAction="'choice'">
<BuilderSelectItem dataAttributeActionValue="'0'">0</BuilderSelectItem>
<BuilderSelectItem dataAttributeActionValue="'1'">1</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test">Test</div>`);
await contains(":iframe .test").click();
expect(":iframe .test").not.toHaveAttribute("data-choice");
await contains(".we-bg-options-container .dropdown").press("enter");
await press("arrowdown");
expect(":iframe .test").toHaveAttribute("data-choice", "0");
});
test("revert a preview selected with the keyboard when cancelling with escape", async () => {
addBuilderOption({
selector: ".test",
template: xml`
<BuilderSelect dataAttributeAction="'choice'">
<BuilderSelectItem dataAttributeActionValue="'0'">0</BuilderSelectItem>
<BuilderSelectItem dataAttributeActionValue="'1'">1</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test">Test</div>`);
await contains(":iframe .test").click();
expect(":iframe .test").not.toHaveAttribute("data-choice");
await contains(".we-bg-options-container .dropdown").press("enter");
await press("arrowdown");
expect(".o-dropdown--menu div.o-dropdown-item:contains('0')").toBeFocused();
await press("escape");
await tick();
expect(":iframe .test").not.toHaveAttribute("data-choice");
});
test("isApplied shouldn't be called when the element is removed from the DOM", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
isApplied({ editingElement: el }) {
expect(el.isConnected).toBe(true);
}
},
});
addBuilderOption({
selector: ".test",
template: xml`
<BuilderSelect action="'customAction'">
<BuilderSelectItem actionParam="'0'">0</BuilderSelectItem>
<BuilderSelectItem actionParam="'1'">1</BuilderSelectItem>
</BuilderSelect>`,
});
await setupHTMLBuilder(`<div class="test">Test</div>`);
await contains(":iframe .test").click();
await contains(".fa-trash ").click();
expect(":iframe .test").toHaveCount(0);
});

View file

@ -0,0 +1,49 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { expect, test, describe } from "@odoo/hoot";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("hide/display base on applyTo", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>`,
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderTextInput applyTo="'.my-custom-class'" action="'customAction'"/>`,
});
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
getValue() {
return "customValue";
}
},
});
const { getEditableContent } = await setupHTMLBuilder(
`<div class="parent-target"><div class="child-target">b</div></div>`
);
const editableContent = getEditableContent();
await contains(":iframe .parent-target").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target o-paragraph">b</div></div>`
);
expect("[data-class-action='my-custom-class']").not.toHaveClass("active");
expect("[data-action-id='customAction']").toHaveCount(0);
await contains("[data-class-action='my-custom-class']").click();
expect(editableContent).toHaveInnerHTML(
`<div class="parent-target"><div class="child-target o-paragraph my-custom-class">b</div></div>`
);
expect("[data-class-action='my-custom-class']").toHaveClass("active");
expect("[data-action-id='customAction']").toHaveCount(1);
expect("[data-action-id='customAction'] input").toHaveValue("customValue");
});

View file

@ -0,0 +1,87 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { xml } from "@odoo/owl";
import { contains, defineModels, fields, models, onRpc } from "@web/../tests/web_test_helpers";
import { delay } from "@web/core/utils/concurrency";
class Test extends models.Model {
_name = "test";
_records = [
{ id: 1, name: "First" },
{ id: 2, name: "Second" },
{ id: 3, name: "Third" },
];
name = fields.Char();
}
class TestBase extends models.Model {
_name = "test.base";
_records = [
{
id: 1,
rel: [],
},
];
rel = fields.Many2many({
relation: "test",
string: "Test",
});
}
describe.current.tags("desktop");
defineModels([Test, TestBase]);
test("model many2many: find tag, select tag, unselect tag", async () => {
onRpc("test", "name_search", () => [
[1, "First"],
[2, "Second"],
[3, "Third"],
]);
addBuilderOption({
selector: ".test-options-target",
template: xml`<ModelMany2Many baseModel="'test.base'" m2oField="'rel'" recordId="1"/>`,
});
const { getEditor } = await setupHTMLBuilder(
`<div class="test-options-target" data-res-model="test.base" data-res-id="1">b</div>`
);
await contains(":iframe .test-options-target").click();
const modelEdit = getEditor().shared.cachedModel.useModelEdit({
model: "test.base",
recordId: 1,
});
expect(".options-container").toBeDisplayed();
expect("table tr").toHaveCount(0);
expect(modelEdit.get("rel")).toEqual([]);
await contains(".btn.o-dropdown").click();
expect("input").toHaveCount(1);
await contains("input").click();
await delay(300); // debounce
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(3);
await contains("span.o-dropdown-item").click();
expect(modelEdit.get("rel")).toEqual([{ id: 1, name: "First", display_name: "First" }]);
expect("table tr").toHaveCount(1);
await contains(".btn.o-dropdown").click();
await delay(300); // debounce
await animationFrame();
expect("span.o-dropdown-item").toHaveCount(2);
await contains("span.o-dropdown-item").click();
expect(modelEdit.get("rel")).toEqual([
{ id: 1, name: "First", display_name: "First" },
{ id: 2, name: "Second", display_name: "Second" },
]);
expect("table tr").toHaveCount(2);
await contains("button.fa-minus").click();
expect(modelEdit.get("rel")).toEqual([{ id: 2, name: "Second", display_name: "Second" }]);
expect("table tr").toHaveCount(1);
expect("table input").toHaveValue("Second");
await contains(".o-snippets-tabs button").click();
await contains(":iframe .test-options-target").click();
expect("table tr").toHaveCount(1);
expect("table input").toHaveValue("Second");
});

View file

@ -0,0 +1,241 @@
import { addBuilderOption, setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { describe, expect, test } from "@odoo/hoot";
import { fill } from "@odoo/hoot-dom";
import { xml } from "@odoo/owl";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
describe("classAction", () => {
test("should reset when cliking on an empty classAction", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="''"/>
<BuilderButton classAction="'x'"/>
</BuilderButtonGroup>
`,
});
await setupHTMLBuilder(`<div class="test-options-target x">a</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
expect("[data-class-action='x']").toHaveClass("active");
await contains("[data-class-action='']").click();
expect(":iframe .test-options-target").not.toHaveClass("x");
});
test("set multiples classes", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup>
<BuilderButton classAction="'x'"/>
<BuilderButton classAction="'x y z'"/>
</BuilderButtonGroup>
`,
});
await setupHTMLBuilder(`<div class="test-options-target x">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
expect("[data-class-action='x']").toHaveClass("active");
expect("[data-class-action='x y z']").not.toHaveClass("active");
await contains("[data-class-action='x y z']").click();
expect(":iframe .test-options-target").toHaveClass("x y z");
expect("[data-class-action='x']").not.toHaveClass("active");
expect("[data-class-action='x y z']").toHaveClass("active");
await contains("[data-class-action='x']").click();
expect(":iframe .test-options-target").toHaveClass("x");
expect(":iframe .test-options-target").not.toHaveClass("y z");
expect("[data-class-action='x']").toHaveClass("active");
expect("[data-class-action='x y z']").not.toHaveClass("active");
});
test("toggle class when not inside a BuilderButtonGroup", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton classAction="'x'"/>
<BuilderButtonGroup>
<BuilderButton classAction="'y'"/>
</BuilderButtonGroup>
`,
});
await setupHTMLBuilder(`<div class="test-options-target">a</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
await contains("[data-class-action='x']").click();
expect(":iframe .test-options-target").toHaveClass("x");
await contains("[data-class-action='x']").click();
expect(":iframe .test-options-target").not.toHaveClass("x");
await contains("[data-class-action='y']").click();
expect(":iframe .test-options-target").toHaveClass("y");
await contains("[data-class-action='y']").click();
expect(":iframe .test-options-target").toHaveClass("y");
});
});
describe("styleAction", () => {
test("should set a plain style", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderNumberInput styleAction="'width'" unit="'px'"
/>
`,
});
await setupHTMLBuilder(`<div class="test-options-target" style="width: 10px;">a</div>`);
await contains(":iframe .test-options-target").click();
expect("input").toHaveValue("10");
expect(".options-container").toBeDisplayed();
expect(":iframe .test-options-target").toHaveStyle({ width: "10px" });
expect(":iframe .test-options-target").toHaveAttribute("style", "width: 10px;"); // no !important
await contains("input").click();
await fill("1");
expect("input").toHaveValue("101");
expect(":iframe .test-options-target").toHaveStyle({ width: "101px" });
expect(":iframe .test-options-target").toHaveAttribute("style", "width: 101px;"); // no !important
await contains("input").edit("");
expect(":iframe .test-options-target").toHaveAttribute("style", "width: 0px;");
});
test("should set a style with its associated class", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderNumberInput styleAction="{ mainParam: 'border-width', extraClass: 'border' }" unit="'px'" min="0" composable="true"
/>
`,
});
await setupHTMLBuilder(`<div class="test-options-target border">a</div>`, {
styleContent: ".border { border: solid; border-width: 1px !important; }",
});
await contains(":iframe .test-options-target").click();
expect("input").toHaveValue("1");
expect(".options-container").toBeDisplayed();
expect(":iframe .test-options-target").not.toHaveAttribute("style");
await contains("input").click();
await fill("2");
expect("input").toHaveValue("12");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-width: 12px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("0");
expect(":iframe .test-options-target").not.toHaveAttribute("style");
expect(":iframe .test-options-target").not.toHaveClass("border");
await contains("input").edit("1");
expect(":iframe .test-options-target").toHaveAttribute("style", "");
expect(":iframe .test-options-target").toHaveClass("border");
});
test("should set a composite style with its associated class", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderNumberInput styleAction="{ mainParam: 'border-width', extraClass: 'border' }" unit="'px'" min="0" composable="true"
/>
`,
});
await setupHTMLBuilder(`<div class="test-options-target">a</div>`, {
styleContent: ".border { border: solid; border-width: 1px !important; }",
});
await contains(":iframe .test-options-target").click();
expect("input").toHaveValue("0");
expect(".options-container").toBeDisplayed();
expect(":iframe .test-options-target").not.toHaveAttribute("style");
await contains("input").edit("10");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-width: 10px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("10 20");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-width: 10px 20px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("10 20 30");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-width: 10px 20px 30px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("10 20 30 40");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-width: 10px 20px 30px 40px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("10 1");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-bottom-width: 10px !important; border-top-width: 10px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("1 10");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-left-width: 10px !important; border-right-width: 10px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("1 10 10");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-left-width: 10px !important; border-bottom-width: 10px !important; border-right-width: 10px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
await contains("input").edit("1 1 1 10");
expect(":iframe .test-options-target").toHaveAttribute(
"style",
"border-left-width: 10px !important;"
);
expect(":iframe .test-options-target").toHaveClass("border");
});
test("button isApplied is properly computed with percentage width values", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButtonGroup styleAction="'width'">
<BuilderButton styleActionValue="''">Default</BuilderButton>
<BuilderButton styleActionValue="'50%'">50%</BuilderButton>
</BuilderButtonGroup>
`,
});
await setupHTMLBuilder(`<div class="test-options-target x">a</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeDisplayed();
expect("[data-style-action-value='']").toHaveClass("active");
expect("[data-style-action-value='50%']").not.toHaveClass("active");
expect(":iframe .test-options-target").toHaveOuterHTML(
`<div class="test-options-target x o-paragraph"> a </div>`
);
await contains("[data-style-action-value='50%']").click();
expect(":iframe .test-options-target").toHaveOuterHTML(
`<div class="test-options-target x o-paragraph" style="width: 50% !important;"> a </div>`
);
expect("[data-style-action-value='']").not.toHaveClass("active");
expect("[data-style-action-value='50%']").toHaveClass("active");
});
});

View file

@ -0,0 +1,701 @@
import {
addBuilderAction,
addBuilderOption,
setupHTMLBuilder,
} from "@html_builder/../tests/helpers";
import { BuilderAction } from "@html_builder/core/builder_action";
import { BaseOptionComponent, useDomState } from "@html_builder/core/utils";
import { OptionsContainer } from "@html_builder/sidebar/option_container";
import { setContent, setSelection } from "@html_editor/../tests/_helpers/selection";
import { redo, undo } from "@html_editor/../tests/_helpers/user_actions";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame, queryAllTexts, queryFirst } from "@odoo/hoot-dom";
import { Component, onWillStart, xml } from "@odoo/owl";
import { contains, patchWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("Open custom tab with template option", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderRow label="'Row 1'">
Test
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target" data-name="Yop">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(queryAllTexts(".options-container > div")).toEqual(["Yop", "Row 1\nTest"]);
});
test("Open custom tab with Component option", async () => {
class TestOption extends BaseOptionComponent {
static template = xml`
<BuilderRow label="'Row 1'">
Test
</BuilderRow>`;
static props = {};
}
addBuilderOption({
selector: ".test-options-target",
Component: TestOption,
});
await setupHTMLBuilder(`<div class="test-options-target" data-name="Yop">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(queryAllTexts(".options-container > div")).toEqual(["Yop", "Row 1\nTest"]);
});
test("OptionContainer should display custom title", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderRow label="'Row 1'">
Test
</BuilderRow>`,
title: "My custom title",
});
await setupHTMLBuilder(`<div class="test-options-target" data-name="Yop">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(queryAllTexts(".options-container > div")).toEqual(["My custom title", "Row 1\nTest"]);
});
test("Don't display option base on exclude", async () => {
addBuilderOption({
selector: ".test-options-target",
exclude: ".test-exclude",
template: xml`<BuilderRow label="'Row 1'">a</BuilderRow>`,
});
addBuilderOption({
selector: ".test-options-target",
exclude: ".test-exclude-2",
template: xml`<BuilderRow label="'Row 2'">b</BuilderRow>`,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`<BuilderRow label="'Row 3'">
<BuilderButton classAction="'test-exclude-2'">c</BuilderButton>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target test-exclude">b</div>`);
await contains(":iframe .test-options-target").click();
expect(queryAllTexts(".options-container .hb-row")).toEqual(["Row 2\nb", "Row 3\nc"]);
await contains("[data-class-action='test-exclude-2']").click();
expect(queryAllTexts(".options-container .hb-row")).toEqual(["Row 3\nc"]);
});
test("Don't display option base on applyTo", async () => {
addBuilderOption({
selector: ".test-options-target",
applyTo: ".test-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton classAction="'test-target-2'">a</BuilderButton>
</BuilderRow>`,
});
addBuilderOption({
selector: ".test-options-target",
applyTo: ".test-target-2",
template: xml`<BuilderRow label="'Row 2'">b</BuilderRow>`,
});
await setupHTMLBuilder(`
<div class="test-options-target">
<div class="test-target">b</div>
</div>`);
await contains(":iframe .test-options-target").click();
expect(queryAllTexts(".options-container .hb-row")).toEqual(["Row 1\na"]);
await contains("[data-class-action='test-target-2']").click();
await animationFrame();
expect(queryAllTexts(".options-container .hb-row")).toEqual(["Row 1\na", "Row 2\nb"]);
});
test("basic multi options containers", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderRow label="'Row 1'">A</BuilderRow>`,
});
addBuilderOption({
selector: ".a",
template: xml`
<BuilderRow label="'Row 2'">B</BuilderRow>`,
});
addBuilderOption({
selector: ".main",
template: xml`
<BuilderRow label="'Row 3'">C</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="main"><p class="test-options-target a">b</p></div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toHaveCount(2);
expect(queryAllTexts(".options-container:first .we-bg-options-container > div > div")).toEqual([
"Row 3",
"C",
]);
expect(
queryAllTexts(".options-container:nth-child(2) .we-bg-options-container > div > div")
).toEqual(["Row 1", "A", "Row 2", "B"]);
});
test("option that matches several elements", async () => {
addBuilderOption({
selector: ".a",
template: xml`<BuilderRow label="'Row'">
<BuilderButton classAction="'my-custom-class'">Test</BuilderButton>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="a"><div class="a test-target">b</div></div>`);
await contains(":iframe .test-target").click();
expect(".options-container:not(.d-none)").toHaveCount(2);
expect(queryAllTexts(".options-container:not(.d-none)")).toEqual([
"Block\nRow\nTest",
"Block\nRow\nTest",
]);
});
test("Snippets options respect sequencing", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderRow label="'Row 2'">
Test
</BuilderRow>`,
sequence: 2,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderRow label="'Row 1'">
Test
</BuilderRow>`,
sequence: 1,
});
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderRow label="'Row 3'">
Test
</BuilderRow>`,
sequence: 3,
});
await setupHTMLBuilder(`<div class="test-options-target" data-name="Yop">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(queryAllTexts(".options-container .we-bg-options-container > div > div")).toEqual([
"Row 1",
"Test",
"Row 2",
"Test",
"Row 3",
"Test",
]);
});
test("hide empty OptionContainer and display OptionContainer with content", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".parent-target > div",
template: xml`<BuilderRow label="'Row 3'">
<BuilderButton applyTo="'.my-custom-class'" classAction="'test'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(
`<div class="parent-target"><div><div class="child-target">b</div></div></div>`
);
await contains(":iframe .parent-target > div").click();
expect(".options-container:not(.d-none)").toHaveCount(1);
await contains("[data-class-action='my-custom-class']").click();
expect(".options-container:not(.d-none)").toHaveCount(2);
});
test("hide empty OptionContainer and display OptionContainer with content (with BuilderButtonGroup)", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".parent-target > div",
template: xml`
<BuilderRow label="'Row 2'">
<BuilderButtonGroup>
<BuilderButton applyTo="'.my-custom-class'" classAction="'test'">Test</BuilderButton>
</BuilderButtonGroup>
</BuilderRow>`,
});
await setupHTMLBuilder(
`<div class="parent-target"><div><div class="child-target">b</div></div></div>`
);
await contains(":iframe .parent-target > div").click();
expect(".options-container:not(.d-none)").toHaveCount(1);
await contains("[data-class-action='my-custom-class']").click();
expect(".options-container:not(.d-none)").toHaveCount(2);
expect(".options-container:not(.d-none):nth-child(2)").toHaveText("Block\nRow 2\nTest");
});
test("hide empty OptionContainer and display OptionContainer with content (with BuilderButtonGroup) - 2", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".parent-target > div",
template: xml`
<BuilderRow label="'Row 2'">
<BuilderButtonGroup applyTo="'.my-custom-class'">
<BuilderButton classAction="'test'">Test</BuilderButton>
</BuilderButtonGroup>
</BuilderRow>`,
});
await setupHTMLBuilder(
`<div class="parent-target"><div><div class="child-target">b</div></div></div>`
);
await contains(":iframe .parent-target > div").click();
expect(".options-container:not(.d-none)").toHaveCount(1);
await contains("[data-class-action='my-custom-class']").click();
expect(".options-container:not(.d-none)").toHaveCount(2);
expect(".options-container:not(.d-none):nth-child(2)").toHaveText("Block\nRow 2\nTest");
});
test("fallback on the 'Blocks' tab if no option match the selected element", async () => {
await setupHTMLBuilder(`<div class="parent-target"><div class="child-target">b</div></div>`);
await contains(":iframe .parent-target > div").click();
expect(".o-snippets-tabs button:contains('Blocks')").toHaveClass("active");
});
test("display empty message if no option container is visible", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton applyTo="'.invalid'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="parent-target"><div class="child-target">b</div></div>`);
await contains(":iframe .parent-target > div").click();
await animationFrame();
expect(".o_customize_tab").toHaveText("Select a block on your page to style it.");
});
test("hide/display option base on selector", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton classAction="'my-custom-class'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".my-custom-class",
template: xml`<BuilderRow label="'Row 2'">
<BuilderButton classAction="'test'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="parent-target"><div class="child-target">b</div></div>`);
await contains(":iframe .parent-target").click();
expect("[data-class-action='test']").not.toHaveCount();
await contains("[data-class-action='my-custom-class']").click();
expect("[data-class-action='test']").toBeVisible();
});
test("hide/display option container base on selector", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".my-custom-class",
template: xml`<BuilderRow label="'Row 2'">
<BuilderButton classAction="'test'"/>
</BuilderRow>`,
});
addBuilderOption({
selector: ".sub-child-target",
template: xml`<BuilderRow label="'Row 3'">
<BuilderButton classAction="'another-custom-class'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`
<div class="parent-target">
<div class="child-target">
<div class="sub-child-target">b</div>
</div>
</div>`);
await contains(":iframe .sub-child-target").click();
expect("[data-class-action='test']").not.toHaveCount();
const selectorRowLabel = ".options-container .hb-row:not(.d-none) .hb-row-label";
expect(queryAllTexts(selectorRowLabel)).toEqual(["Row 1", "Row 3"]);
await contains("[data-class-action='my-custom-class']").click();
expect("[data-class-action='test']").toBeVisible();
expect(queryAllTexts(selectorRowLabel)).toEqual(["Row 1", "Row 2", "Row 3"]);
});
test("don't rerender the OptionsContainer every time you click on the same element", async () => {
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'">
<BuilderButton applyTo="'.child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
patchWithCleanup(OptionsContainer.prototype, {
setup() {
super.setup();
onWillStart(() => {
expect.step("onWillStart");
});
},
});
await setupHTMLBuilder(`
<div class="parent-target">
<div class="child-target">
<div class="sub-child-target">b</div>
</div>
</div>`);
await contains(":iframe .sub-child-target").click();
expect("[data-class-action='test']").not.toHaveCount();
expect.verifySteps(["onWillStart"]);
await contains(":iframe .sub-child-target").click();
expect.verifySteps([]);
});
test("no need to define 'isApplied' method for custom action if the widget already has a generic action", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement, value }) {
editingElement.textContent = value;
}
},
});
addBuilderOption({
selector: ".s_test",
template: xml`
<BuilderRow label.translate="Type">
<BuilderSelect>
<BuilderSelectItem classAction="'A-class'" action="'customAction'" actionParam="'A'">A</BuilderSelectItem>
</BuilderSelect>
</BuilderRow>
`,
});
await setupHTMLBuilder(`
<div class="s_test A-class">
a
</div>`);
await contains(":iframe .s_test").click();
expect(".options-container [data-class-action='A-class']").toHaveText("A");
});
test("useDomState callback shouldn't be called when the editingElement is removed", async () => {
let editor;
let count = 0;
class TestOption extends Component {
static template = xml`<div class="test_option">test</div>`;
static props = {};
setup() {
useDomState(() => {
expect.step(`useDomState ${count}`);
return {
count: (count = count + 1),
};
});
}
}
addBuilderOption({
selector: ".s_test",
editableOnly: false,
Component: TestOption,
});
addBuilderOption({
selector: "*",
template: xml`<BuilderButton action="'addTestSnippet'">Add</BuilderButton>`,
});
addBuilderAction({
addTestSnippet: class extends BuilderAction {
static id = "addTestSnippet";
apply({ editingElement }) {
const testEl = document.createElement("div");
testEl.classList.add("s_test", "alert-info");
testEl.textContent = "test";
editingElement.after(testEl);
editor.shared["builderOptions"].setNextTarget(testEl);
}
},
});
const { getEditor } = await setupHTMLBuilder(`<div class="s_dummy">Hello</div>`);
editor = getEditor();
await contains(":iframe .s_dummy").click();
await contains("[data-action-id='addTestSnippet']").click();
expect(".options-container .test_option").toHaveCount(1);
expect.verifySteps(["useDomState 0"]);
undo(editor);
await animationFrame();
expect(".options-container .test_option").toHaveCount(0);
expect.verifySteps([]);
redo(editor);
await animationFrame();
expect(".options-container .test_option").toHaveCount(1);
expect.verifySteps(["useDomState 1"]);
});
test("Update editing elements at dom change with multiple levels of applyTo", async () => {
addBuilderAction({
customAction: class extends BuilderAction {
static id = "customAction";
apply({ editingElement }) {
const createdEl = editingElement.cloneNode(true);
const parentEl = editingElement.parentElement;
parentEl.appendChild(createdEl);
}
},
});
addBuilderOption({
selector: ".parent-target",
template: xml`<BuilderRow label="'Row 1'" applyTo="'.child-target'">
<BuilderButton action="'customAction'" />
<BuilderButton applyTo="'.sub-child-target'" classAction="'my-custom-class'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`
<div class="parent-target">
<div class="child-target">
<div class="sub-child-target">b</div>
</div>
</div>`);
await contains(":iframe .parent-target").click();
await contains("[data-action-id='customAction']").click();
await contains("[data-class-action='my-custom-class']").click();
expect(":iframe .sub-child-target").toHaveClass("my-custom-class");
});
test("An option should only appear if its target is inside an editable area, unless specified otherwise", async () => {
addBuilderOption({
selector: ".test-target",
template: xml`
<BuilderButton classAction="'dummy-class-a'">Option A</BuilderButton>
`,
});
addBuilderOption({
selector: ".test-target",
editableOnly: false,
template: xml`
<BuilderButton classAction="'dummy-class-b'">Option B</BuilderButton>
`,
});
const { getEditor } = await setupHTMLBuilder(`<div></div>`);
const editor = getEditor();
setContent(
editor.editable,
`<div class="content">
<div class="test-target test-not-editable">NOT IN EDITABLE</div>
</div>
<div class="content o_editable">
<div class="test-target test-editable">IN EDITABLE</div>
</div>`
);
editor.shared.history.addStep();
await contains(":iframe .test-not-editable").click();
expect(queryAllTexts(".options-container [data-class-action]")).toEqual(["Option B"]);
await contains(":iframe .test-editable").click();
expect(queryAllTexts(".options-container [data-class-action]")).toEqual([
"Option A",
"Option B",
]);
});
describe("isActiveItem", () => {
test("a button should not be visible if its dependency isn't (with undo)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton attributeAction="'my-attribute1'" attributeActionValue="'x'" id="'id1'">b1</BuilderButton>
<BuilderButton attributeAction="'my-attribute1'" attributeActionValue="'y'" id="'id2'">b2</BuilderButton>
<BuilderButton attributeAction="'my-attribute2'" attributeActionValue="'1'" t-if="this.isActiveItem('id1')">b3</BuilderButton>
<BuilderButton attributeAction="'my-attribute2'" attributeActionValue="'2'" t-if="this.isActiveItem('id2')">b4</BuilderButton>
`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
setSelection({
anchorNode: queryFirst(":iframe .test-options-target").childNodes[0],
anchorOffset: 0,
});
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).not.toHaveCount();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='2']"
).not.toHaveCount();
await contains(
"[data-attribute-action='my-attribute1'][data-attribute-action-value='x']"
).click();
expect(":iframe .test-options-target").toHaveAttribute("my-attribute1", "x");
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).toBeVisible();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='2']"
).not.toHaveCount();
await contains(
"[data-attribute-action='my-attribute1'][data-attribute-action-value='y']"
).click();
expect(":iframe .test-options-target").toHaveAttribute("my-attribute1", "y");
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).not.toHaveCount();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='2']"
).toBeVisible();
await contains(".fa-undo").click();
expect(":iframe .test-options-target").toHaveAttribute("my-attribute1", "x");
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).toBeVisible();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='2']"
).not.toHaveCount();
await contains(".fa-undo").click();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).not.toHaveCount();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='2']"
).not.toHaveCount();
});
test("a button should not be visible if its dependency isn't (in a BuilderSelect with priority)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderSelect>
<BuilderSelectItem classAction="'a'" id="'x'">x</BuilderSelectItem>
<BuilderSelectItem classAction="'a b'" id="'y'">y</BuilderSelectItem>
</BuilderSelect>
<BuilderButton classAction="'b1'" t-if="this.isActiveItem('x')">b1</BuilderButton>
<BuilderButton classAction="'b2'" t-if="this.isActiveItem('y')">b2</BuilderButton>
`,
});
await setupHTMLBuilder(`<div class="test-options-target a">a</div>`);
setSelection({
anchorNode: queryFirst(":iframe .test-options-target").childNodes[0],
anchorOffset: 0,
});
await contains(":iframe .test-options-target").click();
await animationFrame();
expect(".options-container").toBeVisible();
expect(".we-bg-options-container .dropdown").toHaveText("x");
expect("[data-class-action='b1']").toBeVisible();
expect("[data-class-action='b2']").not.toHaveCount();
await contains(".we-bg-options-container .dropdown").click();
await contains("[data-class-action='a b']").click();
expect(".we-bg-options-container .dropdown").toHaveText("y");
expect("[data-class-action='b1']").not.toHaveCount();
expect("[data-class-action='b2']").toBeVisible();
});
test("a button should not be visible if the dependency is active", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton attributeAction="'my-attribute1'" attributeActionValue="'x'" id="'id1'">b1</BuilderButton>
<BuilderButton attributeAction="'my-attribute2'" attributeActionValue="'1'" t-if="!this.isActiveItem('id1')">b3</BuilderButton>
`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).toBeVisible();
await contains(
"[data-attribute-action='my-attribute1'][data-attribute-action-value='x']"
).click();
expect(":iframe .test-options-target").toHaveAttribute("my-attribute1", "x");
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).not.toHaveCount();
});
test("a button should not be visible if the dependency is active (when a dependency is added after a dependent)", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton attributeAction="'my-attribute2'" attributeActionValue="'1'" t-if="this.isActiveItem('id')">b1</BuilderButton>
<BuilderButton attributeAction="'my-attribute2'" attributeActionValue="'2'" t-if="!this.isActiveItem('id')">b2</BuilderButton>
<BuilderRow label="'dependency'">
<BuilderButton attributeAction="'my-attribute1'" attributeActionValue="'x'" id="'id'">b3</BuilderButton>
</BuilderRow>
`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
await contains(":iframe .test-options-target").click();
expect(".options-container").toBeVisible();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).not.toHaveCount();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='2']"
).toBeVisible();
await contains(
"[data-attribute-action='my-attribute1'][data-attribute-action-value='x']"
).click();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='1']"
).toBeVisible();
expect(
"[data-attribute-action='my-attribute2'][data-attribute-action-value='2']"
).not.toHaveCount();
});
test("a button should not be visible if its dependency is removed from the DOM", async () => {
addBuilderOption({
selector: ".test-options-target",
template: xml`
<BuilderButton classAction="'my-class1'" id="'id1'">b1</BuilderButton>
<BuilderButton classAction="'my-class2'" id="'id2'" t-if="this.isActiveItem('id1')">b2</BuilderButton>
<BuilderButton classAction="'my-class3'" t-if="this.isActiveItem('id2')">b3</BuilderButton>
`,
});
await setupHTMLBuilder(`<div class="test-options-target my-class1 my-class2">b</div>`);
await contains(":iframe .test-options-target").click();
await contains("[data-class-action='my-class1']").click();
// Wait 2 animation frames: one for id2 to be removed and another for
// id3 to be removed.
await animationFrame();
expect("[data-class-action='my-class3']").not.toHaveCount();
});
});

View file

@ -0,0 +1,13 @@
import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { expect, test, describe } from "@odoo/hoot";
describe.current.tags("desktop");
test("should not allow edition of date and datetime fields", async () => {
await setupHTMLBuilder(
`<time data-oe-model="blog.post" data-oe-id="3" data-oe-field="post_date" data-oe-type="datetime" data-oe-expression="blog_post.post_date" data-oe-original="2025-07-30 09:54:36" data-oe-original-with-format="07/30/2025 09:54:36" data-oe-original-tz="Europe/Brussels">
Jul 30, 2025
</time>`
);
expect(":iframe time").toHaveProperty("isContentEditable", false);
});

View file

@ -0,0 +1,39 @@
import {
setupHTMLBuilder,
waitForEndOfOperation,
confirmAddSnippet,
} from "@html_builder/../tests/helpers";
import { describe, expect, test } from "@odoo/hoot";
import { contains } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
const dropzone = (hovered = false) => {
const highlightClass = hovered ? " o_dropzone_highlighted" : "";
return `<div class="oe_drop_zone oe_insert${highlightClass}" data-editor-message-default="true" data-editor-message="DRAG BUILDING BLOCKS HERE"></div>`;
};
test("wrapper element has the 'DRAG BUILDING BLOCKS HERE' message", async () => {
const { contentEl } = await setupHTMLBuilder("");
expect(contentEl).toHaveAttribute("data-editor-message", "DRAG BUILDING BLOCKS HERE");
});
test("drop beside dropzone inserts the snippet", async () => {
const { contentEl } = await setupHTMLBuilder();
const { moveTo, drop } = await contains(
".o-snippets-menu #snippet_groups .o_snippet_thumbnail"
).drag();
await moveTo(contentEl.ownerDocument.body);
// The dropzone is not hovered, so not highlighted.
expect(contentEl).toHaveInnerHTML(dropzone());
await drop();
await confirmAddSnippet();
expect(".o_add_snippet_dialog").toHaveCount(0);
await waitForEndOfOperation();
expect(contentEl)
.toHaveInnerHTML(`<section class="s_test" data-snippet="s_test" data-name="Test">
<div class="test_a o-paragraph">
<br>
</div>
</section>`);
});

View file

@ -0,0 +1,279 @@
import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { setSelection } from "@html_editor/../tests/_helpers/selection";
import { expandToolbar } from "@html_editor/../tests/_helpers/toolbar";
import { insertText, pasteHtml } from "@html_editor/../tests/_helpers/user_actions";
import { FontPlugin } from "@html_editor/main/font/font_plugin";
import { isTextNode } from "@html_editor/utils/dom_info";
import { parseHTML } from "@html_editor/utils/html";
import { expect, test, describe } from "@odoo/hoot";
import {
click,
manuallyDispatchProgrammaticEvent,
waitFor,
queryOne,
waitForNone,
} from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { contains, patchWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("should add an icon from the media modal dialog", async () => {
const { getEditor } = await setupHTMLBuilder(`<p>x</p>`);
const editor = getEditor();
const p = editor.document.querySelector("p");
editor.shared.selection.focusEditable();
editor.shared.selection.setSelection({
anchorNode: p,
anchorOffset: 1,
focusNode: p,
focusOffset: 1,
});
await insertText(editor, "/image");
await animationFrame();
await contains(".o-we-command").click();
await contains(".modal .modal-body .nav-item:nth-child(3) a").click();
await contains(".modal .modal-body .fa-heart").click();
expect(p).toHaveInnerHTML(`x<span class="fa fa-heart" contenteditable="false">\u200b</span>`);
});
test("should delete text forward", async () => {
const keyPress = async (editor, key) => {
await manuallyDispatchProgrammaticEvent(editor.editable, "keydown", { key });
await manuallyDispatchProgrammaticEvent(editor.editable, "keyup", { key });
};
const { getEditor } = await setupHTMLBuilder(`<p>abc</p><p>def</p>`);
const editor = getEditor();
const p = editor.editable.querySelector("p");
editor.shared.selection.setSelection({ anchorNode: p, anchorOffset: 1 });
await keyPress(editor, "delete");
// paragraphs get merged
expect(p).toHaveInnerHTML("abcdef");
await keyPress(editor, "delete");
// following character gets deleted
expect(p).toHaveInnerHTML("abcef");
});
test("unsplittable node predicates should not crash when called with text node argument", async () => {
const { getEditor } = await setupHTMLBuilder(`<p>abc</p>`);
const editor = getEditor();
const textNode = editor.editable.querySelector("p").firstChild;
expect(isTextNode(textNode)).toBe(true);
expect(() =>
editor.resources.unsplittable_node_predicates.forEach((p) => p(textNode))
).not.toThrow();
});
test("should set contenteditable to false on .o_not_editable elements", async () => {
const { getEditor } = await setupHTMLBuilder(`
<div class="o_not_editable">
<p>abc</p>
</div>
`);
const editor = getEditor();
const div = editor.editable.querySelector("div.o_not_editable");
expect(div).toHaveAttribute("contenteditable", "false");
// Add a snippet-like element
const snippetHtml = `
<section class="o_not_editable">
<p>abc</p>
</section>
`;
const snippet = parseHTML(editor.document, snippetHtml).firstChild;
div.after(snippet);
editor.shared.history.addStep();
// Normalization should set contenteditable to false
expect(snippet).toHaveAttribute("contenteditable", "false");
});
test("should preserve iframe in the toolbar's font size input", async () => {
const { getEditor } = await setupHTMLBuilder(`
<section class="s_text_block pt40 pb40 o_colored_level" data-snippet="s_text_block" data-name="Text">
<div class="container s_allow_columns">
<p>Some text.</p>
<p>Some more text.</p>
</div>
</section>
`);
const editor = getEditor();
const p = editor.editable.querySelector("p");
const p2 = p.nextElementSibling;
// Activate the text block snippet.
click(p);
// Select the word "more".
editor.shared.selection.setSelection({
anchorNode: p2.firstChild,
anchorOffset: 5,
focusNode: p2.firstChild,
focusOffset: 9,
});
await waitFor(".o-we-toolbar");
// Get the font size selector input.
let iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe");
let inputEl = iframeEl.contentWindow.document?.querySelector("input");
// Change the font style from paragraph to paragraph.
await contains(".o-we-toolbar .btn[name='font'].dropdown-toggle").click();
await waitFor(".btn[name='font'].dropdown-toggle.show");
await contains(".dropdown-menu [name='p']").click();
iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe");
let newInputEl = iframeEl.contentWindow.document?.querySelector("input");
expect(newInputEl).toBe(inputEl); // The input shouldn't have been changed.
// Select the first word "text".
editor.shared.selection.setSelection({
anchorNode: p.firstChild,
anchorOffset: 5,
focusNode: p.firstChild,
focusOffset: 9,
});
await waitFor(".o-we-toolbar");
// Get the font size selector input.
iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe");
inputEl = iframeEl.contentWindow.document?.querySelector("input");
// Change the font style from paragraph to header 1.
await contains(".o-we-toolbar .btn[name='font'].dropdown-toggle").click();
await waitFor(".btn[name='font'].dropdown-toggle.show");
await contains(".dropdown-menu [name='h2']").click();
iframeEl = queryOne(".o-we-toolbar [name='font_size_selector'] iframe");
newInputEl = iframeEl.contentWindow.document?.querySelector("input");
expect(newInputEl).toBe(inputEl); // The input shouldn't have been changed.
});
test("should apply default table classes on paste", async () => {
const { getEditor } = await setupHTMLBuilder(`<p><br></p>`);
const editor = getEditor();
const p = editor.document.querySelector("p");
editor.shared.selection.focusEditable();
editor.shared.selection.setSelection({
anchorNode: p,
anchorOffset: 0,
});
pasteHtml(editor, `<table><tr><td>1234</td></tr></table>`);
expect(editor.document.querySelector("table")).toHaveClass("table table-bordered");
});
describe("toolbar dropdowns", () => {
const setup = async () => {
const { getEditor } = await setupHTMLBuilder(`<p>abc</p>`);
const editor = getEditor();
const p = editor.editable.querySelector("p");
setSelection({ anchorNode: p, anchorOffset: 0, focusOffset: 1 });
await waitFor(".o-we-toolbar");
await expandToolbar();
return { editor, p };
};
const focusAndClick = async (selector) => {
const target = await waitFor(selector);
manuallyDispatchProgrammaticEvent(target, "mousedown");
manuallyDispatchProgrammaticEvent(target, "focus");
await animationFrame();
// Dropdown menu needs another animation frame to be closed after the
// toolbar is closed.
await animationFrame();
expect(target).toBeVisible();
manuallyDispatchProgrammaticEvent(target, "mouseup");
manuallyDispatchProgrammaticEvent(target, "click");
};
test("list dropdown should not close on click", async () => {
const { editor } = await setup();
click(".o-we-toolbar .btn[name='list_selector']");
const bulletedListButtonSelector = ".dropdown-menu button[name='bulleted_list']";
await focusAndClick(bulletedListButtonSelector);
await animationFrame();
expect(bulletedListButtonSelector).toBeVisible();
expect(bulletedListButtonSelector).toHaveClass("active");
expect(!!editor.editable.querySelector("ul li")).toBe(true);
});
test("text alignment dropdown should not close on click", async () => {
const { p } = await setup();
click(".o-we-toolbar .btn[name='text_align']");
const alignCenterButtonSelector = ".dropdown-menu button.fa-align-center";
await focusAndClick(alignCenterButtonSelector);
await animationFrame();
expect(alignCenterButtonSelector).toBeVisible();
expect(alignCenterButtonSelector).toHaveClass("active");
expect(p).toHaveStyle("text-align: center");
});
test("font style dropdown should close only after click", async () => {
const { editor } = await setup();
click(".o-we-toolbar .btn[name='font']");
await focusAndClick(".dropdown-menu .dropdown-item[name='h2']");
await animationFrame();
expect(!!editor.editable.querySelector("h2")).toBe(true);
});
test("font size dropdown should close only after click", async () => {
patchWithCleanup(FontPlugin.prototype, {
get fontSizeItems() {
return [{ name: "test", className: "test-font-size" }];
},
});
const { p } = await setup();
click(".o-we-toolbar .btn[name='font_size_selector']");
await focusAndClick(".dropdown-menu .dropdown-item");
await animationFrame();
expect(p.firstChild).toHaveClass("test-font-size");
});
test("font selector dropdown should not have normal as an option", async () => {
await setup();
click(".o-we-toolbar .btn[name='font']");
await animationFrame();
expect(".o_font_selector_menu .o-dropdown-item[name='div']").toHaveCount(0);
});
});
describe("font types", () => {
test("Header 1 Display 1 to 4 are available", async () => {
const { getEditor } = await setupHTMLBuilder(`<p>abc</p>`);
const editor = getEditor();
const p = editor.editable.querySelector("p");
setSelection({ anchorNode: p, anchorOffset: 0, focusOffset: 1 });
await waitFor(".o-we-toolbar");
click(".o-we-toolbar .btn[name='font']");
await waitFor(".o_font_selector_menu");
const expectedButtons = [
"Header 1 Display 1",
"Header 1 Display 2",
"Header 1 Display 3",
"Header 1 Display 4",
];
expectedButtons.forEach((button) => {
expect(`.o_font_selector_menu .o-dropdown-item:contains('${button}')`).toHaveCount(1);
});
});
test("'Light' is available", async () => {
const { getEditor } = await setupHTMLBuilder(`<p>abc</p>`);
const editor = getEditor();
const p = editor.editable.querySelector("p");
setSelection({ anchorNode: p, anchorOffset: 0, focusOffset: 1 });
await waitFor(".o-we-toolbar");
click(".o-we-toolbar .btn[name='font']");
await waitFor(".o_font_selector_menu");
expect(`.o_font_selector_menu .o-dropdown-item:contains('Light')`).toHaveCount(1);
click(".o_font_selector_menu .o-dropdown-item:contains('Light')");
await waitForNone(".o_font_selector_menu");
expect(".o-we-toolbar .btn[name='font']").toHaveText("Light");
expect(editor.editable.querySelector("p")).toHaveClass("lead");
});
test("'Small' is available", async () => {
const { getEditor } = await setupHTMLBuilder(`<p>abc</p>`);
const editor = getEditor();
const p = editor.editable.querySelector("p");
setSelection({ anchorNode: p, anchorOffset: 0, focusOffset: 1 });
await waitFor(".o-we-toolbar");
click(".o-we-toolbar .btn[name='font']");
await waitFor(".o_font_selector_menu");
expect(`.o_font_selector_menu .o-dropdown-item:contains('Small')`).toHaveCount(1);
click(".o_font_selector_menu .o-dropdown-item:contains('Small')");
await waitForNone(".o_font_selector_menu");
expect(".o-we-toolbar .btn[name='font']").toHaveText("Small");
expect(editor.editable.querySelector("p")).toHaveClass("small");
});
});

View file

@ -0,0 +1,183 @@
import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { undo } from "@html_editor/../tests/_helpers/user_actions";
import { describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
describe.current.tags("desktop");
describe("replicate changes", () => {
test("translated elements", async () => {
const { getEditor } = await setupHTMLBuilder("", {
headerContent: `
<div class="test-1">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
<div class="test-2">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
`,
});
queryOne(":iframe .test-2 span").append(" ici");
const editor = getEditor();
editor.shared.history.addStep();
expect(":iframe span:contains(Contactez-nous ici)").toHaveCount(2);
});
test("link and non-link elements", async () => {
const { getEditor } = await setupHTMLBuilder(
`
<div class="test-4">
<a data-oe-xpath="/t[1]/nav[1]/div[1]/div[1]/t[2]/ul[1]/li[2]/a[1]/" href="/blog/travel-1" data-oe-model="blog.blog" data-oe-id="1" data-oe-field="name" data-oe-type="char" data-oe-expression="nav_blog.name">Travel</a>
</div>
`,
{
headerContent: `
<div class="test-1">
<b data-oe-xpath="/t[1]/nav[1]/div[1]/ul[1]/li[3]/a[1]/b[1]" data-oe-model="blog.blog" data-oe-id="1" data-oe-field="name" data-oe-type="char" data-oe-expression="nav_blog.name">Travel</b>
</div>
<div class="test-2">
<a data-oe-xpath="/t[1]/div[1]/div[1]/b[1]/a[1]" href="/blog/travel-1" data-oe-model="blog.post" data-oe-id="1" data-oe-field="blog_id" data-oe-type="many2one" data-oe-expression="blog_post.blog_id" data-oe-many2one-id="1" data-oe-many2one-model="blog.blog">Travel</a>
</div>
<div class="test-3">
<span data-oe-xpath="/t[1]/nav[1]/div[1]/div[1]/t[2]/ul[1]/li[2]/a[1]/span[1]" data-oe-model="blog.blog" data-oe-id="1" data-oe-field="name" data-oe-type="char" data-oe-expression="nav_blog.name">Travel</span>
</div>
`,
}
);
const editor = getEditor();
queryOne(":iframe .test-1 b").append(" Abroad");
editor.shared.history.addStep();
expect(":iframe .test-1 b").toHaveText("Travel Abroad");
expect(":iframe .test-2 a").toHaveText("Travel Abroad");
expect(":iframe .test-3 span").toHaveText("Travel Abroad");
expect(":iframe .test-4 a").toHaveInnerHTML("\u{FEFF}Travel Abroad\u{FEFF}"); // link in editable get feff
queryOne(":iframe .test-4 a").append("!"); // the feff should not be forwarded
editor.shared.history.addStep();
expect(":iframe .test-1 b").toHaveText("Travel Abroad!");
expect(":iframe .test-2 a").toHaveText("Travel Abroad!");
expect(":iframe .test-3 span").toHaveText("Travel Abroad!");
expect(":iframe .test-4 a").toHaveInnerHTML("\u{FEFF}Travel Abroad!\u{FEFF}");
});
test("menu items", async () => {
const { getEditor } = await setupHTMLBuilder("", {
headerContent: `
<div class="test-1">
<span data-oe-model="website.menu" data-oe-id="5" data-oe-field="name" data-oe-type="char" data-oe-expression="submenu.name">Home</span>
</div>
<div class="test-2">
<span data-oe-model="website.menu" data-oe-id="5" data-oe-field="name" data-oe-type="char" data-oe-expression="submenu.name">Home</span>
</div>
`,
});
queryOne(":iframe .test-1 span").append("y");
const editor = getEditor();
editor.shared.history.addStep();
expect(":iframe span:contains(Homey)").toHaveCount(2);
});
test("contact", async () => {
const { getEditor } = await setupHTMLBuilder("", {
headerContent: `
<div class="test-1">
<span data-oe-xpath="/t[1]/div[1]/div[2]/span[1]" data-oe-model="blog.post" data-oe-id="1" data-oe-field="author_id" data-oe-type="contact" data-oe-expression="blog_post.author_id" data-oe-many2one-id="3" data-oe-many2one-model="res.partner" data-oe-contact-options="{&quot;widget&quot;: &quot;contact&quot;, &quot;fields&quot;: [&quot;name&quot;], &quot;tagName&quot;: &quot;span&quot;, &quot;expression&quot;: &quot;blog_post.author_id&quot;, &quot;type&quot;: &quot;contact&quot;, &quot;inherit_branding&quot;: true, &quot;translate&quot;: false}">
<address class="o_portal_address mb-0">
<div>
<span itemprop="name">YourCompany, Mitchell Admin</span>
</div>
<div class="gap-2" itemscope="itemscope" itemtype="http://schema.org/PostalAddress">
<div itemprop="telephone"></div>
</div>
</address>
</span>
</div>
<div class="test-2">
<span data-oe-xpath="/t[1]/div[1]/div[2]/span[1]" data-oe-model="blog.post" data-oe-id="1" data-oe-field="author_id" data-oe-type="contact" data-oe-expression="blog_post.author_id" data-oe-many2one-id="3" data-oe-many2one-model="res.partner" data-oe-contact-options="{&quot;widget&quot;: &quot;contact&quot;, &quot;fields&quot;: [&quot;name&quot;], &quot;tagName&quot;: &quot;span&quot;, &quot;expression&quot;: &quot;blog_post.author_id&quot;, &quot;type&quot;: &quot;contact&quot;, &quot;inherit_branding&quot;: true, &quot;translate&quot;: false}">
<address class="o_portal_address mb-0">
<div>
<span itemprop="name">YourCompany, Mitchell Admin</span>
</div>
<div class="gap-2" itemscope="itemscope" itemtype="http://schema.org/PostalAddress">
<div itemprop="telephone"></div>
</div>
</address>
</span>
</div>
`,
});
queryOne(":iframe .test-1 > *").append("changed");
const editor = getEditor();
editor.shared.history.addStep();
expect(":iframe .test-1 > *").toHaveText(/changed/);
expect(":iframe .test-2 > *").toHaveText(/changed/);
});
test("should not add o_dirty marks on the ones receiving the replicated changes", async () => {
const { getEditor } = await setupHTMLBuilder("", {
headerContent: `
<div class="test-1">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
<div class="test-2">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
<div class="test-3">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
`,
});
const span1 = queryOne(":iframe .test-1 span");
const span2 = queryOne(":iframe .test-2 span");
const span3 = queryOne(":iframe .test-3 span");
const editor = getEditor();
span2.append(" ici");
editor.shared.history.addStep();
expect(span1).not.toHaveClass("o_dirty");
expect(span2).toHaveClass("o_dirty");
expect(span3).not.toHaveClass("o_dirty");
expect([span1, span2, span3]).toHaveText("Contactez-nous ici");
span1.append("!");
editor.shared.history.addStep();
expect(span1).toHaveClass("o_dirty");
expect(span2).toHaveClass("o_dirty");
expect(span3).not.toHaveClass("o_dirty");
expect([span1, span2, span3]).toHaveText("Contactez-nous ici!");
undo(editor);
expect(span1).not.toHaveClass("o_dirty");
expect(span2).toHaveClass("o_dirty");
expect(span3).not.toHaveClass("o_dirty");
expect([span1, span2, span3]).toHaveText("Contactez-nous ici");
});
test("changing several of occurences at the same time should converge to the same value", async () => {
const { getEditor } = await setupHTMLBuilder("", {
headerContent: `
<div class="test-1">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
<div class="test-2">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
<div class="test-3">
<span data-oe-model="ir.ui.view" data-oe-id="600" data-oe-field="arch_db" data-oe-translation-state="translated" data-oe-translation-source-sha="4242">Contactez-nous</span>
</div>
`,
});
const span1 = queryOne(":iframe .test-1 span");
const span2 = queryOne(":iframe .test-2 span");
const span3 = queryOne(":iframe .test-3 span");
span2.append(" ici");
span1.append("!");
const editor = getEditor();
editor.shared.history.addStep();
expect(span1).toHaveClass("o_dirty");
expect(span2).toHaveClass("o_dirty");
expect(span3).not.toHaveClass("o_dirty");
expect([span2, span3]).toHaveText(span1.textContent); // all the same text
});
});

View file

@ -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);
}

View file

@ -0,0 +1,16 @@
import { setupHTMLBuilder, dummyBase64Img } 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("image field should not be editable, but the image can be replaced", async () => {
await setupHTMLBuilder(
`<div data-oe-model="product.product" data-oe-id="12" data-oe-field="image_1920" data-oe-type="image" data-oe-expression="product_image.image_1920">
<img src="${dummyBase64Img}" alt="Product Image" style="max-width: 100%;"/>
</div>`
);
expect(":iframe img").toHaveProperty("isContentEditable", false);
await contains(":iframe img").click();
expect("span:contains('Double-click to edit')").toHaveCount(1);
});

View file

@ -0,0 +1,46 @@
import { Img } from "@html_builder/core/img";
import { ImgGroup } from "@html_builder/core/img_group";
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test, describe } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels(); // meh
test("ImgGroup's inner Img components should not be blocked before src load", async () => {
const defs = {
img1: new Deferred(),
img2: new Deferred(),
img3: new Deferred(),
};
patchWithCleanup(Img.prototype, {
loadImage() {
const def = defs[this.props.class];
return Promise.all([super.loadImage(), def]);
},
});
class Container extends Component {
static components = { ImgGroup, Img };
static template = xml`
<ImgGroup>
<t t-foreach="Object.keys(defs)" t-as="key" t-key="key">
<Img src="''" class="key"/>
</t>
</ImgGroup>`;
static props = {};
setup() {
this.defs = defs;
}
}
await mountWithCleanup(Container);
for (const key in defs) {
expect("img").toHaveCount(0);
defs[key].resolve();
await animationFrame();
}
expect("img").toHaveCount(3);
});

View file

@ -0,0 +1,54 @@
import { setupHTMLBuilder } from "@html_builder/../tests/helpers";
import { expect, test, describe } from "@odoo/hoot";
import { animationFrame, press } from "@odoo/hoot-dom";
import { contains, onRpc } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
test("should prevent edition in many2one field", async () => {
await setupHTMLBuilder(
`<a data-oe-model="blog.post" data-oe-id="3" data-oe-field="blog_id" data-oe-type="many2one" data-oe-expression="blog_post.blog_id" data-oe-many2one-id="1" data-oe-many2one-model="blog.blog">
Travel
</a>`
);
expect(":iframe a").toHaveProperty("isContentEditable", false);
});
test.tags("desktop"); // NavigationItem only react to mouvemove which is not triggered in test for mobile
test("Preview changes of many2one option", async () => {
onRpc(
"ir.qweb.field.contact",
"get_record_to_html",
({ args: [[id]], kwargs }) => `<span>The ${kwargs.options.option} of ${id}</span>`
);
await setupHTMLBuilder(`
<div>
<span class="span-1" data-oe-model="blog.post" data-oe-id="3" data-oe-field="author_id" data-oe-type="contact" data-oe-many2one-id="3" data-oe-many2one-model="res.partner" data-oe-contact-options='{"option": "Name"}'>
<span>The Name of 3</span>
</span>
<span class="span-2" data-oe-model="blog.post" data-oe-id="3" data-oe-field="author_id" data-oe-type="contact" data-oe-many2one-id="3" data-oe-many2one-model="res.partner" data-oe-contact-options='{"option": "Address"}'>
<span>The Address of 3</span>
</span>
<span class="span-3" data-oe-model="blog.post" data-oe-id="6" data-oe-field="author_id" data-oe-type="contact" data-oe-many2one-id="3" data-oe-many2one-model="res.partner" data-oe-contact-options='{"option": "Address"}'>
<span>The Address of 3</span>
</span>
<span class="span-4" data-oe-model="blog.post" data-oe-id="3" data-oe-field="author_id" data-oe-type="other" data-oe-many2one-id="3" data-oe-many2one-model="res.partner">Other</span>
<div>
`);
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");
});

View file

@ -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(
`<span data-oe-model="product.template" data-oe-id="9" data-oe-field="list_price" data-oe-type="monetary" data-oe-expression="product.list_price">
$&nbsp;<span class="oe_currency_value">750.00</span>
</span>`
);
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(
`<span data-oe-model="product.template" data-oe-id="9" data-oe-field="list_price" data-oe-type="monetary" data-oe-expression="product.list_price">
$<span class="span-in-currency"/>&nbsp;<span class="oe_currency_value">750.00</span>
</span>`
);
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" });
});

View file

@ -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`<BuilderButton action="'customAction'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">TEST</div>`, {
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`
<BuilderRow label.translate="Type">
<BuilderSelect>
<BuilderSelectItem action="'customAction'" actionValue="'first'">first</BuilderSelectItem>
<BuilderSelectItem action="'customAction2'" actionValue="'second'">second</BuilderSelectItem>
</BuilderSelect>
</BuilderRow>
`,
});
await setupHTMLBuilder(`<div class="test-options-target">TEST</div>`);
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`<BuilderRow>
<BuilderColorPicker enabledTabs="['solid']" styleAction="'background-color'" action="'customAction'"/>
</BuilderRow>`,
});
await setupHTMLBuilder(`<div class="test-options-target">TEST</div>`);
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`
<BuilderButton action="'testAction'"/>
<BuilderButton classAction="'test'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
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(
'<div class="test-options-target o-paragraph test">b</div>'
);
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`
<BuilderButton action="'testAction'"/>
<BuilderButton classAction="'test'"/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
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(
'<div class="test-options-target o-paragraph test">b</div>'
);
// preview + commit
expect.verifyErrors(["This action should crash", "This action should crash"]);
});
});

View file

@ -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]);
});

View file

@ -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 = `
<div class="s_rating pt16 pb16" data-icon="fa-star" data-snippet="s_rating" data-name="Rating">
<strong class="s_rating_title">Quality</strong>
<div class="s_rating_icons o_not_editable">
<span class="s_rating_active_icons">
<i class="fa fa-star"></i>
<i class="fa fa-star"></i>
<i class="fa fa-star"></i>
</span>
<span class="s_rating_inactive_icons">
<i class="fa fa-star-o"></i>
<i class="fa fa-star-o"></i>
</span>
</div>
</div>`;
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(
`<strong class="s_rating_title">Quality</strong>
<div class="s_rating_icons o_not_editable" contenteditable="false">
<span class="s_rating_active_icons">
<i class="fa fa-star" contenteditable="false">
&ZeroWidthSpace;
</i>
</span>
<span class="s_rating_inactive_icons">
<i class="fa fa-star-o" contenteditable="false">
&ZeroWidthSpace;
</i>
<i class="fa fa-star-o" contenteditable="false">
&ZeroWidthSpace;
</i>
<i class="fa fa-star-o" contenteditable="false">
&ZeroWidthSpace;
</i>
</span>
</div>`
);
});
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");
});

View file

@ -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(`
<div class="s_hr">
<hr class="w-100">
</div>
`);
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);
});

View file

@ -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(
`<section><div class="container"><span class="inside">in</span></div></section>`,
{
headerContent: `<section><div class="container"><span class="outside">out</span></div></section>`,
}
);
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);
});

View file

@ -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`<ShadowOption/>`,
});
await setupHTMLBuilder(`<div class="test-options-target">b</div>`);
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(
'<div class="test-options-target o-paragraph">b</div>'
);
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(
'<div class="test-options-target o-paragraph shadow" style="box-shadow: rgba(0, 0, 0, 0.15) 0px 8px 16px 0px !important;">b</div>'
);
await contains('[data-action-param="offsetX"] input').fill(10);
await contains('[data-action-param="offsetY"] input').fill(2);
expect(":iframe .test-options-target").toHaveOuterHTML(
'<div class="test-options-target o-paragraph shadow" style="box-shadow: rgba(0, 0, 0, 0.15) 10px 82px 16px 0px !important;">b</div>'
);
await contains('[data-action-param="blur"] input').clear();
await contains('[data-action-param="blur"] input').fill(10.5);
expect(":iframe .test-options-target").toHaveOuterHTML(
'<div class="test-options-target o-paragraph shadow" style="box-shadow: rgba(0, 0, 0, 0.15) 10px 82px 10.5px 0px !important;">b</div>'
);
await contains('[data-action-param="spread"] input').fill(".4");
expect(":iframe .test-options-target").toHaveOuterHTML(
'<div class="test-options-target o-paragraph shadow" style="box-shadow: rgba(0, 0, 0, 0.15) 10px 82px 10.5px 0.4px !important;">b</div>'
);
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(
'<div class="test-options-target o-paragraph shadow" style="box-shadow: rgba(0, 0, 0, 0.15) 10px 82px 10.5px 0.4px inset !important;">b</div>'
);
await contains(".options-container button:contains(None)").click();
expect(queryAllTexts(".hb-row .hb-row-label")).toEqual(["Shadow"]);
expect(":iframe .test-options-target").toHaveOuterHTML(
'<div class="test-options-target o-paragraph" style="">b</div>'
);
});

View file

@ -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`<div t-att-data-letter="getLetter()"/>`;
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(`<div class="test-options-target">a</div>`);
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"]);
});
});