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(`
`),
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(`
`),
});
});
test("should not ignore unprotected elements children mutations (false)", async () => {
await testEditor({
contentBefore: unformat(`
`),
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(`
`),
});
});
test("should not normalize protected elements children (true)", async () => {
await testEditor({
contentBefore: unformat(`
`),
contentAfterEdit: unformat(`
`),
});
});
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(`
`),
});
});
test("should not handle table selection in protected elements children (true)", async () => {
await testEditor({
contentBefore: unformat(`
`),
contentAfterEdit: unformat(`
`),
});
});
test("should handle table selection in unprotected elements", async () => {
await testEditor({
contentBefore: unformat(`
`),
contentAfterEdit: unformat(`
`),
});
});
test("should not remove contenteditable attribute of a protected node", async () => {
await testEditor({
contentBefore: unformat(`
`),
contentAfterEdit: unformat(`
`),
});
});
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(`
`),
contentAfterEdit: unformat(`
`),
});
});
test("select a protected element shouldn't open the toolbar", async () => {
const { el } = await setupEditor(
``
);
await waitFor(".o-we-toolbar");
expect(".o-we-toolbar").toHaveCount(1);
setContent(
el,
``
);
await waitForNone(".o-we-toolbar");
expect(".o-we-toolbar").toHaveCount(0);
setContent(
el,
``
);
await waitFor(".o-we-toolbar");
expect(".o-we-toolbar").toHaveCount(1);
});
test("should protect disconnected nodes", async () => {
const { editor, el, plugins } = await setupEditor(
`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
`
);
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(
``
);
});
test("removing a protected node should be undo-able", async () => {
const { editor, el } = await setupEditor(
`[]a
`
);
deleteBackward(editor);
expect(getContent(el)).toBe(`[]a
`);
undo(editor);
expect(getContent(el)).toBe(
`[]a
`
);
});
test("removing a recursively protected then unprotected node should be undo-able", async () => {
const { editor, el, plugins } = await setupEditor(
unformat(`
[]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(`
[]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(`
`)
);
node.remove();
editor.shared.history.addStep();
expect(getContent(el)).toBe(
unformat(`
`)
);
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
`)
);
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(`
[]a
`)
);
deleteBackward(editor);
undo(editor);
expect(getContent(el)).toBe(
unformat(`
[]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(``);
});