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 { addStep, deleteBackward, deleteForward, redo, undo } from "./_helpers/user_actions"; import { makeMockEnv, 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"; import { expectElementCount } from "./_helpers/ui_expectations"; 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`[]
`, { 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(`HELL
HELL
[]
[]
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) => !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( `ALONE
[]
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(`[]
[]
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
[]
[]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
`) ); }); 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
[]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` :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(`UNKNOWN
UNKNOWN
[]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( `[a]
[a]
Counter:0a
C[ou]nter:0a
a
a
a
a
a
a
a
a
UNKNOWN
UNKNOWN
UNKNOWN
UNKNOWN
shallow
deep
shallow
deep
[]after
`), { config: getConfig([ embedding("wrapper", SimpleEmbeddedWrapper, (host) => ({ host }), { getEditableDescendants, }), ]), }); editor.shared.dom.insert( parseHTML( editor.document, unformat(`deep
deep
[]after
`) ); undo(editor); await animationFrame(); expect(getContent(el)).toBe(`[]after
`); expect(plugins.get("history").currentStep.mutations.length).toBe(0); }); 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(`shallow
deep
shallow
deep
shallow
deep
shallow
deep
shallow
deep
shallow
deep
simple-deep
wrapper-deep
simple-deep
wrapper-deep
`, { 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("Removing a non-existing property in the embedded state should do nothing", async () => { let counter; patchWithCleanup(SavedCounter.prototype, { setup() { super.setup(); counter = this; }, }); const { el } = await setupEditor( `
`, { config: getConfig([savedCounter]) } ); expect(getContent(el)).toBe( `
Counter:1
` ); delete counter.embeddedState.notValue; await animationFrame(); expect(getContent(el)).toBe( `Counter:1
` ); expect(counter.embeddedState).toEqual({ value: 1, }); }); 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
` ); }); });