Introduction
This is the second part of an article series where we use TDD to develop an Odoo markdown widget.
Create an Odoo 14 Markdown Widget Field with TDD - Part 1
In the last part (code available here) we ended up with a functional widget transforming pure text markdown content into HTML in render mode and behaving like a standard FieldText when in edit mode.
In this tutorial, we are going to use SimpleMDE Editor instead of the standard FieldText <textarea>
input.
Refactoring and adding new tests
First of all, we are going to remove the test named web_widget_markdown edit form
. As a reminder, this test was used to Edit the form and write into the input like that:
await testUtils.form.clickEdit(form);
await testUtils.fields.editInput(form.$('.o_field_markdown'), ' **bold content**');
await testUtils.form.clickSave(form);
The problem is that the editInput
function will not work anymore because SimpleMDE will replace the whole <textarea>
with his own editor and writing inside will not be possible.
How to test SimpleMDE presence
To test for SimpleMDE presence we have to analyze how this library insert its editor into the DOM, and a quick inspect gives us more info:
<div class="CodeMirror cm-s-paper CodeMirror-wrap">
<div style="overflow: hidden; position: relative; width: 3px; height: 0px; top: 15px; left: 38.8281px;" data-children-count="1">
<textarea autocorrect="off" autocapitalize="off" spellcheck="false" tabindex="0" style="position: absolute; padding: 0px; width: 1000px; height: 1em; outline: none;">
</textarea>
</div>
<div class="CodeMirror-vscrollbar" cm-not-content="true" style="bottom: 0px; width: 12px; pointer-events: none;">
...
</div>
</div>
As we can see, SimpleMDE uses the underlying library CodeMirror to create his editor. So checking for the presence of the div
with class .CodeMirror
should validate the presence of the Editor. Let's write a new test.
QUnit.test('web_widget_markdown SimpleMDE is present', 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"/>' +
'</group>' +
'</form>',
res_id: 1,
});
await testUtils.form.clickEdit(form);
assert.strictEqual(
form.$('.o_field_markdown').find("div.CodeMirror").length,
1,
"CodeMirror div should be present"
)
form.destroy();
});
How to test that SimpleMDE is working
To test that simple MDE is working we should:
- First, go into Edit mode so SimpleMDE is initialized (previous test)
- Check that the initial value of our model (data) is passed to SimpleMDE
- Change the value of SimpleMDE content (mock behavior of user writing inside the WYSIWYG) and verify that Odoo widget value has been updated
- Save the Form and assert that our edits are saved and present
Writing the tests
To go through our test we will need to have access to the widget itself from the mocked FormView. Form object have a renderer
attribute that will be helpful in that situation by inspecting it's allFieldWidgets
property:
// [1] because in our form the first field is for the name of the blog
// So the first field is in [0] and ours is in [1]
var markdownField = _.find(form.renderer.allFieldWidgets)[1];
Inside the test, we want to be able to have access to the SimpleMDE instance directly from the widget.
Often times, we write tests that drive us to implement the solution in a specific way. In this example we know that we want the Widget Object to hold a property object named simplemde
containing the current instance of new SimpleMDE
Editor. This will help us to initialize it, destroy it, set, or get its value. This is a powerful way of programming because the test help us make more robust APIs by directly needing us to implement the strict necessary functions for it to be functional.
So given the idea, we have that property available the test can be written like that
QUnit.test('web_widget_markdown edit SimpleMDE', async function(assert) {
assert.expect(4);
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,
});
await testUtils.form.clickEdit(form);
var markdownField = _.find(form.renderer.allFieldWidgets)[1];
assert.strictEqual(
markdownField.simplemde.value(),
"# Hello world",
"Initial Value of SimpleMDE should be set"
)
markdownField.simplemde.value(' **bold content**');
assert.strictEqual(
markdownField._getValue(),
" **bold content**",
"If we change value in SimpleMDE, value of odoo widget should be updated"
)
await testUtils.form.clickSave(form);
assert.strictEqual(
form.$('.o_field_markdown').find("strong").length,
1,
"After Save, b should be present"
)
assert.strictEqual(
form.$('.o_field_markdown strong').text(),
"bold content",
"After Save, <strong> should contain 'bold content'"
)
form.destroy();
});
We cannot properly interact with the CodeMirror editor with JQuery testUtils so we will refer to the CodeMirror user manual to see how to insert a value (this is also what happens when user type) and this is how we will do it from the test function:
markdownField.simplemde.codemirror.setValue(' **bold content**');
And to test that the Odoo field itself has the same value as the Markdown editor we make this assertion.
assert.strictEqual(
markdownField._getValue(),
" **bold content**",
"Value of odoo widget should be updated"
)
General knowledge: Understanding _getValue() in Odoo Widget
_getValue()
is a function first defined in the DebouncedField
(FieldText inherits DebouncedField).
// Inside DebouncedField in odoo/addons/web/static/src/js/fields/basic_fields.js
/**
* Should return the current value of the field, in the DOM (for example,
* the content of the input)
*
* @abstract
* @private
* @returns {*}
*/
_getValue: function () {},
A DebouncedField
is a superclass that handles the debouncing of the user input.
Debouncing an input in JavaScript is a common technique to reduce the rate of execution of a function. If a user is typing inside an input and you execute a function on each change of that input (each letter typed) it can quickly lead to a lot of computation power being used on just that. The common technique is called debounced and it will delay the execution of the function listening to the input, only every X secondes. Odoo use
_.debounce
for this with the underscorejs library.
This is a summarized view of the Odoo Fields Widget inheritance graph
// the super class
var AbstractField = {}
// handle debouncing
var DebouncedField = AbstractField.extend({})
// handle keystroke evnts, state and other things
var InputField = DebouncedField.extend({})
// more specific implementations using InputField logic
var FieldText = InputField.extend({})
var FieldChar = InputField.extend({})
var FieldDate = InputField.extend({})
var FieldDate = InputField.extend({})
Most of all the field inheriting InputField are overriding this _getValue()
function to return more than the basic this.value
property of a widget and we will do the same.
Running the tests in the current state of our widget expectedly fail.
Initialize SimpleMDE editor in widget Edit mode
As we wrote our tests earlier, we know that we need to have simplemde
as a property of our widget, let's then extend the init
function of our widgetto do so:
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
this.simplemde = {}
},
Attaching SimpleMDE to our Widget dom root element.
And in the start
function (available in all Odoo Widgets) we will do this:
/**
* When the the widget render, check view mode, if edit we
* instanciate our SimpleMDE
*
* @override
*/
start: function () {
if (this.mode === 'edit') {
this.simplemde = new SimpleMDE({element: this.$el[0]});
}
return this._super();
},
When we instantiate SimpleMDE we need to at least give him the element
option or else it will attach itself to any <textarea>
existing (this the the default behavior of the library).
What is this.$el[0]
?
this.$el
is a JQuery object and not a pure dom Element as required by SimpleMDE, so by doing this.$el[0]
we get the proper dom element.
Keep in mind that we inherit FieldText, and FieldText has some original logic about the HTML element it uses to render itself. In read-only mode, it is a <span>
and in edit mode the tag change, as seen here in the source code of the FieldText
:
/**
* @constructor
*/
init: function () {
this._super.apply(this, arguments);
if (this.mode === 'edit') {
this.tagName = 'textarea';
}
}
Running the tests and analyzing the error
Now if we run the tests we will see this error
- Cannot read property 'insertBefore' of null@ 121 ms
TypeError: Cannot read property 'insertBefore' of null
at http://localhost:8069/web_widget_markdown/static/lib/simplemde.min.js:12:1240
at new t (http://localhost:8069/web_widget_markdown/static/lib/simplemde.min.js:7:31640)
at new e (http://localhost:8069/web_widget_markdown/static/lib/simplemde.min.js:7:29476)
at e (http://localhost:8069/web_widget_markdown/static/lib/simplemde.min.js:7:29276)
at Function.e.fromTextArea (http://localhost:8069/web_widget_markdown/static/lib/simplemde.min.js:12:1213)
at B.render (http://localhost:8069/web_widget_markdown/static/lib/simplemde.min.js:15:4157)
at new B (http://localhost:8069/web_widget_markdown/static/lib/simplemde.min.js:14:28861)
at Class.start (http://localhost:8069/web_widget_markdown/static/src/js/field_widget.js:34:30)
at Class.prototype.<computed> [as start] (http://localhost:8069/web/static/src/js/core/class.js:90:38)
at http://localhost:8069/web/static/src/js/core/widget.js:440:25
The error actually comes from the simplemde library trying to insert itself into the DOM. We gave him $el[0]
as an element. And as seen in the source code, the actual element given is a <textarea>
, this is due to us inheriting FieldText.
But the problem actually comes from the surrounding of the <textarea>
element. SimpleMDE will actually use parentNode
on the element given to place itself. The element given as $el[0]
as is has no parent due to the way the Odoo Framework inserts it in the DOM.
So the base template of our field cannot be as simple as a span, it has to be encapsulated by another div or something else.
Moving to a dedicated Qweb Template for our widget
To create a Template for a widget we need to create an XML file containing our template then explicitly use it in our javascript widget declaration.
The Qweb template
Create the file static/src/xml/qweb_template.xml
with this content.
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="FieldMarkdown">
<div class="o_field_markdown">
<textarea name="o_field_markdown" id="o_field_markdown"></textarea>
</div>
</t>
</templates>
We gave our template the same name t-name="FieldMarkdown
as the name we export in our JavaScript file for consistency.
Inside it is just a wrapper div class with the same class .o_field_markdown
we used before and inside it a <textare>
for SimpleMDE to attach to.
Add it to your __manifest__.py
"qweb": [
'static/src/xml/qweb_template.xml',
],
Using the template in our JavaScript field widget
var markdownField = basicFields.FieldText.extend({
supportedFieldTypes: ['text'],
// className: 'o_field_markdown',
template: 'FieldMarkdown', // name of template in xml Qweb file
jsLibs: [
'/web_widget_markdown/static/lib/simplemde.min.js',
],
// ...
We removed the className
attribute because it is no longer useful.
Run the tests again and surely it fails again because we still tell SimpleMDE to attach itself to the root $el of our widget.
Refactoring our widget to use the new template
Inside the start function of the widget, we will target the <textarea>
inside the <div>
we created in the template.
start: function () {
if (this.mode === 'edit') {
var $textarea = this.$el.find('textarea');
this.simplemde = new SimpleMDE({element: $textarea[0]});
}
return this._super();
},
Now if we run the tests again:
- Markdown Widget Tests: web_widget_markdown SimpleMDE is present (1) ✅
- Markdown Widget Tests: web_widget_markdown edit SimpleMDE (3, 0, 3) ❌
It means our SimpleMDE is well initialized but there is no communication of value between the widget and SimpleMDE editor.
Communication between SimpleMDE and the widget
Initialize SimpleMDE with data value
The first test we will try to pass is Initial Value of SimpleMDE should be set. To do so, we will refer to the SimpleMDE documentation on setting and getting value.
We see that there is a simple method set("value")
but also an initialValue
that can be passed at instantiation. We will choose the second solution and make these changes to the start
function of our widget:
start: function () {
if (this.mode === 'edit') {
var $textarea = this.$el.find('textarea');
this.simplemde = new SimpleMDE({
element: $textarea[0],
initialValue: this.value, // this.value represents widget data
});
}
return this._super();
},
Now we run the tests again and surely see that our first test passed ✅
In the first part, we handled the _renderReadonly
function, now that we work on edit mode we will override the function _renderEdit
to set the value into SimpleMDE, add these methods to the widget
_formatValue: function (value) {
return this._super.apply(this, arguments) || '';
},
_renderEdit: function () {
this._super.apply(this, arguments);
var newValue = this._formatValue(this.value);
if (this.simplemde.value() !== newValue) {
this.simplemde.value(newValue);
}
},
SimpleMDE can't handle false or null value so the function _formatValue
is there to help us return an empty string when there is nothing in the field.
_renderEdit
and _renderReadonly
are called by the main _render
function that is defined in odoo/addons/web/static/src/js/fields/abstract_field.js
. This main render function handles the conditional logic of the widget being in Edit or Readonly mode and call the correct function:
_render: function () {
if (this.attrs.decorations) {
this._applyDecorations();
}
if (this.mode === 'edit') {
return this._renderEdit();
} else if (this.mode === 'readonly') {
return this._renderReadonly();
}
},
Again we run the tests and everything is still green ✅ so we can go to the next step.
Listening to change in SimpleMDE to update our widget value.
In our previous test, we wrote that markdownField._getValue()
should be equal to what we write inside the SimpleMDE editor.
Naturally we will add that _getValue()
function and make it return the inner value of SimpleMDE.
/**
* return the SimpleMDE value
*
* @private
*/
_getValue: function () {
return this.simplemde.value();
},
Since we have access to the property simplemde
that we initialize in our widget it is very easy to get the data.
Then, to listen to changes, we have to get the CodeMirror instance of our SimpleMDE and listen to its change
events that CodeMirror is triggering.
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());
})
}
return this._super();
},
We had to declare var self = this
to be able to use it in the callback function.
With that change made let's run the tests again
- The initial Value of SimpleMDE should be set@ 75 ms ✅
- If we change the value in SimpleMDE, the value of the odoo widget should be updated@ 81 ms ✅
- After Save, b should be present@ 380 ms ✅
- After Save, should contain 'bold content' ✅
Victory !
Now that our tests passed we can try the module from the user perspective
Unfortunately, we can see that there is some problem with the aspect of our Markdown Editor.
It seems that the height is fixed so there is not enough space for it. This is coming from the fact that we are extending the FieldText widget and it has built-in auto-resize features.
In the next part, we will see how to deal with that as we improve our widget.
The source code for this tutorial is available here on GitHub.
✨ Update 17/06/2021 🎓 Third part is now available here
Thanks for reading, if you liked this article please consider:
- ☕️ Buying me a Coffee
- 🥳 Register on Codingdodo.com
Top comments (0)