Create an Odoo 14 Markdown Widget Field with TDD - Part 3
Coding Dodo
Posted on May 26, 2021
Introduction
This is the third part of an article series where we use TDD to develop an Odoo markdown widget.
We continue right where we left last time, writing tests, exploring the JS Framework, making mistakes, and refactoring our code. We saw, by installing and trying to use the widget, that it was not correctly visible and hard to use, so we will fix that.
Managing the built-in the auto-resize of FieldText
Analysis
First, we need to take a look at the FieldText widget inside the source code:
var FieldText = InputField.extend(TranslatableFieldMixin, {
description: _lt("Multiline Text"),
className: 'o_field_text',
supportedFieldTypes: ['text', 'html'],
tagName: 'span',
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
if (this.mode === 'edit') {
this.tagName = 'textarea';
}
this.autoResizeOptions = {parent: this};
},
/**
* As it it done in the start function, the autoresize is done only once.
*
* @override
*/
start: function () {
if (this.mode === 'edit') {
dom.autoresize(this.$el, this.autoResizeOptions);
if (this.field.translate) {
this.$el = this.$el.add(this._renderTranslateButton());
this.$el.addClass('o_field_translate');
}
}
return this._super();
},
In the init
function we see the declaration of the autoResizeOptions property, then in the start
function it is used in conjunction with the dom.autoresize
function.
We could directly override the start
function to modify that behavior but in this deep-dive tutorial series we try to understand how things work so we will look at that function inside odoo/addons/web/static/src/js/core/dom.js
autoresize: function ($textarea, options) {
if ($textarea.data("auto_resize")) {
return;
}
var $fixedTextarea;
var minHeight;
function resize() {
$fixedTextarea.insertAfter($textarea);
//...
//...
What interests us is right at the beginning of the function. We don't want the autoResize feature to kick in so we need to get inside this condition so the function returns directly.
And to get into that condition, the JQuery Element (in the variable $textarea
) should have a property "data" named auto_resize
. (Data properties are prefixed with data, so in the XML markup it will be data-auto_resize
)
Updating the QWeb Template of our widget?
So we will modify the QWeb template of our widget to add that data and prevent the auto-resize feature. Update web_widget_markdown/static/src/xml/qweb_template.xml
with that content
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="FieldMarkdown">
<div class="o_field_markdown" data-auto_resize="False">
<textarea name="o_field_markdown" id="o_field_markdown"></textarea>
</div>
</t>
</templates>
This seems to do the job, the Editor is now useable and fully scrollable if we go over the limit but there is still a lot of problems:
- FieldText transforms our div tag name to
<textarea>
making the dom in Edit mode having 2<textarea>
inside of each other. - We can't use the Tabulation key, some KeyUp events seem to be in conflict and have different behavior.
- The
reset
function of FieldText wants to trigger a change event on an $input that doesn't exist withself.$input.trigger
('change');
so we should also override thereset
function?
It seems that we are fighting against the implementation of FieldText
(with logic about <textarea>
, resizing, translation) inheriting InputField
with logic about Key Up/down events and injecting input field inside our dom.
What do we actually use from FieldText or InputField?
The answer is quite simple, nothing.
It seemed a good idea at first because our Markdown field is a Text field in essence but conflicts with the basic widgets are becoming an annoyance. So we will go up the inheritance tree and use the DebouncedField
. This class contains the logic we actually want and are using in our widget.
Refactoring our widget to extend DebouncedField
Updating the Field declaration
The good news is that we have a full test suite to use against our refactoring, so we can be confident about the changes we will make. Inside web_widget_markdown/static/src/js/field_widget.js
var markdownField = basicFields.DebouncedField.extend({
supportedFieldTypes: ['text'],
template: 'FieldMarkdown',
jsLibs: [
'/web_widget_markdown/static/lib/simplemde.min.js',
],
//...
Then we run our test suite
Everything seems OK ✅ and we can also edit our template to remove the data-auto_resize
as it is no longer useful.
Handling KeyUp/Down events
We still have the problem of using the tab key inside the Editor.
Now that the inheritance chain is simplified we know that the logic handling the Key events is either inside DebouncedField
or his parent AbstractField
.
A quick look inside DebouncedField
gives us nothing so the logic is inside AbstractField
, the "super" class that is at the top of all field widgets in odoo/addons/web/static/src/js/fields/abstract_field.js
var AbstractField = Widget.extend({
events: {
'keydown': '_onKeydown',
},
//...
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.TAB:
var event = this.trigger_up('navigation_move', {
direction: ev.shiftKey ? 'previous' : 'next',
});
if (event.is_stopped()) {
ev.preventDefault();
ev.stopPropagation();
}
break;
//...
All fields have this events
property that map an event bubbled up by the controller, here keydown
, to a function _onKeydown
.
And we see here that this where the logic about the TAB keyCode press happens. As a solution we will remove all the key events of our widget because the events are handled by SimpleMDE already, so we update our widget declaration like that:
var markdownField = basicFields.DebouncedField.extend({
supportedFieldTypes: ['text'],
template: 'FieldMarkdown',
jsLibs: [
'/web_widget_markdown/static/lib/simplemde.min.js',
],
events: {}, // events are triggered manually for this debounced widget
//...
Run the tests again (after each refactoring) and test the UI to see that now we can press TAB Key again without leaving the Editor.
Directly bind CodeMirror changes to the debounceActions
We will also refactor that part to use the debounceAction function given by DebouncedField
. We will also improve our widget to bind on the blur method (where the user clicks out of the markdown editor) so it saves the changes.
Change
this.simplemde.codemirror.on("change", function(){
self._setValue(self.simplemde.value());
})
Replace with those lines
this.simplemde.codemirror.on("change", this._doDebouncedAction.bind(this));
this.simplemde.codemirror.on("blur", this._doAction.bind(this));
Run the tests again, they should still be all green.
Making our widget translatable
Going away from FieldText
inheritance made us lose the Translatable functionality, but it is okay, we didn't have any tests for that feature.
Writing the test suite for our translatable field
When a field has a translation feature, it has a little icon on the right with the code of the language.
Clicking on that button opens a Dialog with as many rows as languages installed on the environment, allowing the user to edit the source and translation value.
For these tests we will inspire us of the basic widget test suite, testing the CharField translatable feature. In our file web_widget_markdown/static/tests/web_widget_markdown_tests.js
QUnit.test('markdown widget field translatable', async function (assert) {
assert.expect(12);
this.data.blog.fields.content.translate = true;
var multiLang = _t.database.multi_lang;
_t.database.multi_lang = true;
var form = await testUtils.createView({
View: FormView,
model: 'blog',
data: this.data,
arch: '<form string="Blog">' +
'<group>' +
'<field name="name"/>' +
'<field name="content" widget="markdown"/>' +
'</group>' +
'</form>',
res_id: 1,
session: {
user_context: {lang: 'en_US'},
},
mockRPC: function (route, args) {
if (route === "/web/dataset/call_button" && args.method === 'translate_fields') {
assert.deepEqual(args.args, ["blog", 1, "content"], 'should call "call_button" route');
return Promise.resolve({
domain: [],
context: {search_default_name: 'blog,content'},
});
}
if (route === "/web/dataset/call_kw/res.lang/get_installed") {
return Promise.resolve([["en_US", "English"], ["fr_BE", "French (Belgium)"]]);
}
if (args.method === "search_read" && args.model == "ir.translation") {
return Promise.resolve([
{lang: 'en_US', src: '# Hello world', value: '# Hello world', id: 42},
{lang: 'fr_BE', src: '# Hello world', value: '# Bonjour le monde', id: 43}
]);
}
if (args.method === "write" && args.model == "ir.translation") {
assert.deepEqual(args.args[1], {value: "# Hellow mister Johns"},
"the new translation value should be written");
return Promise.resolve();
}
return this._super.apply(this, arguments);
},
});
await testUtils.form.clickEdit(form);
var $translateButton = form.$('div.o_field_markdown + .o_field_translate');
assert.strictEqual($translateButton.length, 1, "should have a translate button");
assert.strictEqual($translateButton.text(), 'EN', 'the button should have as test the current language');
await testUtils.dom.click($translateButton);
await testUtils.nextTick();
assert.containsOnce($(document), '.modal', 'a translate modal should be visible');
assert.containsN($('.modal .o_translation_dialog'), '.translation', 2,
'two rows should be visible');
var $dialogENSourceField = $('.modal .o_translation_dialog .translation:first() input');
assert.strictEqual($dialogENSourceField.val(), '# Hello world',
'English translation should be filled');
assert.strictEqual($('.modal .o_translation_dialog .translation:last() input').val(), '# Bonjour le monde',
'French translation should be filled');
await testUtils.fields.editInput($dialogENSourceField, "# Hellow mister Johns");
await testUtils.dom.click($('.modal button.btn-primary')); // save
await testUtils.nextTick();
var markdownField = _.find(form.renderer.allFieldWidgets)[1];
assert.strictEqual(markdownField._getValue(), "# Hellow mister Johns",
"the new translation was not transfered to modified record");
markdownField.simplemde.value(' **This is new English content**');
await testUtils.nextTick();
// Need to wait nextTick for data to be in markdownField.value and passed
// to the next dialog open
await testUtils.dom.click($translateButton);
await testUtils.nextTick();
assert.strictEqual($('.modal .o_translation_dialog .translation:first() input').val(), ' **This is new English content**',
'Modified value should be used instead of translation');
assert.strictEqual($('.modal .o_translation_dialog .translation:last() input').val(), '# Bonjour le monde',
'French translation should be filled');
form.destroy();
_t.database.multi_lang = multiLang;
});
Explaining the test suite
This test suite begins by asserting that the translationButton
is present. Then the test presses the button and checks that the Dialog opens and contains the right data.
The next step for the tests is to focus the input in that dialog and write something in the source (English), save it and verify that the changes are visible in our widget (SimpleMDE should have this new value).
Then we will change the value in our widget via SimpleMDE. Press the translate button again and inside the dialogue, the new source value should be what we just wrote in the widget. On the other hand, the value in French should have kept its value from the fake RPC Calls made.
Mocking RPC Calls
Each click to open the translate button actually makes multiple RPC calls to the server.
It queries the languages installed on the instance and then it queries for translations rows on that record for that field so we will have to mock the calls to the server.
We will mock the fetching of the translation languages, the fetching of the translation rows, and the writing of a new translation ( by returning an empty resolved Promise).
mockRPC: function (route, args) {
if (route === "/web/dataset/call_button" && args.method === 'translate_fields') {
assert.deepEqual(args.args, ["blog", 1, "content"], 'should call "call_button" route');
return Promise.resolve({
domain: [],
context: {search_default_name: 'blog,content'},
});
}
if (route === "/web/dataset/call_kw/res.lang/get_installed") {
return Promise.resolve([["en_US", "English"], ["fr_BE", "French (Belgium)"]]);
}
if (args.method === "search_read" && args.model == "ir.translation") {
return Promise.resolve([
{lang: 'en_US', src: '# Hello world', value: '# Hello world', id: 42},
{lang: 'fr_BE', src: '# Hello world', value: '# Bonjour le monde', id: 43}
]);
}
if (args.method === "write" && args.model == "ir.translation") {
assert.deepEqual(args.args[1], {value: "# Hellow mister Johns"},
"the new translation value should be written");
return Promise.resolve();
}
return this._super.apply(this, arguments);
},
Adding the Translate button
The translation button and event handling logic is located inside a mixin class in odoo/addons/web/static/src/js/fields/basic_fields.js
called TranslatableFieldMixin
.
We will inherit that mixin to have access to the function to render buttons, so we change the declaration of our widget
var markdownField = basicFields.DebouncedField.extend(basicFields.TranslatableFieldMixin, {
//...
}
Then, inside the start of our function, we will add the translate button in the edit mode condition
start: function () {
if (this.mode === 'edit') {
var $textarea = this.$el.find('textarea');
this.simplemde = new SimpleMDE({
element: $textarea[0],
initialValue: this.value,
});
var self = this;
this.simplemde.codemirror.on("change", function(){
self._setValue(self.simplemde.value());
})
if (this.field.translate) {
this.$el = this.$el.add(this._renderTranslateButton());
this.$el.addClass('o_field_translate');
}
}
return this._super();
},
Running the tests
Every test passed ✅ ! It took us longer to write the tests than the functionality as it is oftentimes with TDD. But it gives us confidence in the future when we will have to refactor the code for any reason.
Passing attributes to our widget
Widgets often have an option
attribute that you can pass directly inside the XML when you call the widget. These options are then accessible inside the widget itself via the nodeOptions
property.
SimpleMDE has options that we can pass inside the configuration object, for example, there is a placeholder
property that we can use if the SimpleMDE Editor is empty and show a text to invite the user to write something
var simplemde = new SimpleMDE({placeholder: "Begin typing here..."})
We already use the configuration object in our start
function to set the initialValue, we will do the same for other options.
In the end, we want to be able to use our widget like that:
<group>
<field name="content" widget="markdown" options="{'placeholder':'Write your content here'}"/>
</group>
And see the placeholder text inside our instance of SimpleMDE
Writing the tests
The options will be available in our field simplemde instance with markdownField.simplemde.options
object.
QUnit.test('web_widget_markdown passing property to SimpleMDE', async function(assert) {
assert.expect(1);
var form = await testUtils.createView({
View: FormView,
model: 'blog',
data: this.data,
arch: `<form string="Blog">
<group>
<field name="name"/>
<field name="content" widget="markdown" options="{'placeholder': 'Begin writing here...'}"/>
</group>
</form>`,
res_id: 1,
});
await testUtils.form.clickEdit(form);
var markdownField = _.find(form.renderer.allFieldWidgets)[1];
assert.strictEqual(
markdownField.simplemde.options.placeholder,
"Begin writing here...",
"SimpleMDE should have the correct placeholder"
);
await testUtils.form.clickSave(form);
form.destroy();
});
Run the tests, they will fail obviously.
Handling the options
To handle the attributes passed in the XML declaration we have access to this.nodeOptions
. With that in mind let's rewrite our instantiation inside the start
function.
start: function () {
if (this.mode === 'edit') {
var $textarea = this.$el.find('textarea');
var simplemdeConfig = {
element: $textarea[0],
initialValue: this.value,
}
if (this.nodeOptions) {
simplemdeConfig.placeholder = this.nodeOptions.placeholder || '';
}
this.simplemde = new SimpleMDE(simplemdeConfig);
this.simplemde.codemirror.on("change", this._doDebouncedAction.bind(this));
this.simplemde.codemirror.on("blur", this._doAction.bind(this));
if (this.field.translate) {
this.$el = this.$el.add(this._renderTranslateButton());
this.$el.addClass('o_field_translate');
}
}
return this._super();
},
Run the tests and you should see all green ✅
Refactoring the options assignment
We have 2 options:
- Inside the nodeOptions getting each option possible (that we want available) and passing them as config
- Letting the user pass any config options that he can find on SimpleMDE documentation.
We will try to do the latter by refactoring the way we map nodeOptions to config options via the Javascript ...
spread operator to combine 2 objects.
if (this.nodeOptions) {
simplemdeConfig = {...simplemdeConfig, ...this.nodeOptions};
}
If we run the tests again they are still green ✅ and now our user can pass any (for complex objects it will be complicated in the XML declaration) option he wants.
Conclusion
The source code for this Part 3 of the series is available here on GitHub.
In this long-running series, we tried to implement TDD in Odoo JavaScript development through the example of creating a new Field widget.
I hope you found it useful, we will use our widget later in another series where we create a totally new kind of view with Owl and use our widget inside. Become a member to have access to future posts so you don't miss any future articles.
- ☕️ Buying me a Coffee
- 🥳 Register on Codingdodo.com
Posted on May 26, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.