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

codingdodo

Coding Dodo

Posted on May 26, 2021

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

Introduction

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

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

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

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>

Enter fullscreen mode Exit fullscreen mode

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

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

Then we run our test suite

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

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

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

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

Replace with those lines

this.simplemde.codemirror.on("change", this._doDebouncedAction.bind(this));
this.simplemde.codemirror.on("blur", this._doAction.bind(this));
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Running the tests

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

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

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

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

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

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

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.

💖 💪 🙅 🚩
codingdodo
Coding Dodo

Posted on May 26, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related