DEV Community

Coding Dodo
Coding Dodo

Posted on • Originally published at codingdodo.com on

Create an Odoo 14 Markdown Widget Field with TDD - Part 2

Introduction

Create an Odoo 14 Markdown Widget Field with TDD - Part 2

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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**');
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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 () {},
Enter fullscreen mode Exit fullscreen mode

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({})
Enter fullscreen mode Exit fullscreen mode

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.

Create an Odoo 14 Markdown Widget Field with TDD - Part 2

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 = {}
},
Enter fullscreen mode Exit fullscreen mode

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();
},
Enter fullscreen mode Exit fullscreen mode

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';
    }
}
Enter fullscreen mode Exit fullscreen mode

Running the tests and analyzing the error

Now if we run the tests we will see this error

  1. 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
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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',
],
Enter fullscreen mode Exit fullscreen mode

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',
    ],
    // ...
Enter fullscreen mode Exit fullscreen mode

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();
},
Enter fullscreen mode Exit fullscreen mode

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();
},
Enter fullscreen mode Exit fullscreen mode

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);
        }
    },
Enter fullscreen mode Exit fullscreen mode

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();
        }
    },


Enter fullscreen mode Exit fullscreen mode

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();
},
Enter fullscreen mode Exit fullscreen mode

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();
},
Enter fullscreen mode Exit fullscreen mode

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

  1. The initial Value of SimpleMDE should be set@ 75 ms ✅
  2. If we change the value in SimpleMDE, the value of the odoo widget should be updated@ 81 ms ✅
  3. After Save, b should be present@ 380 ms ✅
  4. After Save, should contain 'bold content' ✅

Victory !

Now that our tests passed we can try the module from the user perspective

Create an Odoo 14 Markdown Widget Field with TDD - Part 2

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:

Top comments (0)