import { expect, test } from "@odoo/hoot"; import { setupEditor, testEditor } from "../_helpers/editor"; import { unformat } from "../_helpers/format"; import { setSelection, setContent, getContent } from "../_helpers/selection"; import { deleteBackward, insertText, undo } from "../_helpers/user_actions"; import { waitFor, waitForNone } from "@odoo/hoot-dom"; import { parseHTML } from "@html_editor/utils/html"; import { Plugin } from "@html_editor/plugin"; import { MAIN_PLUGINS } from "@html_editor/plugin_sets"; import { execCommand } from "../_helpers/userCommands"; test("should ignore protected elements children mutations (true)", async () => { await testEditor({ contentBefore: unformat(`

a[]

a

`), stepFunction: async (editor) => { await insertText(editor, "bc"); const protectedParagraph = editor.editable.querySelector( '[data-oe-protected="true"] > p' ); protectedParagraph.append(document.createTextNode("b")); editor.shared.history.addStep(); execCommand(editor, "historyUndo"); }, contentAfterEdit: unformat(`

ab[]

ab

`), }); }); test("should not ignore unprotected elements children mutations (false)", async () => { await testEditor({ contentBefore: unformat(`

a[]

a

`), stepFunction: async (editor) => { await insertText(editor, "bc"); const unProtectedParagraph = editor.editable.querySelector( '[data-oe-protected="false"] > p' ); setSelection({ anchorNode: unProtectedParagraph, anchorOffset: 1 }); await insertText(editor, "bc"); execCommand(editor, "historyUndo"); }, contentAfterEdit: unformat(`

abc

ab[]

`), }); }); test("should not normalize protected elements children (true)", async () => { await testEditor({ contentBefore: unformat(`

`), contentAfterEdit: unformat(`

\u200B

`), }); }); test("should not remove/merge empty (identical) protecting nodes", async () => { const { el, editor } = await setupEditor(`

[]

`); editor.shared.dom.insert(parseHTML(editor.document, ``)); editor.shared.history.addStep(); expect(getContent(el)).toBe( unformat( `

[]

` ) ); }); test("should normalize unprotected elements children (false)", async () => { await testEditor({ contentBefore: unformat(`

`), contentAfterEdit: unformat(`

\u200B

`), }); }); test("should not handle table selection in protected elements children (true)", async () => { await testEditor({ contentBefore: unformat(`

a[bc

a]bcdef
`), contentAfterEdit: unformat(`

a[bc

a]bcdef
`), }); }); test("should handle table selection in unprotected elements", async () => { await testEditor({ contentBefore: unformat(`

a[bc

a]bcdef
`), contentAfterEdit: unformat(`

a[bc

a]b cd ef
`), }); }); test("should not remove contenteditable attribute of a protected node", async () => { await testEditor({ contentBefore: unformat(`

content

ab

content

`), contentAfterEdit: unformat(`

content

ab

content

`), }); }); test("should not select a protected table even if it is contenteditable='true'", async () => { // Individually protected cells are not yet supported for simplicity // since there is no need for that currently. await testEditor({ contentBefore: unformat(`
[ab
cd]
`), contentAfterEdit: unformat(`
[ab
cd]
`), }); }); test("select a protected element shouldn't open the toolbar", async () => { const { el } = await setupEditor( `

[a]

b

c
` ); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(1); setContent( el, `

a

[b]

c
` ); await waitForNone(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(0); setContent( el, `

a

b

[c]
` ); await waitFor(".o-we-toolbar"); expect(".o-we-toolbar").toHaveCount(1); }); test("should protect disconnected nodes", async () => { const { editor, el, plugins } = await setupEditor( `

a

a

` ); const div = el.querySelector("div"); const protectedP = div.querySelector("p"); protectedP.remove(); div.remove(); editor.shared.history.addStep(); const lastStep = editor.shared.history.getHistorySteps().at(-1); expect(lastStep.mutations.length).toBe(1); expect(lastStep.mutations[0].type).toBe("remove"); expect(plugins.get("history").unserializeNode(lastStep.mutations[0].node).outerHTML).toBe( `
` ); }); test("should not crash when changing attributes and removing a protecting anchor", async () => { const { editor, el, plugins } = await setupEditor( `

a

a

` ); const div = el.querySelector("div"); div.dataset.attr = "other"; div.remove(); editor.shared.history.addStep(); const lastStep = editor.shared.history.getHistorySteps().at(-1); expect(lastStep.mutations.length).toBe(2); expect(lastStep.mutations[0].type).toBe("attributes"); expect(lastStep.mutations[1].type).toBe("remove"); expect(plugins.get("history").unserializeNode(lastStep.mutations[1].node).outerHTML).toBe( `

a

` ); }); test("removing a protected node should be undo-able", async () => { const { editor, el } = await setupEditor( `

a

[]a

` ); deleteBackward(editor); expect(getContent(el)).toBe(`

[]a

`); undo(editor); expect(getContent(el)).toBe( `

a

[]a

` ); }); test("removing a recursively protected then unprotected node should be undo-able", async () => { const { editor, el, plugins } = await setupEditor( unformat(`

a

b

c

d

w

x

y

[]z

`) ); const protectPlugin = plugins.get("protectedNode"); const protectingNodes = [...el.querySelectorAll(`[data-oe-protected="true"]`)]; const unprotectingNodes = [...el.querySelectorAll(`[data-oe-protected="false"]`)]; const unprotectedDescendants = []; for (const unprotectingNode of unprotectingNodes) { unprotectedDescendants.push([...unprotectingNode.childNodes]); } expect(protectPlugin.filterDescendantsToRemove(protectingNodes[0])).toEqual( unprotectedDescendants[0] ); expect(protectPlugin.filterDescendantsToRemove(protectingNodes[1])).toEqual( unprotectedDescendants[1] ); deleteBackward(editor); expect([...unprotectingNodes[0].childNodes]).toEqual([]); expect([...unprotectingNodes[1].childNodes]).toEqual([]); expect(getContent(el)).toBe(`

[]z

`); undo(editor); expect(getContent(el)).toBe( unformat(`

a

b

c

d

w

x

y

[]z

`) ); }); test("removing a protected node and then removing its protected parent should be ignored", async () => { const { editor, el, plugins } = await setupEditor( unformat(`
`) ); const historyPlugin = plugins.get("history"); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); const a = el.querySelector(".a"); const b = el.querySelector(".b"); b.remove(); a.remove(); editor.shared.history.addStep(); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); expect(getContent(el)).toBe(`
`); }); test("removing a protected ancestor, then a protected descendant, then its protected parent should be ignored", async () => { const { editor, el, plugins } = await setupEditor( unformat(`
`) ); const historyPlugin = plugins.get("history"); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); const a = el.querySelector(".a"); const b = el.querySelector(".b"); const c = el.querySelector(".c"); a.remove(); c.remove(); b.remove(); editor.shared.history.addStep(); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); expect(getContent(el)).toBe(`
`); }); test("moving a protected node at an unprotected location, only remove should be ignored", async () => { const { editor, el, plugins } = await setupEditor( unformat(`
`) ); const historyPlugin = plugins.get("history"); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); const a = el.querySelector(".a"); const b = el.querySelector(".b"); b.append(a); editor.shared.history.addStep(); const historySteps = editor.shared.history.getHistorySteps(); expect(historySteps.length).toBe(2); const lastStep = historySteps.at(-1); expect(lastStep.mutations.length).toBe(1); expect(lastStep.mutations[0].type).toBe("add"); expect(historyPlugin.idToNodeMap.get(lastStep.mutations[0].id)).toBe(a); expect(getContent(el)).toBe( unformat(`
`) ); }); test("moving an unprotected node at a protected location, only add should be ignored", async () => { const { editor, el, plugins } = await setupEditor( unformat(`
`) ); const historyPlugin = plugins.get("history"); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); const a = el.querySelector(".a"); const b = el.querySelector(".b"); b.append(a); editor.shared.history.addStep(); const historySteps = editor.shared.history.getHistorySteps(); expect(historySteps.length).toBe(2); const lastStep = historySteps.at(-1); expect(lastStep.mutations.length).toBe(1); expect(lastStep.mutations[0].type).toBe("remove"); expect(historyPlugin.idToNodeMap.get(lastStep.mutations[0].id)).toBe(a); expect(getContent(el)).toBe( unformat(`
`) ); }); test("sequentially added nodes under a protecting parent are correctly protected", async () => { const { editor, el, plugins } = await setupEditor( unformat(`
content
`) ); const protectedPlugin = plugins.get("protectedNode"); expect(editor.shared.history.getHistorySteps().length).toBe(1); const protecting = el.querySelector("[data-oe-protected='true']"); const element = editor.document.createElement("div"); const node = editor.document.createTextNode("a"); protecting.prepend(element); element.prepend(node); editor.shared.history.addStep(); expect(protectedPlugin.protectedNodes.has(element)).toBe(true); expect(protectedPlugin.protectedNodes.has(node)).toBe(true); expect(getContent(el)).toBe( unformat(`
a
content
`) ); node.remove(); editor.shared.history.addStep(); expect(getContent(el)).toBe( unformat(`
content
`) ); expect(editor.shared.history.getHistorySteps().length).toBe(1); }); test("don't protect a node under data-oe-protected='false' through delete and undo", async () => { const { editor, el, plugins } = await setupEditor( unformat(`

a

[]a

`) ); const protectedPlugin = plugins.get("protectedNode"); expect(editor.shared.history.getHistorySteps().length).toBe(1); const protecting = el.querySelector("[data-oe-protected='false']"); const paragraph = editor.document.createElement("p"); const node = editor.document.createTextNode("b"); protecting.prepend(paragraph); paragraph.prepend(node); editor.shared.history.addStep(); expect(editor.shared.history.getHistorySteps().length).toBe(2); expect(protectedPlugin.protectedNodes.has(paragraph)).toBe(false); expect(protectedPlugin.protectedNodes.has(node)).toBe(false); expect(getContent(el)).toBe( unformat(`

b

a

[]a

`) ); deleteBackward(editor); undo(editor); expect(getContent(el)).toBe( unformat(`

b

a

[]a

`) ); expect(editor.shared.history.getHistorySteps().length).toBe(4); }); test("protected plugin is robust against other plugins which can filter mutations", async () => { class FilterPlugin extends Plugin { static id = "filterPlugin"; resources = { savable_mutation_record_predicates: this.isMutationRecordSavable.bind(this), }; isMutationRecordSavable(record) { if ( record.type === "childList" && record.removedNodes.length === 1 && [...record.removedNodes][0] === a ) { // Artificially hide the removal of `a` node return false; } return true; } } const { editor, el, plugins } = await setupEditor( unformat(`
`), // Put FilterPlugin as the first plugin, so that its filter is applied before // protected_node_plugin. { config: { Plugins: [FilterPlugin, ...MAIN_PLUGINS] } } ); const historyPlugin = plugins.get("history"); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); const a = el.querySelector(".a"); const b = el.querySelector(".b"); a.remove(); b.remove(); editor.shared.history.addStep(); expect(editor.shared.history.getHistorySteps().length).toBe(1); expect(historyPlugin.currentStep.mutations).toEqual([]); expect(getContent(el)).toBe(`
`); });