[MOV] web: Moved CodeEditor component from web_studio to web

This component was created as an easy way to add a code editing field
without needing a record (unlike the AceField component). It also
completely abstracts the use of the Ace Editor library.

The CodeEditor has been moved to web core as it was needed in other
places.

The name "CodeEditor" was purposely chosen to clearly state what the
component is used for and abstract the use of Ace.

Part-of: odoo/odoo#117782
This commit is contained in:
Bastien Fafchamps (bafa)
2023-04-25 12:57:44 +02:00
parent 5962a1516f
commit e4e10337d4
4 changed files with 337 additions and 0 deletions
+10
View File
@@ -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
# ---------------------------------------------------------------------
@@ -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());
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web.CodeEditor" owl="1">
<div t-ref="editorRef" class="w-100 h-100" t-att-class="props.class">
</div>
</t>
</templates>
@@ -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`<CodeEditor mode="'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`
<CodeEditor
mode="'xml'"
onChange.bind="onChange"
/>
`;
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 = "<div>\n<p>A Paragraph</p>\n</div>";
const textB = "<div>\n<p>An Other Paragraph</p>\n</div>";
const textC = "<div>\n<p>A Paragraph</p>\n</div>\n<p>And More</p>";
class Parent extends Component {
static components = { CodeEditor };
static template = xml`
<CodeEditor
mode="'xml'"
value="state.value"
onChange.bind="onChange"
/>
`;
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`<CodeEditor mode="state.mode" />`;
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`
<CodeEditor
mode="'xml'"
theme="state.theme"
/>
`;
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");
});
});