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("[]
[]
[]
[]
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("[]
`, { 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( `[]
a[]
[]
"); 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[]
"); await insertText(editor, "/head"); await animationFrame(); await press("Tab"); expect(getContent(el)).toBe("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( `[] | ||
[]
[] | ||
[]
/checklist[]
"); await animationFrame(); expect(commandNames(el)).toEqual(["Checklist"]); expect(".o-we-powerbox").toHaveCount(1); await press("Enter"); expect(getContent(el)).toBe( `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("[]
[]
/no-op[]
"); await animationFrame(); expect(".o-we-powerbox").toHaveCount(1); expect(commandNames(el)).toEqual(["No-op"]); await press("Enter"); 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, "historyRedo"); 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( `[]
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[]
"); 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[]
"); 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("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
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(); });