Create an Odoo 14 Markdown Widget Field with TDD - Part 3
Tags: tdd, javascript, odoo, tutorial
Published: 2021-05-26 13:28:34

---
title: Create an Odoo 14 Markdown Widget Field with TDD - Part 3
published: true
date: 2021-05-16 23:00:00 UTC
tags: TDD,JavaScript,Odoo,tutorial
canonical_url: https://codingdodo.com/create-odoo-markdown-widget-field-with-tdd-part-3/
series: Odoo 14 Javascript Test-Driven Development
---

## Introduction
 ![Create an Odoo 14 Markdown Widget Field with TDD - Part 3](https://codingdodo.com/content/images/2021/05/post-cover-tdd-part-3.png)

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:

```javascript
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`

```javascript
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
<?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 with _`self.$input.trigger`_`('change');`so we should also override the `reset` 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`

```javascript
var markdownField = basicFields.DebouncedField.extend({
    supportedFieldTypes: ['text'],
    template: 'FieldMarkdown',
    jsLibs: [
        '/web_widget_markdown/static/lib/simplemde.min.js',
    ],
    //...
```

Then we run our test suite

![Create an Odoo 14 Markdown Widget Field with TDD - Part 3](https://res.cloudinary.com/phildl-cloudinary/image/upload/v1617622056/codingdodo/Testing%20suite%20passed.png)

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`

```javascript
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:

```javascript
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

```javascript
this.simplemde.codemirror.on("change", function(){
    self._setValue(self.simplemde.value());
})
```

Replace with those lines

```javascript
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`

```javascript
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).

```javascript
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

```javascript
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

```javascript
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

![Create an Odoo 14 Markdown Widget Field with TDD - Part 3](https://res.cloudinary.com/phildl-cloudinary/image/upload/v1617631411/codingdodo/translate-field-test-suite-ok.png)

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

```javascript
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:

```xml
<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.

```javascript
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.

```javascript
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.

```javascript
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](https://github.com/Coding-Dodo/web_widget_markdown).

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](https://codingdodo.com/signup) to have access to future posts so you don't miss any future articles.

- ☕️ [Buying me a Coffee](https://www.buymeacoffee.com/CodingDodo)
- 🥳 Register on [Codingdodo.com](https://codingdodo.com)