[IMP] web: Make AceField use the CodeEditor component

Before this commit:
The AceField component used the ace library to provide the user a code
editing field

After this commit:
The AceField component is now using the CodeEditor component and the usage
of the ace editor lib is now abstracted behind the component.

closes odoo/odoo#117782

Related: odoo/enterprise#39365
Signed-off-by: Géry Debongnie <ged@odoo.com>
This commit is contained in:
Bastien Fafchamps (bafa)
2023-04-25 12:57:45 +02:00
parent 353a38086c
commit bacd02f91a
12 changed files with 118 additions and 137 deletions
+1
View File
@@ -484,6 +484,7 @@ This module provides the core of the Odoo Web Client.
"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-python.js",
"web/static/lib/ace/mode-scss.js",
"web/static/lib/ace/theme-monokai.js",
],
@@ -32,6 +32,7 @@ export class CodeEditor extends Component {
validate: (mode) => CodeEditor.MODES.includes(mode),
},
value: { type: String, optional: true },
readonly: { type: Boolean, optional: true },
onChange: { type: Function, optional: true },
class: { type: String, optional: true },
theme: {
@@ -41,6 +42,7 @@ export class CodeEditor extends Component {
},
};
static defaultProps = {
readonly: false,
value: "",
onChange: () => {},
class: "",
@@ -64,9 +66,19 @@ export class CodeEditor extends Component {
// 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.setOptions({
maxLines: Infinity,
showPrintMargin: false,
});
this.aceEditor.session.setOptions({
useWorker: false,
tabSize: 2,
useSoftTabs: true,
});
this.aceEditor.$blockScrolling = true;
this.aceEditor.setValue(this.props.value);
this.aceEditor.session.on("change", () => {
if (this.props.onChange) {
this.props.onChange(this.aceEditor.getValue());
@@ -95,16 +107,36 @@ export class CodeEditor extends Component {
);
useEffect(
(theme) => this.aceEditor.setTheme(theme ? `ace/theme/${theme}` : "textmate"),
(theme) => this.aceEditor.setTheme(theme ? `ace/theme/${theme}` : ""),
() => [this.props.theme]
);
this.debouncedResize = useDebounced(() => {
useEffect(
(readonly) => {
this.aceEditor.setOptions({
readOnly: readonly,
highlightActiveLine: !readonly,
highlightGutterLine: !readonly,
});
this.aceEditor.renderer.setOptions({
displayIndentGuides: !readonly,
showGutter: !readonly,
});
this.aceEditor.renderer.$cursorLayer.element.style.display = readonly
? "none"
: "block";
},
() => [this.props.readonly]
);
const debouncedResize = useDebounced(() => {
if (this.aceEditor) {
this.aceEditor.resize();
}
}, 250);
onResized(this.editorRef, () => this.debouncedResize());
onResized(this.editorRef, debouncedResize);
}
}
@@ -2,7 +2,7 @@
<templates>
<t t-name="web.CodeEditor" owl="1">
<div t-ref="editorRef" class="w-100 h-100" t-att-class="props.class">
<div t-ref="editorRef" class="w-100" t-att-class="props.class">
</div>
</t>
@@ -1,14 +1,14 @@
/** @odoo-module **/
/* global ace */
import { loadJS } from "@web/core/assets";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { formatText } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillStart, onWillUpdateProps, useEffect, useRef } from "@odoo/owl";
import { CodeEditor } from "@web/core/code_editor/code_editor";
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
import { useDebounced } from "@web/core/utils/timing";
export class AceField extends Component {
static template = "web.AceField";
@@ -19,33 +19,20 @@ export class AceField extends Component {
static defaultProps = {
mode: "qweb",
};
static components = { CodeEditor };
setup() {
this.aceEditor = null;
this.editorRef = useRef("editor");
this.cookies = useService("cookie");
this.debouncedCommit = useDebounced(this.commitChanges, 10);
onWillStart(async () => {
await loadJS("/web/static/lib/ace/ace.js");
const jsLibs = [
"/web/static/lib/ace/mode-python.js",
"/web/static/lib/ace/mode-xml.js",
"/web/static/lib/ace/mode-qweb.js",
];
const proms = jsLibs.map((url) => loadJS(url));
return Promise.all(proms);
this.state = useState({
onChange: (value) => this.handleChange(value),
class: "ace-view-editor",
theme: this.theme,
});
onWillUpdateProps(this.updateAce);
useEffect(
() => {
this.setupAce();
this.updateAce(this.props);
return () => this.destroyAce();
},
() => [this.editorRef.el]
);
this.updateCodeEditor(this.props);
onWillUpdateProps(this.updateCodeEditor);
const { model } = this.props.record;
useBus(model.bus, "WILL_SAVE_URGENTLY", () => this.commitChanges());
@@ -54,65 +41,24 @@ export class AceField extends Component {
);
}
get aceSession() {
return this.aceEditor.getSession();
get theme() {
return this.cookies.current.color_scheme === "dark" ? "monokai" : "";
}
setupAce() {
this.aceEditor = ace.edit(this.editorRef.el);
this.aceEditor.setOptions({
maxLines: Infinity,
showPrintMargin: false,
theme: this.cookies.current.color_scheme === "dark" ? "ace/theme/monokai" : "",
});
this.aceEditor.$blockScrolling = true;
this.aceSession.setOptions({
useWorker: false,
tabSize: 2,
useSoftTabs: true,
});
this.aceEditor.on("blur", this.commitChanges.bind(this));
handleChange(newValue) {
this.state.value = newValue;
this.debouncedCommit();
}
updateAce({ mode, readonly, record }) {
if (!this.aceEditor) {
return;
}
this.aceSession.setOptions({
mode: `ace/mode/${mode === "xml" ? "qweb" : mode}`,
});
this.aceEditor.setOptions({
readOnly: readonly,
highlightActiveLine: !readonly,
highlightGutterLine: !readonly,
});
this.aceEditor.renderer.setOptions({
displayIndentGuides: !readonly,
showGutter: !readonly,
});
this.aceEditor.renderer.$cursorLayer.element.style.display = readonly ? "none" : "block";
const formattedValue = formatText(record.data[this.props.name]);
if (this.aceSession.getValue() !== formattedValue) {
this.aceSession.setValue(formattedValue);
}
}
destroyAce() {
if (this.aceEditor) {
this.aceEditor.destroy();
}
updateCodeEditor({ record, mode, readonly }) {
this.state.value = formatText(record.data[this.props.name]);
this.state.mode = mode === "xml" ? "qweb" : mode;
this.state.readonly = readonly;
}
commitChanges() {
if (!this.props.readonly) {
const value = this.aceSession.getValue();
const value = this.state.value;
if (this.props.record.data[this.props.name] !== value) {
return this.props.record.update({ [this.props.name]: value });
}
@@ -130,3 +76,4 @@ export const aceField = {
};
registry.category("fields").add("ace", aceField);
registry.category("fields").add("code", aceField);
@@ -1,3 +1,3 @@
.o_field_ace {
.o_field_ace, .o_field_code {
display: block !important;
}
@@ -4,7 +4,14 @@
<t t-name="web.AceField" owl="1">
<!-- TODO check if some classes are useless -->
<div class="o_field_widget oe_form_field o_ace_view_editor oe_ace_open">
<div class="ace-view-editor" t-ref="editor" />
<CodeEditor
value="state.value"
mode="state.mode"
readonly="state.readonly"
onChange.bind="handleChange"
class="'ace-view-editor'"
theme="theme"
/>
</div>
</t>
@@ -38,7 +38,30 @@ QUnit.module("Web Components", (hooks) => {
}
async function edit(value) {
await editInput(target, "textarea", value);
const textArea = target.querySelector(".ace_editor textarea");
await editInput(textArea, null, value);
return null;
}
function getFakeAceEditor() {
return {
session: {
on: () => {},
setMode: () => {},
setUseWorker: () => {},
setOptions: () => {},
},
renderer: {
setOptions: () => {},
$cursorLayer: { element: { style: {} } },
},
setOptions: () => {},
setValue: () => {},
getValue: () => "",
setTheme: () => {},
resize: () => {},
destroy: () => {},
};
}
QUnit.test("Can be rendered", async (assert) => {
@@ -53,12 +76,7 @@ QUnit.module("Web Components", (hooks) => {
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"
/>
`;
static template = xml`<CodeEditor onChange.bind="onChange" />`;
onChange(value) {
assert.step(value);
}
@@ -124,19 +142,9 @@ QUnit.module("Web Components", (hooks) => {
});
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: () => {},
const fakeAceEditor = getFakeAceEditor();
fakeAceEditor.session.setMode = (mode) => {
assert.step(mode);
};
patchWithCleanup(window.ace, {
@@ -163,19 +171,9 @@ QUnit.module("Web Components", (hooks) => {
});
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: () => {},
const fakeAceEditor = getFakeAceEditor();
fakeAceEditor.setTheme = (theme) => {
assert.step(theme ? theme : "default");
};
patchWithCleanup(window.ace, {
@@ -184,12 +182,7 @@ QUnit.module("Web Components", (hooks) => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`
<CodeEditor
mode="'xml'"
theme="state.theme"
/>
`;
static template = xml`<CodeEditor theme="state.theme" />`;
setup() {
this.state = useState({ theme: "" });
}
@@ -199,7 +192,7 @@ QUnit.module("Web Components", (hooks) => {
}
const codeEditor = await mount(Parent, target, { env });
assert.verifySteps(["textmate"], "Default theme should be loaded");
assert.verifySteps(["default"], "Default theme should be loaded");
await codeEditor.setTheme("monokai");
await nextTick();
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { getFixture, triggerEvents } from "@web/../tests/helpers/utils";
import { getFixture, nextTick, triggerEvents } from "@web/../tests/helpers/utils";
import { pagerNext } from "@web/../tests/search/helpers";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { fakeCookieService } from "@web/../tests/helpers/mock_services";
@@ -46,7 +46,7 @@ QUnit.module("Fields", (hooks) => {
serverData,
arch: `
<form>
<field name="foo" widget="ace" />
<field name="foo" widget="code" />
</form>`,
});
@@ -57,7 +57,7 @@ QUnit.module("Fields", (hooks) => {
"should have rendered something with ace editor"
);
assert.ok(target.querySelector(".o_field_ace").textContent.includes("yop"));
assert.ok(target.querySelector(".o_field_code").textContent.includes("yop"));
});
QUnit.test("AceEditorField doesn't crash when editing", async (assert) => {
@@ -69,7 +69,7 @@ QUnit.module("Fields", (hooks) => {
arch: `
<form>
<field name="display_name" />
<field name="foo" widget="ace" />
<field name="foo" widget="code" />
</form>`,
});
@@ -86,14 +86,15 @@ QUnit.module("Fields", (hooks) => {
serverData,
arch: /* xml */ `
<form>
<field name="foo" widget="ace" />
<field name="foo" widget="code" />
</form>`,
});
assert.ok(target.querySelector(".o_field_ace").textContent.includes("yop"));
assert.ok(target.querySelector(".o_field_code").textContent.includes("yop"));
await pagerNext(target);
await nextTick();
assert.ok(target.querySelector(".o_field_ace").textContent.includes("blip"));
assert.ok(target.querySelector(".o_field_code").textContent.includes("blip"));
});
});
+2 -2
View File
@@ -37,10 +37,10 @@
<notebook>
<page string="Custom Code" groups="base.group_no_one">
<label for="custom_code_head"/>
<field name="custom_code_head" widget="ace" options="{'mode': 'xml'}"/>
<field name="custom_code_head" widget="code" options="{'mode': 'xml'}"/>
<label for="custom_code_footer"/>
<field name="custom_code_footer" widget="ace" options="{'mode': 'xml'}"/>
<field name="custom_code_footer" widget="code" options="{'mode': 'xml'}"/>
</page>
</notebook>
</sheet>
+1 -1
View File
@@ -313,7 +313,7 @@
<notebook>
<page string="Python Code" name='code'
attrs="{'invisible': [('state', '!=', 'code')]}">
<field name="code" widget="ace" options="{'mode': 'python'}" placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document."/>
<field name="code" widget="code" options="{'mode': 'python'}" placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document."/>
</page>
<page string="Data to Write" name="page_object"
+3 -3
View File
@@ -124,7 +124,7 @@
<group string="Advanced Properties" groups="base.group_no_one">
<field name="related"/>
<field name="depends" attrs="{'required': [('compute', 'not in', [False, ''])]}"/>
<field name="compute" widget="ace" options="{'mode': 'python'}"/>
<field name="compute" widget="code" options="{'mode': 'python'}"/>
</group>
<div groups="base.group_no_one">
<h3>How to define a computed field</h3>
@@ -319,7 +319,7 @@
<group string="Advanced Properties" groups="base.group_no_one">
<field name="related"/>
<field name="depends" attrs="{'required': [('compute', 'not in', [False, ''])]}"/>
<field name="compute" widget="ace" options="{'mode': 'python'}"/>
<field name="compute" widget="code" options="{'mode': 'python'}"/>
</group>
<div groups="base.group_no_one">
<h3>How to define a computed field</h3>
@@ -672,7 +672,7 @@
<field name="perm_create"/>
<field name="perm_unlink"/>
</group>
</sheet>
</sheet>
</form>
</field>
</record>
+1 -1
View File
@@ -29,7 +29,7 @@
<notebook>
<page string="Architecture" name="architecture">
<field name="arch_db" class="oe_no_translation_content"/>
<field name="arch_base" string="View Architecture" widget="ace" options="{'mode': 'xml'}" required="1"/>
<field name="arch_base" string="View Architecture" widget="code" options="{'mode': 'xml'}" required="1"/>
</page>
<page string="Access Rights" name="access_rights">
<field name="groups_id"/>