Odoo18-Base/addons/html_editor/static/tests/embedded_components.test.js
2025-01-06 10:57:38 +07:00

1574 lines
69 KiB
JavaScript

import {
Counter,
embedding,
EmbeddedWrapper,
EmbeddedWrapperMixin,
namedCounter,
NamedCounter,
OffsetCounter,
offsetCounter,
SavedCounter,
savedCounter,
} from "@html_editor/../tests/_helpers/embedded_component";
import {
getEditableDescendants,
StateChangeManager,
} from "@html_editor/others/embedded_component_utils";
import { MAIN_PLUGINS } from "@html_editor/plugin_sets";
import { parseHTML } from "@html_editor/utils/html";
import { beforeEach, describe, expect, getFixture, test } from "@odoo/hoot";
import { click, queryFirst } from "@odoo/hoot-dom";
import { animationFrame, tick } from "@odoo/hoot-mock";
import {
App,
Component,
onMounted,
onPatched,
onWillDestroy,
onWillStart,
onWillUnmount,
useRef,
useState,
xml,
} from "@odoo/owl";
import { EmbeddedComponentPlugin } from "../src/others/embedded_component_plugin";
import { setupEditor } from "./_helpers/editor";
import { unformat } from "./_helpers/format";
import { getContent, setSelection } from "./_helpers/selection";
import { deleteBackward, deleteForward, redo, undo } from "./_helpers/user_actions";
import { makeMockEnv } from "@web/../tests/_framework/env_test_helpers";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { Deferred } from "@web/core/utils/concurrency";
import { Plugin } from "@html_editor/plugin";
import { dispatchClean, dispatchCleanForSave } from "./_helpers/dispatch";
function getConfig(components) {
return {
Plugins: [...MAIN_PLUGINS, EmbeddedComponentPlugin],
resources: {
embedded_components: components,
},
};
}
describe("Mount and Destroy embedded components", () => {
test("can mount a embedded component", async () => {
const { el } = await setupEditor(`<div><span data-embedded="counter"></span></div>`, {
config: getConfig([embedding("counter", Counter)]),
});
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:1</span></span></div>`
);
});
test("can mount a embedded component from a step", async () => {
const { el, editor } = await setupEditor(`<div>a[]b</div>`, {
config: getConfig([embedding("counter", Counter)]),
});
expect(getContent(el)).toBe(`<div>a[]b</div>`);
editor.shared.dom.insert(
parseHTML(editor.document, `<span data-embedded="counter"></span>`)
);
editor.shared.history.addStep();
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"></span>[]b</div>`
);
await animationFrame();
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]b</div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:1</span></span>[]b</div>`
);
});
test("embedded component are mounted and destroyed", async () => {
const steps = [];
class Test extends Counter {
setup() {
onMounted(() => {
steps.push("mounted");
expect(this.ref.el.isConnected).toBe(true);
});
onWillUnmount(() => {
steps.push("willunmount");
expect(this.ref.el.isConnected).toBe(true);
});
onWillDestroy(() => steps.push("willdestroy"));
}
}
const { el, editor } = await setupEditor(
`<div><span data-embedded="counter"></span></div>`,
{
config: getConfig([embedding("counter", Test)]),
}
);
expect(steps).toEqual(["mounted"]);
editor.destroy();
expect(steps).toEqual(["mounted", "willunmount", "willdestroy"]);
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"></span></div>`
);
});
test("embedded component are destroyed when deleted", async () => {
const steps = [];
class Test extends Counter {
setup() {
onMounted(() => {
steps.push("mounted");
expect(this.ref.el.isConnected).toBe(true);
});
onWillUnmount(() => {
steps.push("willunmount");
expect(this.ref.el?.isConnected).toBe(true);
});
}
}
const { el, editor } = await setupEditor(
`<div>a<span data-embedded="counter"></span>[]</div>`,
{
config: getConfig([embedding("counter", Test)]),
}
);
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
expect(steps).toEqual(["mounted"]);
deleteBackward(editor);
expect(steps).toEqual(["mounted", "willunmount"]);
expect(getContent(el)).toBe(`<div>a[]</div>`);
});
test("undo and redo a component insertion", async () => {
class Test extends Counter {
setup() {
onMounted(() => {
expect.step("mounted");
expect(this.ref.el.isConnected).toBe(true);
});
onWillUnmount(() => {
expect.step("willunmount");
expect(this.ref.el?.isConnected).toBe(true);
});
}
}
const { el, editor } = await setupEditor(`<div>a[]</div>`, {
config: getConfig([embedding("counter", Test)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, `<span data-embedded="counter"></span>`)
);
editor.shared.history.addStep();
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
undo(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`<div>a[]</div>`);
redo(editor);
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
editor.destroy();
expect.verifySteps(["willunmount"]);
});
test("undo and redo a component delete", async () => {
class Test extends Counter {
setup() {
onMounted(() => {
expect.step("mounted");
expect(this.ref.el.isConnected).toBe(true);
});
onWillUnmount(() => {
expect.step("willunmount");
expect(this.ref.el?.isConnected).toBe(true);
});
}
}
const { el, editor } = await setupEditor(
`<div>a<span data-embedded="counter"></span>[]</div>`,
{
config: getConfig([embedding("counter", Test)]),
}
);
editor.shared.history.stageSelection();
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
expect.verifySteps(["mounted"]);
deleteBackward(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`<div>a[]</div>`);
// now, we undo and check that component still works
undo(editor);
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"></span>[]</div>`
);
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:1</span></span>[]</div>`
);
redo(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`<div>a[]</div>`);
});
test("mount and destroy components after a savepoint", async () => {
class Test extends Counter {
setup() {
onMounted(() => {
expect.step("mounted");
});
onWillUnmount(() => {
expect.step("willunmount");
});
}
}
const { el, editor } = await setupEditor(
`<div>a<span data-embedded="counter"></span>[]</div>`,
{
config: getConfig([embedding("counter", Test)]),
}
);
editor.shared.history.stageSelection();
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
expect.verifySteps(["mounted"]);
const savepoint = editor.shared.history.makeSavePoint();
deleteBackward(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`<div>a[]</div>`);
editor.shared.dom.insert(
parseHTML(editor.document, `<span data-embedded="counter"></span>`)
);
editor.shared.history.addStep();
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
savepoint();
expect.verifySteps(["willunmount"]);
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`<div>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</div>`
);
editor.destroy();
expect.verifySteps(["willunmount"]);
});
test("embedded component plugin does not try to destroy the same subroot twice", async () => {
patchWithCleanup(EmbeddedComponentPlugin.prototype, {
destroyComponent() {
expect.step("destroy from plugin");
super.destroyComponent(...arguments);
},
});
class Test extends Counter {
setup() {
onWillDestroy(() => {
expect.step("willdestroy");
});
}
}
const { editor } = await setupEditor(
`<div>a<span data-embedded="counter"></span>[]</div>`,
{
config: getConfig([embedding("counter", Test)]),
}
);
deleteBackward(editor);
expect.verifySteps(["destroy from plugin", "willdestroy"]);
editor.destroy();
expect.verifySteps([]);
});
test("Can mount and destroy recursive embedded components in any order", async () => {
class RecursiveComponent extends Component {
static template = xml`
<div>
<div t-on-click="increment" t-att-class="'click count-' + props.index">Count:<t t-esc="state.value"/></div>
<div t-ref="innerEditable" t-att-class="'innerEditable-' + props.index"/>
</div>
`;
static props = {
innerValue: HTMLElement,
index: Number,
};
setup() {
this.innerEditableRef = useRef("innerEditable");
this.state = useState({
value: this.props.index,
});
onMounted(() => {
this.props.innerValue.dataset.oeProtected = "false";
this.props.innerValue.setAttribute("contenteditable", "true");
this.innerEditableRef.el.append(this.props.innerValue);
expect.step(`mount ${this.props.index}`);
});
onWillDestroy(() => {
expect.step(`destroy ${this.props.index}`);
});
}
increment() {
this.state.value++;
}
}
let index = 1;
const { el, editor, plugins } = await setupEditor(`<div class="target">[]</div>`, {
config: getConfig([
embedding("recursiveComponent", RecursiveComponent, (host) => {
const result = {
index,
innerValue: host.querySelector("[data-prop-name='innerValue']"),
};
index++;
return result;
}),
]),
});
editor.shared.dom.insert(
parseHTML(
editor.document,
unformat(`
<div data-embedded="recursiveComponent">
<div data-prop-name="innerValue" data-oe-protected="false">
<div data-embedded="recursiveComponent">
<div data-prop-name="innerValue" data-oe-protected="false">
<div data-embedded="recursiveComponent">
<div data-prop-name="innerValue" data-oe-protected="false">
<p>HELL</p>
</div>
</div>
</div>
</div>
</div>
</div>
`)
)
);
const indexOrder = [1, 0, 2];
const orderedMountInfos = [];
const embeddedComponentPlugin = plugins.get("embeddedComponents");
embeddedComponentPlugin.forEachEmbeddedComponentHost(el, (host, embedding) => {
orderedMountInfos.push([host, embedding]);
});
// Force mounting disorder.
for (const index of indexOrder) {
embeddedComponentPlugin.mountComponent(...orderedMountInfos[index]);
}
// Validate the step, but the mounting process already started.
editor.shared.history.addStep();
await animationFrame();
expect.verifySteps(["mount 1", "mount 2", "mount 3"]);
expect(getContent(el)).toBe(
unformat(`
<div class="target">
<div data-embedded="recursiveComponent" data-oe-protected="true" contenteditable="false">
<div>
<div class="click count-2">Count:2</div>
<div class="innerEditable-2">
<div data-prop-name="innerValue" data-oe-protected="false" contenteditable="true">
<div data-embedded="recursiveComponent" data-oe-protected="true" contenteditable="false">
<div>
<div class="click count-1">Count:1</div>
<div class="innerEditable-1">
<div data-prop-name="innerValue" data-oe-protected="false" contenteditable="true">
<div data-embedded="recursiveComponent" data-oe-protected="true" contenteditable="false">
<div>
<div class="click count-3">Count:3</div>
<div class="innerEditable-3">
<div data-prop-name="innerValue" data-oe-protected="false" contenteditable="true">
<p>HELL</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
[]</div>
`)
);
for (const index of indexOrder) {
const host = orderedMountInfos[index][0];
await click(host.querySelector(".click"));
}
await animationFrame();
expect(el.querySelector(".count-1")).toHaveText("Count:2");
expect(el.querySelector(".count-2")).toHaveText("Count:3");
expect(el.querySelector(".count-3")).toHaveText("Count:4");
for (const index of indexOrder) {
const host = orderedMountInfos[index][0];
embeddedComponentPlugin.deepDestroyComponent({ host });
}
// Hierarchy is, referring to the index prop: 2 > 1 > 3
// destroying order is, by index prop: 1, 2, 3
// destroying 1 removes 3 from the dom, therefore 3 is destroyed in
// the process of destroying 1, that is why it is done before 2.
expect.verifySteps(["destroy 1", "destroy 3", "destroy 2"]);
// OWL:Root.destroy removes every node inside its host during destroy,
// so after the full operation, nothing should be left except the
// outermost host.
expect(getContent(el)).toBe(
unformat(`
<div class="target">
<div data-embedded="recursiveComponent" data-oe-protected="true" contenteditable="false"></div>
[]</div>
`)
);
// Verify that there is no potential host outside of the editable,
// because removed hosts are put back in the DOM and destroyed next to
// the editable element, before being removed again.
const fixture = getFixture();
expect(
[...fixture.querySelectorAll("[data-embedded]")].filter((elem) => {
return !elem.closest(".odoo-editor-editable");
})
).toEqual([]);
});
test("Can destroy a component from a removed host", async () => {
patchWithCleanup(EmbeddedComponentPlugin.prototype, {
destroyComponent({ host }) {
expect(this.editable.contains(host)).toBe(false);
super.destroyComponent(...arguments);
expect.step(`destroyed ${host.dataset.embedded}`);
},
});
const { editor, el } = await setupEditor(
`<div><span data-embedded="counter"></span>ALONE</div>`,
{
config: getConfig([embedding("counter", Counter)]),
}
);
const host = el.querySelector("[data-embedded='counter']");
host.remove();
editor.shared.history.addStep();
expect.verifySteps(["destroyed counter"]);
// Verify that there is no potential host outside of the editable,
// because removed hosts are put back in the DOM and destroyed next to
// the editable element, before being removed again.
const fixture = getFixture();
expect(
[...fixture.querySelectorAll("[data-embedded]")].filter((elem) => {
return !elem.closest(".odoo-editor-editable");
})
).toEqual([]);
});
test("Can destroy a component from a removed host's parent, and give the host back to the parent", async () => {
let hostElement;
patchWithCleanup(EmbeddedComponentPlugin.prototype, {
destroyComponent({ host }) {
hostElement = host;
expect(this.editable.contains(host)).toBe(false);
super.destroyComponent(...arguments);
expect.step(`destroyed ${host.dataset.embedded}`);
},
});
const { editor, el } = await setupEditor(
`<div><div class="parent"><span data-embedded="counter"></span></div>ALONE</div>`,
{
config: getConfig([embedding("counter", Counter)]),
}
);
const parent = el.querySelector(".parent");
parent.remove();
editor.shared.history.addStep();
expect.verifySteps(["destroyed counter"]);
// Verify that there is no potential host outside of the editable,
// because removed hosts are put back in the DOM and destroyed next to
// the editable element, before being removed again.
const fixture = getFixture();
expect(
[...fixture.querySelectorAll("[data-embedded]")].filter((elem) => {
return !elem.closest(".odoo-editor-editable");
})
).toEqual([]);
expect(editor.editable.contains(parent)).toBe(false);
expect(parent.contains(hostElement)).toBe(true);
});
});
describe("Selection after embedded component insertion", () => {
test("inline in empty paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]<br></p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, `<span data-embedded="counter">a</span>`)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`<p><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</p>`
);
});
test("inline at the end of paragraph", async () => {
const { el, editor } = await setupEditor(`<p>a[]</p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, `<span data-embedded="counter"></span>`)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`<p>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]</p>`
);
});
test("inline at the start of paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]a</p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, `<span data-embedded="counter"></span>`)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`<p><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]a</p>`
);
});
test("inline in the middle of paragraph", async () => {
const { el, editor } = await setupEditor(`<p>a[]b</p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, `<span data-embedded="counter"></span>`)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`<p>a<span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span>[]b</p>`
);
});
test("block in empty paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]<br></p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, `<div data-embedded="counter"></div>`));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
<div data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></div>
<p>[]<br></p>`)
);
});
test("block at the end of paragraph", async () => {
const { el, editor } = await setupEditor(`<p>a[]</p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, `<div data-embedded="counter"></div>`));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
<p>a</p>
<div data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></div>
<p>[]<br></p>`)
);
});
test("block at the start of paragraph", async () => {
const { el, editor } = await setupEditor(`<p>[]a</p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, `<div data-embedded="counter"></div>`));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
<div data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></div>
<p>[]a</p>`)
);
});
test("block in the middle of paragraph", async () => {
const { el, editor } = await setupEditor(`<p>a[]b</p>`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, `<div data-embedded="counter"></div>`));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
<p>a</p>
<div data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></div>
<p>[]b</p>`)
);
});
});
describe("Mount processing", () => {
test("embedded component get proper props", async () => {
class Test extends Counter {
static props = ["initialCount"];
setup() {
expect(this.props.initialCount).toBe(10);
this.state.value = this.props.initialCount;
}
}
const { el } = await setupEditor(`<div><span data-embedded="counter"></span></div>`, {
config: getConfig([embedding("counter", Test, () => ({ initialCount: 10 }))]),
});
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:10</span></span></div>`
);
});
test("embedded component can compute props from element", async () => {
class Test extends Counter {
static props = ["initialCount"];
setup() {
expect(this.props.initialCount).toBe(10);
this.state.value = this.props.initialCount;
}
}
const { el } = await setupEditor(
`<div><span data-embedded="counter" data-count="10"></span></div>`,
{
config: getConfig([
embedding("counter", Test, (host) => ({
initialCount: parseInt(host.dataset.count),
})),
]),
}
);
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-count="10" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:10</span></span></div>`
);
});
test("embedded component can set attributes on host element", async () => {
class Test extends Counter {
static props = ["host"];
setup() {
const initialCount = parseInt(this.props.host.dataset.count);
this.state.value = initialCount;
}
increment() {
super.increment();
this.props.host.dataset.count = this.state.value;
}
}
const { el } = await setupEditor(
`<div><span data-embedded="counter" data-count="10"></span></div>`,
{
config: getConfig([embedding("counter", Test, (host) => ({ host }))]),
}
);
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-count="10" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:10</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-count="11" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:11</span></span></div>`
);
});
test("embedded component get proper env", async () => {
/** @type { any } */
let env;
class Test extends Counter {
setup() {
env = this.env;
}
}
const rootEnv = await makeMockEnv();
await setupEditor(`<div><span data-embedded="counter"></span></div>`, {
config: getConfig([embedding("counter", Test)]),
env: Object.assign(rootEnv, { somevalue: 1 }),
});
expect(env.somevalue).toBe(1);
});
test("Content within an embedded component host is removed when mounting", async () => {
const { el } = await setupEditor(`<div><span data-embedded="counter">hello</span></div>`, {
config: getConfig([embedding("counter", Counter)]),
});
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
});
test("Host child nodes are removed synchronously with the insertion of owl rendered nodes during mount", async () => {
const asyncControl = new Deferred();
asyncControl.then(() => {
expect.step("minimal asynchronous time");
});
patchWithCleanup(App.prototype, {
createRoot(Root, config) {
if (Root.name !== "LabeledCounter") {
return super.createRoot(...arguments);
}
const root = super.createRoot(...arguments);
const mount = root.mount;
root.mount = (target, options) => {
const result = mount(target, options);
if (target.dataset.embedded === "labeledCounter") {
const fiber = root.node.fiber;
const fiberComplete = fiber.complete;
fiber.complete = function () {
expect.step("html prop suppression");
asyncControl.resolve();
fiberComplete.call(this);
};
}
return result;
};
return root;
},
});
const delayedWillStart = new Deferred();
class LabeledCounter extends Counter {
static template = xml`
<span t-ref="root" class="counter" t-on-click="increment">
<span t-ref="label"/>:<t t-esc="state.value"/>
</span>
`;
static props = {
label: HTMLElement,
};
labelRef = useRef("label");
setup() {
onWillStart(async () => {
expect.step("willstart");
await delayedWillStart;
});
onMounted(() => {
this.props.label.dataset.oeProtected = "false";
this.props.label.setAttribute("contenteditable", "true");
this.labelRef.el.append(this.props.label);
expect.step("html prop insertion");
});
}
}
const { el } = await setupEditor(
`<div><span data-embedded="labeledCounter">
<span data-prop-name="label">Counter</span>
</span>[]a</div>`,
{
config: getConfig([
embedding("labeledCounter", LabeledCounter, (host) => ({
label: host.querySelector("[data-prop-name='label']"),
})),
]),
}
);
expect.verifySteps(["willstart"]);
delayedWillStart.resolve();
await animationFrame();
expect(getContent(el)).toBe(
unformat(`
<div>
<span data-embedded="labeledCounter" data-oe-protected="true" contenteditable="false">
<span class="counter">
<span>
<span data-prop-name="label" data-oe-protected="false" contenteditable="true">Counter</span>
</span>
:0
</span>
</span>
[]a
</div>
`)
);
expect.verifySteps([
"html prop suppression",
"html prop insertion",
"minimal asynchronous time",
]);
});
test("Ignore unknown data-embedded types for mounting", async () => {
patchWithCleanup(EmbeddedComponentPlugin.prototype, {
handleComponents() {
const getEmbedding = this.getEmbedding;
this.getEmbedding = (host) => {
expect.step(`${host.dataset.embedded} handled`);
return getEmbedding.call(this, host);
};
super.handleComponents(...arguments);
this.getEmbedding = getEmbedding;
},
mountComponent(host) {
super.mountComponent(...arguments);
expect.step(`${host.dataset.embedded} mounted`);
},
});
const { el } = await setupEditor(`<div data-embedded="unknown"><p>UNKNOWN</p></div>`, {
config: getConfig([]),
});
// "unknown" data-embedded should be considered once during the first
// mounting wave.
expect.verifySteps(["unknown handled"]);
expect(getContent(el)).toBe(`<div data-embedded="unknown"><p>UNKNOWN</p></div>`);
});
test("Mount a component with a plugin that modifies the Component's env", async () => {
let setSelection;
class SimplePlugin extends Plugin {
static id = "simple";
static dependencies = ["selection", "embeddedComponents", "dom", "history"];
resources = {
mount_component_handlers: this.setupNewComponent.bind(this),
};
setupNewComponent({ name, env }) {
if (name === "embeddedCounter") {
Object.assign(env, {
...this.dependencies.selection,
});
}
}
insertElement(element) {
const html = parseHTML(this.document, element);
this.dependencies.dom.insert(html);
this.dependencies.history.addStep();
}
}
class EmbeddedCounter extends Counter {
static template = xml`
<span class="counter" t-on-click="increment">
<t t-esc="state.value"/>
</span>
`;
setup() {
super.setup();
setSelection = this.env.setSelection;
}
}
const config = getConfig([embedding("embeddedCounter", EmbeddedCounter)]);
config.Plugins.push(SimplePlugin);
const { plugins } = await setupEditor(`<div>[]a</div>`, { config });
const simplePlugin = plugins.get("simple");
simplePlugin.insertElement("<div data-embedded='embeddedCounter'/>");
await animationFrame();
expect(setSelection).toBe(simplePlugin.dependencies.selection.setSelection);
});
});
describe("In-editor manipulations", () => {
test("select content of a component shouldn't open the toolbar", async () => {
const { el } = await setupEditor(
`<div><p>[a]</p><span data-embedded="counter"></span></div>`,
{
config: getConfig([embedding("counter", Counter)]),
}
);
await animationFrame();
expect(".o-we-toolbar").toHaveCount(1);
expect(getContent(el)).toBe(
`<div><p>[a]</p><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
const node = queryFirst(".counter", {}).firstChild;
setSelection({ anchorNode: node, anchorOffset: 1, focusNode: node, focusOffset: 3 });
await tick();
await animationFrame();
expect(getContent(el)).toBe(
`<div><p>a</p><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">C[ou]nter:0</span></span></div>`
);
expect(".o-we-toolbar").toHaveCount(0);
});
test("should remove embedded elements children during clean for save (on a clone)", async () => {
const { el, editor } = await setupEditor(
'<div><p>a</p></div><div data-embedded="counter"><p>a</p></div>',
{
config: getConfig([embedding("counter", Counter)]),
}
);
const clone = el.cloneNode(true);
dispatchCleanForSave(editor, { root: clone });
expect(getContent(clone)).toBe(`<div><p>a</p></div><div data-embedded="counter"></div>`);
});
test("should not remove embedded elements children during clean (not a clone)", async () => {
const { el, editor } = await setupEditor(
'<div><p>a</p></div><div data-embedded="counter"><p>a</p></div>',
{
config: getConfig([embedding("counter", Counter)]),
}
);
dispatchClean(editor);
expect(getContent(el)).toBe(
`<div><p>a</p></div><div data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></div>`
);
});
test("should ignore embedded elements children during serialization", async () => {
const { el, plugins } = await setupEditor(
`<div><p>a</p></div><div data-embedded="counter"><p>a</p></div>`,
{
config: getConfig([embedding("counter", Counter)]),
}
);
const historyPlugin = plugins.get("history");
const node = historyPlugin._unserializeNode(historyPlugin.serializeNode(el))[0];
expect(getContent(node, { sortAttrs: true })).toBe(
`<div><p>a</p></div><div contenteditable="false" data-embedded="counter" data-oe-protected="true"></div>`
);
});
test("Ignore unknown data-embedded types for cleanforsave", async () => {
const { editor, el } = await setupEditor(
`<div data-embedded="unknown"><p>UNKNOWN</p></div>`,
{ config: getConfig([]) }
);
dispatchCleanForSave(editor, { root: el });
expect(getContent(el)).toBe(`<div data-embedded="unknown"><p>UNKNOWN</p></div>`);
});
test("Ignore unknown data-embedded types for serialization", async () => {
const { el, plugins } = await setupEditor(
`<div data-embedded="unknown"><p>UNKNOWN</p></div>`,
{ config: getConfig([]) }
);
const historyPlugin = plugins.get("history");
const node = historyPlugin._unserializeNode(historyPlugin.serializeNode(el))[0];
expect(getContent(node)).toBe(`<div data-embedded="unknown"><p>UNKNOWN</p></div>`);
});
});
describe("editable descendants", () => {
test("editable descendants are extracted and put back in place during mount", async () => {
const { el } = await setupEditor(
unformat(`
<div data-embedded="wrapper">
<div data-embedded-editable="shallow">
<p>shallow</p>
</div>
<div data-embedded-editable="deep">
<p>deep</p>
</div>
</div>
`),
{
config: getConfig([
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
expect(getContent(el)).toBe(
unformat(`
<div data-embedded="wrapper" data-oe-protected="true" contenteditable="false">
<div class="shallow">
<div data-embedded-editable="shallow" data-oe-protected="false" contenteditable="true">
<p>shallow</p>
</div>
</div>
<div>
<div class="deep">
<div data-embedded-editable="deep" data-oe-protected="false" contenteditable="true">
<p>deep</p>
</div>
</div>
</div>
</div>
`)
);
});
test("editable descendants are extracted and put back in place when a patch is changing the template shape", async () => {
let wrapper;
patchWithCleanup(EmbeddedWrapper.prototype, {
setup() {
super.setup();
wrapper = this;
onPatched(() => {
expect.step("patched");
});
},
});
const { editor, el, plugins } = await setupEditor(
unformat(`
<div data-embedded="wrapper">
<div data-embedded-editable="shallow">
<p>shallow</p>
</div>
<div data-embedded-editable="deep">
<p>deep</p>
</div>
</div>
`),
{
config: getConfig([
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
wrapper.state.switch = true;
await animationFrame();
expect.verifySteps(["patched"]);
expect(getContent(el)).toBe(
unformat(`
<div data-embedded="wrapper" data-oe-protected="true" contenteditable="false">
<div class="shallow">
<div data-embedded-editable="shallow" data-oe-protected="false" contenteditable="true">
<p>shallow</p>
</div>
</div>
<div>
<div class="switched">
<div class="deep">
<div data-embedded-editable="deep" data-oe-protected="false" contenteditable="true">
<p>deep</p>
</div>
</div>
</div>
</div>
</div>
`)
);
// No mutation should be added to the next step
editor.shared.history.addStep();
const historyPlugin = plugins.get("history");
const historySteps = editor.shared.history.getHistorySteps();
expect(historySteps.length).toBe(1);
expect(historyPlugin.currentStep.mutations).toEqual([]);
});
test("editable descendants are extracted and put back in place during cleanforsave", async () => {
const { el, editor } = await setupEditor(
unformat(`
<div data-embedded="wrapper">
<div data-embedded-editable="shallow">
<p>shallow</p>
</div>
<div data-embedded-editable="deep">
<p>deep</p>
</div>
</div>
`),
{
config: getConfig([
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
const clone = el.cloneNode(true);
dispatchCleanForSave(editor, { root: clone });
expect(getContent(clone)).toBe(
unformat(`
<div data-embedded="wrapper">
<div data-embedded-editable="shallow">
<p>shallow</p>
</div>
<div data-embedded-editable="deep">
<p>deep</p>
</div>
</div>
`)
);
});
test("editable descendants are extracted and put back in place during serialization", async () => {
const { el, plugins } = await setupEditor(
unformat(`
<div data-embedded="wrapper">
<div data-embedded-editable="shallow">
<p>shallow</p>
</div>
<div data-embedded-editable="deep">
<p>deep</p>
</div>
</div>
`),
{
config: getConfig([
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
const historyPlugin = plugins.get("history");
const node = historyPlugin._unserializeNode(historyPlugin.serializeNode(el))[0];
expect(getContent(node, { sortAttrs: true })).toBe(
unformat(`
<div contenteditable="false" data-embedded="wrapper" data-oe-protected="true">
<div contenteditable="true" data-embedded-editable="shallow" data-oe-protected="false">
<p>shallow</p>
</div>
<div contenteditable="true" data-embedded-editable="deep" data-oe-protected="false">
<p>deep</p>
</div>
</div>
`)
);
});
test("can discriminate own editable descendants from editable descendants of a descendant", async () => {
const SimpleEmbeddedWrapper = EmbeddedWrapperMixin("deep");
const { el } = await setupEditor(
unformat(`
<div data-embedded="wrapper">
<div data-embedded-editable="shallow">
<div data-embedded="simpleWrapper">
<div data-embedded-editable="deep">
<p>simple-deep</p>
</div>
</div>
</div>
<div data-embedded-editable="deep">
<p>wrapper-deep</p>
</div>
</div>
`),
{
config: getConfig([
embedding("simpleWrapper", SimpleEmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
expect(getContent(el)).toBe(
unformat(`
<div data-embedded="wrapper" data-oe-protected="true" contenteditable="false">
<div class="shallow">
<div data-embedded-editable="shallow" data-oe-protected="false" contenteditable="true">
<div data-embedded="simpleWrapper" data-oe-protected="true" contenteditable="false">
<div class="deep">
<div data-embedded-editable="deep" data-oe-protected="false" contenteditable="true">
<p>simple-deep</p>
</div>
</div>
</div>
</div>
</div>
<div>
<div class="deep">
<div data-embedded-editable="deep" data-oe-protected="false" contenteditable="true">
<p>wrapper-deep</p>
</div>
</div>
</div>
</div>
`)
);
const wrapper = el.querySelector(`[data-embedded="wrapper"]`);
const simple = el.querySelector(`[data-embedded="simpleWrapper"]`);
const editableDescendants = el.querySelectorAll(`[data-embedded-editable="deep"]`);
expect(getEditableDescendants(simple).deep).toBe(editableDescendants[0]);
expect(getEditableDescendants(wrapper).deep).toBe(editableDescendants[1]);
});
});
describe("Embedded state", () => {
beforeEach(() => {
let id = 1;
patchWithCleanup(StateChangeManager.prototype, {
generateId: () => id++,
});
});
test("Write on the embedded state should re-render the component, write on `data-embedded-state` and write on `data-embedded-props`", async () => {
let counter;
patchWithCleanup(OffsetCounter.prototype, {
setup() {
super.setup();
counter = this;
},
});
const { el } = await setupEditor(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":0}'></span></div>`,
{ config: getConfig([offsetCounter]) }
);
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":0}' data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
counter.embeddedState.baseValue = 2;
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"baseValue":0},"next":{"baseValue":2}}'><span class="counter">Counter:2</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"baseValue":0},"next":{"baseValue":2}}'><span class="counter">Counter:3</span></span></div>`
);
expect(counter.embeddedState).toEqual({
baseValue: 2,
});
expect(counter.state).toEqual({
value: 1,
});
});
test("Adding a new property in the embedded state should re-render and write on embedded attributes", async () => {
let counter;
patchWithCleanup(SavedCounter.prototype, {
setup() {
super.setup();
counter = this;
},
});
const { el, editor } = await setupEditor(
`<div><span data-embedded="counter"></span></div>`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{},"next":{"value":1}}' data-embedded-props='{"value":1}'><span class="counter">Counter:1</span></span></div>`
);
expect(counter.embeddedState).toEqual({
value: 1,
});
// `data-embedded-state` should be removed from editor.getElContent result
expect(getContent(editor.getElContent())).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"value":1}'></span></div>`
);
});
test("Removing an existing property in the embedded state should re-render and write on embedded attributes", async () => {
let counter;
patchWithCleanup(SavedCounter.prototype, {
setup() {
super.setup();
counter = this;
},
});
const { el, editor } = await setupEditor(
`<div><span data-embedded="counter" data-embedded-props='{"value":1}'></span></div>`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"value":1}' data-oe-protected="true" contenteditable="false"><span class="counter">Counter:1</span></span></div>`
);
delete counter.embeddedState.value;
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props="{}" data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"value":1},"next":{}}'><span class="counter">Counter:0</span></span></div>`
);
expect(counter.embeddedState).toEqual({});
// `data-embedded-state` should be removed from editor.getElContent result
expect(getContent(editor.getElContent())).toBe(
`<div><span data-embedded="counter" data-embedded-props="{}"></span></div>`
);
});
test("Write on `data-embedded-state` should write on the state, re-render the component and write on `data-embedded-props` and the embedded state", async () => {
let counter;
patchWithCleanup(OffsetCounter.prototype, {
setup() {
super.setup();
counter = this;
},
});
const { editor, el } = await setupEditor(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":0}'></span></div>`,
{ config: getConfig([offsetCounter]) }
);
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":0}' data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
counter.props.host.dataset.embeddedState = JSON.stringify({
stateChangeId: -1,
previous: {
baseValue: 1,
},
next: {
baseValue: 5,
},
});
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":4}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":-1,"previous":{"baseValue":1},"next":{"baseValue":5}}'><span class="counter">Counter:4</span></span></div>`
);
expect(counter.embeddedState).toEqual({
baseValue: 4,
});
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-embedded-props='{"baseValue":4}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":-1,"previous":{"baseValue":1},"next":{"baseValue":5}}'><span class="counter">Counter:5</span></span></div>`
);
expect(counter.embeddedState).toEqual({
baseValue: 4,
});
expect(counter.state).toEqual({
value: 1,
});
});
test("Re-write the same value on `data-embedded-state` does not update the embedded state", async () => {
let counter;
patchWithCleanup(SavedCounter.prototype, {
setup() {
super.setup();
counter = this;
onPatched(() => {
expect.step("patched");
});
},
});
const { el } = await setupEditor(`<div><span data-embedded="counter"></span></div>`, {
config: getConfig([savedCounter]),
});
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{},"next":{"value":1}}' data-embedded-props='{"value":1}'><span class="counter">Counter:1</span></span></div>`
);
expect.verifySteps(["patched"]);
counter.props.host.dataset.embeddedState = JSON.stringify({
stateChangeId: 1,
previous: {},
next: {
value: 1,
},
});
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{},"next":{"value":1}}' data-embedded-props='{"value":1}'><span class="counter">Counter:1</span></span></div>`
);
expect.verifySteps([]);
});
test("Re-write the same value on the embedded state does not write on `data-embedded-state`", async () => {
let counter;
patchWithCleanup(SavedCounter.prototype, {
setup() {
super.setup();
counter = this;
onPatched(() => {
expect.step("patched");
});
},
});
const { el } = await setupEditor(`<div><span data-embedded="counter"></span></div>`, {
config: getConfig([savedCounter]),
});
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false"><span class="counter">Counter:0</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{},"next":{"value":1}}' data-embedded-props='{"value":1}'><span class="counter">Counter:1</span></span></div>`
);
expect.verifySteps(["patched"]);
counter.embeddedState.value = 1;
await animationFrame();
expect(getContent(el)).toBe(
`<div><span data-embedded="counter" data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{},"next":{"value":1}}' data-embedded-props='{"value":1}'><span class="counter">Counter:1</span></span></div>`
);
expect.verifySteps([]);
});
test("Embedded state evolves during undo and redo", async () => {
const { el, editor } = await setupEditor(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}'></span></div>`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}' data-oe-protected="true" contenteditable="false"><span class="counter">Counter:1</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"value":1},"next":{"value":2}}'><span class="counter">Counter:2</span></span></div>`
);
undo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":2,"previous":{"value":2},"next":{"value":1}}'><span class="counter">Counter:1</span></span></div>`
);
redo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":3,"previous":{"value":1},"next":{"value":2}}'><span class="counter">Counter:2</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":3}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":4,"previous":{"value":2},"next":{"value":3}}'><span class="counter">Counter:3</span></span></div>`
);
undo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":5,"previous":{"value":3},"next":{"value":2}}'><span class="counter">Counter:2</span></span></div>`
);
redo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":3}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":6,"previous":{"value":2},"next":{"value":3}}'><span class="counter">Counter:3</span></span></div>`
);
});
test("Embedded state evolves during the restoration of a savePoint after makeSavePoint, even if the component was destroyed", async () => {
const { el, editor } = await setupEditor(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}'></span></div>`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}' data-oe-protected="true" contenteditable="false"><span class="counter">Counter:1</span></span></div>`
);
const savepoint1 = editor.shared.history.makeSavePoint();
await click(".counter");
await animationFrame();
const savepoint2 = editor.shared.history.makeSavePoint();
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":3}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":2,"previous":{"value":2},"next":{"value":3}}'><span class="counter">Counter:3</span></span></div>`
);
deleteForward(editor);
expect(getContent(el)).toBe(`<div>a[]</div>`);
savepoint2();
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":3,"previous":{"value":3},"next":{"value":2}}'><span class="counter">Counter:2</span></span></div>`
);
savepoint1();
await animationFrame();
// stateChangeId evolved from 3 to 6, since it reverted the last 3
// state changes.
// 2 -> 3, revert mutations created by savepoint2.
// 3 -> 2, revert mutations of the second click.
// 2 -> 1, revert mutations of the first click.
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":6,"previous":{"value":2},"next":{"value":1}}'><span class="counter">Counter:1</span></span></div>`
);
});
test("Embedded state changes are discarded if the component is destroyed before they are applied", async () => {
const { el, editor } = await setupEditor(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}'></span></div>`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":1}' data-oe-protected="true" contenteditable="false"><span class="counter">Counter:1</span></span></div>`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"value":1},"next":{"value":2}}'><span class="counter">Counter:2</span></span></div>`
);
// Launch click sequence without awaiting it
click(queryFirst(".counter"));
deleteForward(editor);
expect(getContent(el)).toBe(`<div>a[]</div>`);
undo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"value":1},"next":{"value":2}}'><span class="counter">Counter:2</span></span></div>`
);
});
test("Embedded state and embedded props can be different, if specified in the config of the stateChangeManager", async () => {
let counter;
patchWithCleanup(NamedCounter.prototype, {
setup() {
super.setup();
counter = this;
},
});
const { el, editor } = await setupEditor(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"name":"customName","value":1}'></span></div>`,
{ config: getConfig([namedCounter]) }
);
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"name":"customName","value":1}' data-oe-protected="true" contenteditable="false"><span class="counter">customName:4</span></span></div>`
);
// Only consider props supposed to be extracted from `data-embedded-props`
const props = {
name: counter.props.name,
value: counter.props.value,
};
expect(props).toEqual({
name: "customName",
value: 1,
});
expect(counter.embeddedState).toEqual({
baseValue: 3, // defined in the embedding (namedCounter)
value: 1, // recovered from the props
});
counter.embeddedState.baseValue = 5;
counter.embeddedState.value = 2;
await animationFrame();
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"name":"customName","value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"baseValue":3,"value":1},"next":{"baseValue":5,"value":2}}'><span class="counter">customName:7</span></span></div>`
);
deleteForward(editor);
undo(editor);
await animationFrame();
// Check that the base value was correctly reset after the destruction
expect(counter.embeddedState).toEqual({
baseValue: 3, // defined in the embedding (namedCounter)
value: 2, // recovered from the props
});
expect(getContent(el)).toBe(
`<div>a[]<span data-embedded="counter" data-embedded-props='{"name":"customName","value":2}' data-oe-protected="true" contenteditable="false" data-embedded-state='{"stateChangeId":1,"previous":{"baseValue":3,"value":1},"next":{"baseValue":5,"value":2}}'><span class="counter">customName:5</span></span></div>`
);
});
});