diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 84f7f4d82ad..4360bb69617 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -478,6 +478,16 @@ This module provides the core of the Odoo Web Client. 'web/static/src/webclient/actions/reports/reset.min.css', ], + 'web.ace_lib': [ + "web/static/lib/ace/ace.js", + "web/static/lib/ace/mode-js.js", + "web/static/lib/ace/javascript_highlight_rules.js", + "web/static/lib/ace/mode-xml.js", + "web/static/lib/ace/mode-qweb.js", + "web/static/lib/ace/mode-scss.js", + "web/static/lib/ace/theme-monokai.js", + ], + # --------------------------------------------------------------------- # COLOR SCHEME BUNDLES # --------------------------------------------------------------------- diff --git a/addons/web/static/src/core/code_editor/code_editor.js b/addons/web/static/src/core/code_editor/code_editor.js new file mode 100644 index 00000000000..26cf6e4faec --- /dev/null +++ b/addons/web/static/src/core/code_editor/code_editor.js @@ -0,0 +1,110 @@ +/** @odoo-module */ +import { Component, onWillDestroy, onWillStart, useEffect, useRef } from "@odoo/owl"; +import { getBundle, loadBundle } from "@web/core/assets"; +import { useDebounced } from "@web/core/utils/timing"; + +function onResized(ref, callback) { + const _ref = typeof ref === "string" ? useRef(ref) : ref; + const resizeObserver = new ResizeObserver(callback); + + useEffect( + (el) => { + if (el) { + resizeObserver.observe(el); + return () => resizeObserver.unobserve(el); + } + }, + () => [_ref.el] + ); + + onWillDestroy(() => { + resizeObserver.disconnect(); + }); +} + +export class CodeEditor extends Component { + static template = "web.CodeEditor"; + static components = {}; + static props = { + mode: { + type: String, + optional: true, + validate: (mode) => CodeEditor.MODES.includes(mode), + }, + value: { type: String, optional: true }, + onChange: { type: Function, optional: true }, + class: { type: String, optional: true }, + theme: { + type: String, + optional: true, + validate: (theme) => CodeEditor.THEMES.includes(theme), + }, + }; + static defaultProps = { + value: "", + onChange: () => {}, + class: "", + theme: "", + }; + + static MODES = ["js", "xml", "qweb", "scss", "python"]; + static THEMES = ["", "monokai"]; + + setup() { + this.editorRef = useRef("editorRef"); + + onWillStart(async () => loadBundle(await getBundle("web.ace_lib"))); + + useEffect( + (el) => { + if (!el) { + return; + } + + // keep in closure + const aceEditor = window.ace.edit(el); + this.aceEditor = aceEditor; + this.aceEditor.session.setUseWorker(false); + this.aceEditor.setValue(this.props.value); + + this.aceEditor.session.on("change", () => { + if (this.props.onChange) { + this.props.onChange(this.aceEditor.getValue()); + } + }); + + return () => { + aceEditor.destroy(); + }; + }, + () => [this.editorRef.el] + ); + + useEffect( + (value) => { + if (value !== this.aceEditor.getValue()) { + this.aceEditor.setValue(value); + } + }, + () => [this.props.value] + ); + + useEffect( + (mode) => this.aceEditor.session.setMode(mode ? `ace/mode/${mode}` : ""), + () => [this.props.mode] + ); + + useEffect( + (theme) => this.aceEditor.setTheme(theme ? `ace/theme/${theme}` : "textmate"), + () => [this.props.theme] + ); + + this.debouncedResize = useDebounced(() => { + if (this.aceEditor) { + this.aceEditor.resize(); + } + }, 250); + + onResized(this.editorRef, () => this.debouncedResize()); + } +} diff --git a/addons/web/static/src/core/code_editor/code_editor.xml b/addons/web/static/src/core/code_editor/code_editor.xml new file mode 100644 index 00000000000..73ff2205470 --- /dev/null +++ b/addons/web/static/src/core/code_editor/code_editor.xml @@ -0,0 +1,9 @@ + + + + +
+
+
+ +
diff --git a/addons/web/static/tests/core/code_editor_tests.js b/addons/web/static/tests/core/code_editor_tests.js new file mode 100644 index 00000000000..ae54d805594 --- /dev/null +++ b/addons/web/static/tests/core/code_editor_tests.js @@ -0,0 +1,208 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { + getFixture, + patchWithCleanup, + mount, + nextTick, + editInput, +} from "@web/../tests/helpers/utils"; + +import { Component, useState, xml } from "@odoo/owl"; +import { CodeEditor } from "@web/core/code_editor/code_editor"; + +QUnit.module("Web Components", (hooks) => { + QUnit.module("Code Editor"); + + let env; + let target; + + hooks.beforeEach(async () => { + env = await makeTestEnv(); + target = getFixture(); + patchWithCleanup(browser, { + setTimeout: (fn) => Promise.resolve().then(fn), + }); + }); + + function getDomValue() { + const lines = [...target.querySelectorAll(".ace_line")]; + return lines + .map((line) => { + const spans = [...line.querySelectorAll(":scope > span")]; + return spans.map((span) => span.textContent).join(""); + }) + .join("\n"); + } + + async function edit(value) { + await editInput(target, "textarea", value); + } + + QUnit.test("Can be rendered", async (assert) => { + class Parent extends Component { + static components = { CodeEditor }; + static template = xml``; + } + await mount(Parent, target, { env }); + assert.containsOnce(target, ".ace_editor", "Code editor is rendered"); + }); + + QUnit.test("onChange props called when code is edited", async (assert) => { + class Parent extends Component { + static components = { CodeEditor }; + static template = xml` + + `; + onChange(value) { + assert.step(value); + } + } + + await mount(Parent, target, { env }); + await edit("Some Text"); + assert.verifySteps(["Some Text"], "Value properly given to onChange"); + }); + + QUnit.test("Default value correctly set and updates", async (assert) => { + const textA = "
\n

A Paragraph

\n
"; + const textB = "
\n

An Other Paragraph

\n
"; + const textC = "
\n

A Paragraph

\n
\n

And More

"; + + class Parent extends Component { + static components = { CodeEditor }; + static template = xml` + + `; + setup() { + this.state = useState({ value: textA }); + } + onChange(value) { + // Changing the value of the textarea manualy triggers an Ace "remove" event + // of the whole text (the value is thus empty), then an "add" event with the + // actual value, this isn't ideal but we ignore the remove. + if (value.length <= 0) { + return; + } + assert.step(value); + } + changeValue(newValue) { + this.state.value = newValue; + } + } + + const codeEditor = await mount(Parent, target, { env }); + await nextTick(); + assert.equal(getDomValue(), textA, "Default value correctly set"); + + await edit(textB); + assert.equal( + getDomValue(), + textB, + "When the textarea is updated the value is correctly changed in the dom" + ); + + codeEditor.changeValue(textC); + await nextTick(); + await nextTick(); + assert.equal( + getDomValue(), + textC, + "When the props is updated the value is correctly changed in the dom" + ); + + assert.verifySteps([textB, textC], "Changes properly given to onChange"); + }); + + QUnit.test("Mode props update imports the mode", async (assert) => { + const fakeAceEditor = { + session: { + setMode: (mode) => { + assert.step(mode); + }, + setUseWorker: () => {}, + on: () => {}, + }, + setValue: () => {}, + getValue: () => "", + setTheme: () => {}, + resize: () => {}, + destroy: () => {}, + }; + + patchWithCleanup(window.ace, { + edit: () => fakeAceEditor, + }); + + class Parent extends Component { + static components = { CodeEditor }; + static template = xml``; + setup() { + this.state = useState({ mode: "xml" }); + } + setMode(newMode) { + this.state.mode = newMode; + } + } + + const codeEditor = await mount(Parent, target, { env }); + assert.verifySteps(["ace/mode/xml"], "XML mode should be loaded"); + + await codeEditor.setMode("js"); + await nextTick(); + assert.verifySteps(["ace/mode/js"], "JS mode should be loaded"); + }); + + QUnit.test("Theme props updates imports the theme", async (assert) => { + const fakeAceEditor = { + session: { + setMode: () => {}, + setUseWorker: () => {}, + on: () => {}, + }, + setValue: () => {}, + getValue: () => "", + setTheme: (theme) => { + assert.step(theme); + }, + resize: () => {}, + destroy: () => {}, + }; + + patchWithCleanup(window.ace, { + edit: () => fakeAceEditor, + }); + + class Parent extends Component { + static components = { CodeEditor }; + static template = xml` + + `; + setup() { + this.state = useState({ theme: "" }); + } + setTheme(newTheme) { + this.state.theme = newTheme; + } + } + + const codeEditor = await mount(Parent, target, { env }); + assert.verifySteps(["textmate"], "Default theme should be loaded"); + + await codeEditor.setTheme("monokai"); + await nextTick(); + assert.verifySteps(["ace/theme/monokai"], "Monokai theme should be loaded"); + }); +});