import { Plugin } from "@html_editor/plugin"; import { CORE_PLUGINS, MAIN_PLUGINS } from "@html_editor/plugin_sets"; import { describe, expect, test } from "@odoo/hoot"; import { click, hover, manuallyDispatchProgrammaticEvent, press, queryAllTexts, waitFor, } from "@odoo/hoot-dom"; import { animationFrame, tick } from "@odoo/hoot-mock"; import { applyConcurrentActions, mergePeersSteps, renderTextualSelection, setupMultiEditor, validateContent, } from "./_helpers/collaboration"; import { setupEditor } from "./_helpers/editor"; import { getContent } from "./_helpers/selection"; import { insertText, redo, undo } from "./_helpers/user_actions"; import { patchWithCleanup } from "@web/../tests/web_test_helpers"; import { PowerboxPlugin } from "@html_editor/main/powerbox/powerbox_plugin"; import { SearchPowerboxPlugin } from "@html_editor/main/powerbox/search_powerbox_plugin"; import { withSequence } from "@html_editor/utils/resource"; import { execCommand } from "./_helpers/userCommands"; function commandNames() { return queryAllTexts(".o-we-command-name"); } test("should open the Powerbox on type `/`", async () => { const { el, editor } = await setupEditor("

ab[]

"); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe("

ab[]

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); }); test.tags("iframe"); test("in iframe: should open the Powerbox on type `/`", async () => { const { el, editor } = await setupEditor("

ab[]

", { props: { iframe: true } }); expect("iframe").toHaveCount(1); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe("

ab[]

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); }); test("should correctly hint in iframes", async () => { const { el } = await setupEditor("

[]

", { props: { iframe: true } }); expect(getContent(el)).toBe( `

[]

` ); }); test("should open the Powerbox on type `/`, but in an empty paragraph", async () => { const { el, editor } = await setupEditor("

[]

"); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe( `

[]

` ); await press("/"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); }); describe("search", () => { test("should filter the Powerbox contents with term", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/"); await animationFrame(); expect(commandNames(el).length).toBe(27); await insertText(editor, "head"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); }); test("should hide categories when you have a search term", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/"); await animationFrame(); expect(commandNames(el).length).toBe(27); expect(".o-we-category").toHaveCount(8); expect(queryAllTexts(".o-we-category")).toEqual([ "STRUCTURE", "BANNER", "FORMAT", "MEDIA", "NAVIGATION", "WIDGET", "AI TOOLS", "BASIC BLOC", ]); await insertText(editor, "h"); await animationFrame(); expect(commandNames(el).length).toBe(9); expect(".o-we-category").toHaveCount(0); }); test.tags("iframe"); test("should filter the Powerbox contents with term, in iframe", async () => { const { el, editor } = await setupEditor("

ab[]

", { props: { iframe: true } }); await insertText(editor, "/"); await animationFrame(); expect(commandNames(el).length).toBe(27); await insertText(editor, "head"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); }); test("press 'backspace' should adapt adapt the search in the Powerbox", async () => { class TestPlugin extends Plugin { static id = "test"; resources = { user_commands: { id: "testCommand", run: () => {} }, powerbox_categories: { id: "test", name: "Test" }, powerbox_items: [ { title: "Test1", description: "Test1", categoryId: "test", commandId: "testCommand", }, { title: "Test12", description: "Test12", categoryId: "test", commandId: "testCommand", }, ], }; } const { editor, el } = await setupEditor(`

[]

`, { config: { Plugins: [...MAIN_PLUGINS, TestPlugin] }, }); expect(".o-we-powerbox").toHaveCount(0); await insertText(editor, "/test12"); await animationFrame(); expect(getContent(el)).toBe("

/test12[]

"); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["Test12"]); expect(".active .o-we-command-name").toHaveText("Test12"); await press("backspace"); await animationFrame(); expect(getContent(el)).toBe("

/test1[]

"); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["Test1", "Test12"]); expect(".active .o-we-command-name").toHaveText("Test1"); }); test("should filter the Powerbox contents with term, even after delete backward", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el).length).toBe(27); await insertText(editor, "headx"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); await press("Backspace"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); }); test("when the powerbox opens, the first command is selected by default", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/"); await animationFrame(); expect(".active .o-we-command-name").toHaveText(commandNames(el)[0]); await insertText(editor, "head"); await animationFrame(); expect(".active .o-we-command-name").toHaveText(commandNames(el)[0]); // "Heading 1" await insertText(editor, "/"); await animationFrame(); expect(".active .o-we-command-name").toHaveText(commandNames(el)[0]); }); test("should filter the Powerbox contents with term, even after a second search and delete backward", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/head"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); expect(".active .o-we-command-name").toHaveText("Heading 1"); await insertText(editor, "/headx"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); await press("backspace"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(".active .o-we-command-name").toHaveText("Heading 1"); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); }); test("should not filter the powerbox contents when collaborator type on two different blocks", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a[c1}{c1]b

c[c2}{c2]d

", }); applyConcurrentActions(peerInfos, { c1: async (editor) => { await insertText(editor, "/heading"); }, }); await animationFrame(); mergePeersSteps(peerInfos); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames()).toEqual(["Heading 1", "Heading 2", "Heading 3"]); applyConcurrentActions(peerInfos, { c2: async (editor) => { await insertText(editor, "g"); }, }); await animationFrame(); mergePeersSteps(peerInfos); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames()).toEqual(["Heading 1", "Heading 2", "Heading 3"]); applyConcurrentActions(peerInfos, { c1: async (editor) => { await insertText(editor, "1"); }, }); await animationFrame(); mergePeersSteps(peerInfos); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames()).toEqual(["Heading 1"]); renderTextualSelection(peerInfos); validateContent(peerInfos, "

a/heading1[c1}{c1]b

cg[c2}{c2]d

"); }); test("powerbox doesn't need to be displayed to apply a command (fast search)", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/head"); expect(".o-we-powerbox").toHaveCount(0); await press("enter"); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe("

ab[]

"); }); describe("search keywords", () => { test("should search commands by optional keywords", async () => { class TestPlugin extends Plugin { static id = "test"; resources = { user_commands: { id: "testCommand", run: () => {} }, powerbox_categories: { id: "test", name: "Test" }, powerbox_items: [ { title: "Test1", description: "Test1", categoryId: "test", commandId: "testCommand", keywords: ["apple", "orange"], }, { title: "Test2", description: "Test2 has apples and oranges in its description", categoryId: "test", commandId: "testCommand", }, ], }; } const { editor, el } = await setupEditor(`

[]

`, { config: { Plugins: [...MAIN_PLUGINS, TestPlugin] }, }); expect(".o-we-powerbox").toHaveCount(0); insertText(editor, "/apple"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); // Both commands should be found with the keyword "apple", being the first // one with a higher score expect(commandNames(el)).toEqual(["Test1", "Test2"]); // Replace "apple" by "orange" for (let i = 0; i < 5; i++) { press("backspace"); } insertText(editor, "/orange"); await animationFrame(); // Same as above expect(commandNames(el)).toEqual(["Test1", "Test2"]); insertText(editor, "s"); // "/oranges" await animationFrame(); // It no longer matches anything in the Test1 command expect(commandNames(el)).toEqual(["Test2"]); }); test("match order: full match on keyword should come before partial matches on names or descriptions", async () => { class TestPlugin extends Plugin { static id = "test"; resources = { user_commands: { id: "testCommand", run: () => {} }, powerbox_categories: { id: "test", name: "Test" }, powerbox_items: [ { title: "Change direction", // "icon" fuzzy matches this description: "test", categoryId: "test", commandId: "testCommand", }, { title: "Some command", description: "add a big section", // "icon" fuzzy matches this categoryId: "test", commandId: "testCommand", }, { title: "Insert a pictogram", description: "test", categoryId: "test", commandId: "testCommand", keywords: ["icon"], }, ], }; } const { editor, el } = await setupEditor(`

[]

`, { config: { Plugins: [...CORE_PLUGINS, PowerboxPlugin, SearchPowerboxPlugin, TestPlugin], }, }); expect(".o-we-powerbox").toHaveCount(0); insertText(editor, "/icon"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); const matchedCommands = commandNames(el); // All three commands are found, as they all match "icon" in some way. expect(matchedCommands).toInclude("Change direction"); expect(matchedCommands).toInclude("Some command"); expect(matchedCommands).toInclude("Insert a pictogram"); // The one with the exact keyword match should come first. expect(matchedCommands[0]).toBe("Insert a pictogram"); }); }); describe("close", () => { test("should close powerbox if there is no result", async () => { const { el, editor } = await setupEditor("

a[]

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); await insertText(editor, "zxzxzxz"); await animationFrame(); expect(getContent(el)).toBe("

a/zxzxzxz[]

"); expect(".o-we-powerbox").toHaveCount(0); }); test("should close powerbox typing a space", async () => { const { el, editor } = await setupEditor("

a[]

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); // We need to add another character (b) otherwise the space will be // considered invisible in the getContent(el). This is a limitation // of the test suite that does not transform the space into a nbsp. await insertText(editor, " b"); await animationFrame(); expect(getContent(el)).toBe("

a/ b[]

"); expect(".o-we-powerbox").toHaveCount(0); }); test("delete '/' should close the powerbox", async () => { const { editor, el } = await setupEditor("

[]

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(getContent(el)).toBe("

/[]

"); await press("backspace"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe( `

[]

` ); await insertText(editor, "a"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe("

a[]

"); }); test("press escape should close the powerbox", async () => { const { editor, el } = await setupEditor("

[]

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(getContent(el)).toBe("

/[]

"); await press("escape"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe(`

/[]

`); await insertText(editor, "h"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); expect(getContent(el)).toBe(`

/h[]

`); }); }); }); test("should execute command and remove term and hot character on Enter", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/head"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); expect(".o-we-powerbox").toHaveCount(1); await press("Enter"); expect(getContent(el)).toBe("

ab[]

"); expect(".o-we-powerbox").toHaveCount(1); // need 1 animation frame to close await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); }); test("should execute command and remove term and hot character on Tab", async () => { const { el, editor } = await setupEditor("

ab[]

"); await insertText(editor, "/head"); await animationFrame(); await press("Tab"); expect(getContent(el)).toBe("

ab[]

"); }); test.todo("should close the powerbox if keyup event is called on other block", async () => { // ged: not sure i understand the goal of this test const { editor } = await setupEditor("

ab

c[]d

"); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); // await dispatch(editor.editable, "keyup"); expect(".o-we-powerbox").toHaveCount(1); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); }); test.tags("desktop"); test("should insert a 3x3 table on type `/table`", async () => { const { el, editor } = await setupEditor("

[]

"); expect(getContent(el)).toBe(`

[]

`); await insertText(editor, "/table"); await waitFor(".o-we-powerbox "); await press("Enter"); await animationFrame(); await press("Enter"); await tick(); expect(getContent(el)).toBe( `

[]










` ); }); test.tags("mobile"); test("should insert a 3x3 table on type `/table` in mobile view", async () => { const { el, editor } = await setupEditor("

[]

"); await insertText(editor, "/table"); await waitFor(".o-we-powerbox "); await press("Enter"); await tick(); expect(getContent(el)).toBe( `

[]










` ); }); test("should toggle list on empty paragraph", async () => { const { el, editor } = await setupEditor("

[]

"); /** @todo fix warnings */ patchWithCleanup(console, { warn: () => {} }); await insertText(editor, "/"); // @todo @phoenix: remove this once we manage inputs. // Simulate
removal by contenteditable when something is inserted el.querySelector("p > br").remove(); await insertText(editor, "checklist"); expect(getContent(el)).toBe("

/checklist[]

"); await animationFrame(); expect(commandNames(el)).toEqual(["Checklist"]); expect(".o-we-powerbox").toHaveCount(1); await press("Enter"); expect(getContent(el)).toBe( `` ); // need 1 animation frame to close await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); }); class NoOpPlugin extends Plugin { static id = "noOp"; resources = { user_commands: [ { id: "noOp", run: () => {}, }, ], powerbox_categories: { id: "no_op", name: "No-op" }, powerbox_items: [ { title: "No-op", description: "No-op", categoryId: "no_op", icon: "fa-header", commandId: "noOp", }, ], }; } test("should restore state before /command insertion when command is executed (1)", async () => { const { el, editor } = await setupEditor("

abc[]

", { config: { Plugins: [...MAIN_PLUGINS, NoOpPlugin] }, }); await insertText(editor, "/no-op"); expect(getContent(el)).toBe("

abc/no-op[]

"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["No-op"]); await press("Enter"); expect(getContent(el)).toBe("

abc[]

"); }); test("should restore state before /command insertion when command is executed (2)", async () => { const { el, editor } = await setupEditor("

[]

", { config: { Plugins: [...MAIN_PLUGINS, NoOpPlugin] }, }); /** @todo fix warnings */ patchWithCleanup(console, { warn: () => {} }); expect(getContent(el)).toBe( `

[]

` ); await insertText(editor, "/"); // @todo @phoenix: remove this once we manage inputs. // Simulate
removal by contenteditable when something is inserted el.querySelector("p > br").remove(); await insertText(editor, "no-op"); expect(getContent(el)).toBe("

/no-op[]

"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["No-op"]); await press("Enter"); expect(getContent(el)).toBe( `

[]

` ); }); test("should discard /command insertion from history when command is executed", async () => { const { el, editor } = await setupEditor("

[]

"); /** @todo fix warnings */ patchWithCleanup(console, { warn: () => {} }); expect(getContent(el)).toBe( `

[]

` ); // @todo @phoenix: remove this once we manage inputs. // Simulate
removal by contenteditable when something is inserted el.querySelector("p > br").remove(); await insertText(editor, "abc/heading1"); expect(getContent(el)).toBe("

abc/heading1[]

"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["Heading 1"]); await press("Enter"); expect(getContent(el)).toBe("

abc[]

"); execCommand(editor, "historyUndo"); expect(getContent(el)).toBe("

abc[]

"); execCommand(editor, "historyRedo"); expect(getContent(el)).toBe("

abc[]

"); execCommand(editor, "historyUndo"); expect(getContent(el)).toBe("

abc[]

"); execCommand(editor, "historyUndo"); expect(getContent(el)).toBe("

ab[]

"); execCommand(editor, "historyUndo"); expect(getContent(el)).toBe("

a[]

"); execCommand(editor, "historyUndo"); expect(getContent(el)).toBe( `

[]

` ); }); test("should adapt the search of the powerbox when undo/redo", async () => { const { editor, el } = await setupEditor("

ab[]

"); await insertText(editor, "/heading1"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1"]); undo(editor); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); redo(editor); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1"]); }); test("should open the Powerbox on type `/` in DIV", async () => { const { editor } = await setupEditor(`
ab

[]
`); expect(".o-we-powerbox").toHaveCount(0); await insertText(editor, "/"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); }); test("press 'arrowdown' to navigate", async () => { const { editor, el } = await setupEditor("

ab[]

"); await insertText(editor, "/head"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); expect(".active .o-we-command-name").toHaveText("Heading 1"); await press("arrowdown"); await animationFrame(); expect(".active .o-we-command-name").toHaveText("Heading 2"); await press("arrowdown"); await animationFrame(); expect(".active .o-we-command-name").toHaveText("Heading 3"); await press("arrowdown"); await animationFrame(); expect(".active .o-we-command-name").toHaveText("Heading 1"); }); test("press 'arrowup' to navigate", async () => { const { editor, el } = await setupEditor("

ab[]

"); await insertText(editor, "/head"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); expect(".active .o-we-command-name").toHaveText("Heading 1"); await press("arrowup"); await animationFrame(); expect(".active .o-we-command-name").toHaveText("Heading 3"); await press("arrowup"); await animationFrame(); expect(".active .o-we-command-name").toHaveText("Heading 2"); await press("arrowup"); await animationFrame(); expect(".active .o-we-command-name").toHaveText("Heading 1"); }); test("press 'arrowleft' should close PowerBox", async () => { const { editor } = await setupEditor("

ab[]c

"); await insertText(editor, "/head"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); await press("arrowleft"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); }); test("press 'arrowright' should close PowerBox", async () => { const { editor } = await setupEditor("

ab[]c

"); await insertText(editor, "/head"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); await press("arrowright"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(0); }); test.tags("desktop"); test("select command with 'mouseenter'", async () => { const { editor, el } = await setupEditor("

ab[]

"); // Hoot don't trigger a mousemove event at the start of an hover, if we don't hover // another element before. So we need to do a first hover to set a previous element. await hover(".odoo-editor-editable"); await insertText(editor, "/head"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); expect(".active .o-we-command-name").toHaveText("Heading 1"); await hover(".o-we-command-name:last"); await animationFrame(); expect(".active .o-we-command-name").toHaveText("Heading 3"); await press("enter"); expect(getContent(el)).toBe("

ab[]

"); }); test("click on a command", async () => { const { editor, el } = await setupEditor("

ab[]

"); await insertText(editor, "/head"); await animationFrame(); expect(commandNames(el)).toEqual(["Heading 1", "Heading 2", "Heading 3"]); expect(".active .o-we-command-name").toHaveText("Heading 1"); await click(".o-we-command-name:last"); expect(getContent(el)).toBe("

ab[]

"); }); test("create a new

with press 'Enter' then apply a powerbox command", async () => { const { editor, el } = await setupEditor("

ab[]cd

"); // Event trigger when you press "Enter" => create a new paragraph await manuallyDispatchProgrammaticEvent(editor.editable, "beforeinput", { inputType: "insertParagraph", }); await insertText(editor, "/head"); await animationFrame(); await press("Enter"); expect(getContent(el)).toBe("

ab

[]cd

"); }); // @todo @phoenix Need a fix in hoot duplicate error are throw test.todo("add plugins with the same powerboxCategory should crash", async () => { expect.errors(1); patchWithCleanup(console, { warn: (msg) => expect.step(msg), }); class Plugin1 extends Plugin { resources = { powerbox_categories: withSequence(10, { id: "test", name: "Test" }), }; } class Plugin2 extends Plugin { resources = { powerbox_categories: withSequence(10, { id: "test", name: "Test" }), }; } await expect( setupEditor("

ab[]cd

", { config: { Plugins: [...MAIN_PLUGINS, Plugin1, Plugin2] }, }) ).rejects.toThrow(); expect(["Duplicate category id: test"]).toVerifyErrors(); expect([ "[Owl] Unhandled error. Destroying the root component", "[Owl] Unhandled error. Destroying the root component", "[Owl] Unhandled error. Destroying the root component", ]).toVerifySteps(); });