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(`
`, {
config: getConfig([embedding("counter", Counter)]),
});
expect(getContent(el)).toBe(
`Counter:0
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`Counter:1
`
);
});
test("can mount a embedded component from a step", async () => {
const { el, editor } = await setupEditor(`a[]b
`, {
config: getConfig([embedding("counter", Counter)]),
});
expect(getContent(el)).toBe(`a[]b
`);
editor.shared.dom.insert(
parseHTML(editor.document, ``)
);
editor.shared.history.addStep();
expect(getContent(el)).toBe(
`a[]b
`
);
await animationFrame();
expect(getContent(el)).toBe(
`aCounter:0[]b
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`aCounter:1[]b
`
);
});
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(
`
`,
{
config: getConfig([embedding("counter", Test)]),
}
);
expect(steps).toEqual(["mounted"]);
editor.destroy();
expect(steps).toEqual(["mounted", "willunmount", "willdestroy"]);
expect(getContent(el)).toBe(
`
`
);
});
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(
`a[]
`,
{
config: getConfig([embedding("counter", Test)]),
}
);
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
expect(steps).toEqual(["mounted"]);
deleteBackward(editor);
expect(steps).toEqual(["mounted", "willunmount"]);
expect(getContent(el)).toBe(`a[]
`);
});
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(`a[]
`, {
config: getConfig([embedding("counter", Test)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, ``)
);
editor.shared.history.addStep();
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
undo(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`a[]
`);
redo(editor);
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
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(
`a[]
`,
{
config: getConfig([embedding("counter", Test)]),
}
);
editor.shared.history.stageSelection();
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
expect.verifySteps(["mounted"]);
deleteBackward(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`a[]
`);
// now, we undo and check that component still works
undo(editor);
expect(getContent(el)).toBe(
`a[]
`
);
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`aCounter:1[]
`
);
redo(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`a[]
`);
});
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(
`a[]
`,
{
config: getConfig([embedding("counter", Test)]),
}
);
editor.shared.history.stageSelection();
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
expect.verifySteps(["mounted"]);
const savepoint = editor.shared.history.makeSavePoint();
deleteBackward(editor);
expect.verifySteps(["willunmount"]);
expect(getContent(el)).toBe(`a[]
`);
editor.shared.dom.insert(
parseHTML(editor.document, ``)
);
editor.shared.history.addStep();
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
savepoint();
expect.verifySteps(["willunmount"]);
await animationFrame();
expect.verifySteps(["mounted"]);
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
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(
`a[]
`,
{
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`
`;
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(`[]
`, {
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(`
`)
)
);
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(`
`)
);
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(`
`)
);
// 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(
`ALONE
`,
{
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(
``,
{
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(`[]
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, `a`)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`Counter:0[]
`
);
});
test("inline at the end of paragraph", async () => {
const { el, editor } = await setupEditor(`a[]
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, ``)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`aCounter:0[]
`
);
});
test("inline at the start of paragraph", async () => {
const { el, editor } = await setupEditor(`[]a
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, ``)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`Counter:0[]a
`
);
});
test("inline in the middle of paragraph", async () => {
const { el, editor } = await setupEditor(`a[]b
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(
parseHTML(editor.document, ``)
);
editor.shared.history.addStep();
await animationFrame();
expect(getContent(el)).toBe(
`aCounter:0[]b
`
);
});
test("block in empty paragraph", async () => {
const { el, editor } = await setupEditor(`[]
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, ``));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
Counter:0
[]
`)
);
});
test("block at the end of paragraph", async () => {
const { el, editor } = await setupEditor(`a[]
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, ``));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
a
Counter:0
[]
`)
);
});
test("block at the start of paragraph", async () => {
const { el, editor } = await setupEditor(`[]a
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, ``));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
Counter:0
[]a
`)
);
});
test("block in the middle of paragraph", async () => {
const { el, editor } = await setupEditor(`a[]b
`, {
config: getConfig([embedding("counter", Counter)]),
});
editor.shared.dom.insert(parseHTML(editor.document, ``));
editor.shared.history.addStep();
await animationFrame();
dispatchClean(editor);
expect(getContent(el)).toBe(
unformat(`
a
Counter:0
[]b
`)
);
});
});
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(`
`, {
config: getConfig([embedding("counter", Test, () => ({ initialCount: 10 }))]),
});
expect(getContent(el)).toBe(
`Counter:10
`
);
});
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(
`
`,
{
config: getConfig([
embedding("counter", Test, (host) => ({
initialCount: parseInt(host.dataset.count),
})),
]),
}
);
expect(getContent(el)).toBe(
`Counter:10
`
);
});
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(
`
`,
{
config: getConfig([embedding("counter", Test, (host) => ({ host }))]),
}
);
expect(getContent(el)).toBe(
`Counter:10
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`Counter:11
`
);
});
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(`
`, {
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(`hello
`, {
config: getConfig([embedding("counter", Counter)]),
});
expect(getContent(el)).toBe(
`Counter:0
`
);
});
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`
:
`;
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(
`
Counter
[]a
`,
{
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(`
Counter
:0
[]a
`)
);
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(``, {
config: getConfig([]),
});
// "unknown" data-embedded should be considered once during the first
// mounting wave.
expect.verifySteps(["unknown handled"]);
expect(getContent(el)).toBe(``);
});
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`
`;
setup() {
super.setup();
setSelection = this.env.setSelection;
}
}
const config = getConfig([embedding("embeddedCounter", EmbeddedCounter)]);
config.Plugins.push(SimplePlugin);
const { plugins } = await setupEditor(`[]a
`, { config });
const simplePlugin = plugins.get("simple");
simplePlugin.insertElement("");
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(
``,
{
config: getConfig([embedding("counter", Counter)]),
}
);
await animationFrame();
expect(".o-we-toolbar").toHaveCount(1);
expect(getContent(el)).toBe(
``
);
const node = queryFirst(".counter", {}).firstChild;
setSelection({ anchorNode: node, anchorOffset: 1, focusNode: node, focusOffset: 3 });
await tick();
await animationFrame();
expect(getContent(el)).toBe(
``
);
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(
'',
{
config: getConfig([embedding("counter", Counter)]),
}
);
const clone = el.cloneNode(true);
dispatchCleanForSave(editor, { root: clone });
expect(getContent(clone)).toBe(``);
});
test("should not remove embedded elements children during clean (not a clone)", async () => {
const { el, editor } = await setupEditor(
'',
{
config: getConfig([embedding("counter", Counter)]),
}
);
dispatchClean(editor);
expect(getContent(el)).toBe(
`Counter:0
`
);
});
test("should ignore embedded elements children during serialization", async () => {
const { el, plugins } = await setupEditor(
``,
{
config: getConfig([embedding("counter", Counter)]),
}
);
const historyPlugin = plugins.get("history");
const node = historyPlugin._unserializeNode(historyPlugin.serializeNode(el))[0];
expect(getContent(node, { sortAttrs: true })).toBe(
``
);
});
test("Ignore unknown data-embedded types for cleanforsave", async () => {
const { editor, el } = await setupEditor(
``,
{ config: getConfig([]) }
);
dispatchCleanForSave(editor, { root: el });
expect(getContent(el)).toBe(``);
});
test("Ignore unknown data-embedded types for serialization", async () => {
const { el, plugins } = await setupEditor(
``,
{ config: getConfig([]) }
);
const historyPlugin = plugins.get("history");
const node = historyPlugin._unserializeNode(historyPlugin.serializeNode(el))[0];
expect(getContent(node)).toBe(``);
});
});
describe("editable descendants", () => {
test("editable descendants are extracted and put back in place during mount", async () => {
const { el } = await setupEditor(
unformat(`
`),
{
config: getConfig([
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
expect(getContent(el)).toBe(
unformat(`
`)
);
});
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(`
`),
{
config: getConfig([
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
wrapper.state.switch = true;
await animationFrame();
expect.verifySteps(["patched"]);
expect(getContent(el)).toBe(
unformat(`
`)
);
// 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(`
`),
{
config: getConfig([
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
const clone = el.cloneNode(true);
dispatchCleanForSave(editor, { root: clone });
expect(getContent(clone)).toBe(
unformat(`
`)
);
});
test("editable descendants are extracted and put back in place during serialization", async () => {
const { el, plugins } = await setupEditor(
unformat(`
`),
{
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(`
`)
);
});
test("can discriminate own editable descendants from editable descendants of a descendant", async () => {
const SimpleEmbeddedWrapper = EmbeddedWrapperMixin("deep");
const { el } = await setupEditor(
unformat(`
`),
{
config: getConfig([
embedding("simpleWrapper", SimpleEmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), {
getEditableDescendants,
}),
]),
}
);
expect(getContent(el)).toBe(
unformat(`
`)
);
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(
`
`,
{ config: getConfig([offsetCounter]) }
);
expect(getContent(el)).toBe(
`Counter:0
`
);
counter.embeddedState.baseValue = 2;
await animationFrame();
expect(getContent(el)).toBe(
`Counter:2
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`Counter:3
`
);
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(
`
`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`Counter:0
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`Counter:1
`
);
expect(counter.embeddedState).toEqual({
value: 1,
});
// `data-embedded-state` should be removed from editor.getElContent result
expect(getContent(editor.getElContent())).toBe(
`
`
);
});
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(
`
`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`Counter:1
`
);
delete counter.embeddedState.value;
await animationFrame();
expect(getContent(el)).toBe(
`Counter:0
`
);
expect(counter.embeddedState).toEqual({});
// `data-embedded-state` should be removed from editor.getElContent result
expect(getContent(editor.getElContent())).toBe(
`
`
);
});
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(
`
`,
{ config: getConfig([offsetCounter]) }
);
expect(getContent(el)).toBe(
`Counter:0
`
);
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(
`Counter:4
`
);
expect(counter.embeddedState).toEqual({
baseValue: 4,
});
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`Counter:5
`
);
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(`
`, {
config: getConfig([savedCounter]),
});
expect(getContent(el)).toBe(
`Counter:0
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`Counter:1
`
);
expect.verifySteps(["patched"]);
counter.props.host.dataset.embeddedState = JSON.stringify({
stateChangeId: 1,
previous: {},
next: {
value: 1,
},
});
await animationFrame();
expect(getContent(el)).toBe(
`Counter:1
`
);
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(`
`, {
config: getConfig([savedCounter]),
});
expect(getContent(el)).toBe(
`Counter:0
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`Counter:1
`
);
expect.verifySteps(["patched"]);
counter.embeddedState.value = 1;
await animationFrame();
expect(getContent(el)).toBe(
`Counter:1
`
);
expect.verifySteps([]);
});
test("Embedded state evolves during undo and redo", async () => {
const { el, editor } = await setupEditor(
`a[]
`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`a[]Counter:1
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:2
`
);
undo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:1
`
);
redo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:2
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:3
`
);
undo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:2
`
);
redo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:3
`
);
});
test("Embedded state evolves during the restoration of a savePoint after makeSavePoint, even if the component was destroyed", async () => {
const { el, editor } = await setupEditor(
`a[]
`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`a[]Counter:1
`
);
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(
`a[]Counter:3
`
);
deleteForward(editor);
expect(getContent(el)).toBe(`a[]
`);
savepoint2();
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:2
`
);
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(
`a[]Counter:1
`
);
});
test("Embedded state changes are discarded if the component is destroyed before they are applied", async () => {
const { el, editor } = await setupEditor(
`a[]
`,
{ config: getConfig([savedCounter]) }
);
expect(getContent(el)).toBe(
`a[]Counter:1
`
);
await click(".counter");
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:2
`
);
// Launch click sequence without awaiting it
click(queryFirst(".counter"));
deleteForward(editor);
expect(getContent(el)).toBe(`a[]
`);
undo(editor);
await animationFrame();
expect(getContent(el)).toBe(
`a[]Counter:2
`
);
});
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(
`a[]
`,
{ config: getConfig([namedCounter]) }
);
expect(getContent(el)).toBe(
`a[]customName:4
`
);
// 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(
`a[]customName:7
`
);
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(
`a[]customName:5
`
);
});
});