Create custom tree field widget for json data with React

| | |

Joining tables in SQL is costly. Consider a database in which we store survey question templates. The normalized way would be to have a template table and a question table, with each question having a foreign key to the template that owns it. So whenever there is a need to render a survey, Odoo would need to query database twice, once for the template table and once for the question table. Sometimes, instead of the usual one to many relationship, we can optimize for read operations by lumping all related records into a json text field. The approach would suffer from low write throughput due to the need to lock the template table just to add a question. In reality, survey template is normally created once and read many times, that does not sound too bad a trade off. So in this post, we will look into rendering a json field as tree view in Odoo.

In order to store survey data, we will use a single model as below. Survey’s questions would be stored in a single json field content.

class BeollaTemplate(models.Model):
    """Odoo model to store survey data"""
    _name = 'beolla_survey.template'
    name = fields.Char(required=True)
    # json data field
    content = fields.Text()

Again, this is straight forward to do with Odoo. First we implement a custom widget which would take json data and render them as tree view. Secondly, we specifiy the field that should use that widget for rendering. Conceptually, our widget would need to do two things, render the json data as html, and handle user’s input to update that data accordingly.

1. Create a custom widget which display Json content using table view.

All custom field widget in Odoo extends the AbstractField widget. To render the json data as table view, we need to override renderReadonly and renderEdit, which render the field in readonly mode and edit mode respectively. Here we can use any javascript view framework of choice (Reactjs, Preactjs or VueJs…). I prefer PreactJs due to its lightweightedness. Please find more information on how to integerate PreactJs with Odoo in the previous post on running babel bundler.

odoo.define('beolla_survey.fields', function(require) {
"use strict"

var registry = require('web.field_registry');
var AbstractField = require('web.AbstractField');
var PreactViews = require('beolla_survey.preact_views'); 
var JsonTreeField = AbstractField.extend({

    /**
     * render in readonly mode
     * @override
     */    
    _renderReadonly: function () {
        this._renderTableView(true);
    },

    /**
     * render in edit mode
     * @override
     */
    _renderEdit: function() {
        this._renderTableView(false);
    },

    /**
     * Display a table view for json data     
     * @private
     */
    _renderTableView: function() {
        var questions = this.value? JSON.parse(this.value): [];
        var questionView = <QuestionList key={'question-list'} 
                                readonly={readonly}
                                create={this._createQuestion.bind(this)}
                                items={questions}/>        
        if (this.$el[0].children.length > 0) {
            // tell preact to update existing nodes
            preact.render(questionView, this.$el[0], ...this.$el[0].children);
        }
        else {
            preact.render(questionView, this.$el[0]);
        }
    }

});

// add the custom widget to Odoo's widget registry
registry.add('json_tree', JsonTreeField);
})

<QuestionList/> is a preact view class. Its aim is to replicate the look and feel of Odoo’s table view. Please refer to the end of this post for detailed implementation. Notice that we need to pass a _createQuestion method to the view, which will be responsible for adding new question to the template.

2. Update field value with new question popup form.

Similar to Odoo’s built-in table view, our custom view has a Add a question button at the end, which shows a pop-up form to create a new question. Once user clicks save, question data will be updated to the model’s content field by the _saveQuestion method.

    /**
     * Show question form dialog when user click `add new question`
     */
    _createQuestion: function(evt) {
        evt.stopPropagation();
        evt.preventDefault();   
        // this.setState({activeQuestion: {id: 'new'}});
        new QuestionEditDialog(this, 
                {id: 'new'}, 
                this._saveQuestion.bind(this)
            ).open();
    },

    /**
     * Update question value 
     */
    _saveQuestion: function(val) {
        var items = this.value? JSON.parse(this.value): [];
        if (val.id === 'new')         
            items.push(val);
        else {
            for (var i = 0; i < items.length; i++) {
                if (items[i].id === val.id) {
                    items[i] = val;
                    break;
                }
            }
        }
        this.isDirty = true;
        if (!this.isDestroyed()) {
            return this._setValue(JSON.stringify(items));
        }
    }

QuestionEditDialog is basically a Odoo Dialog subclass to display the question edit form. Implementation of it is listed at the end of this post.

3. Using the widget

Last but not least, we will need to let Odoo know that it should use the custom widget json_tree to render content field. This is done by specifying the widget property of field in xml.

<record id="view_beolla_survey_survey_template" model="ir.ui.view">
    <field name="name">beolla_survey.template.form</field>
    <field name="model">beolla_survey.template</field>
    <field name="arch" type="xml">
        <form string="Survey Template">
            <sheet>
                <div class="oe_button_box" name="button_box">
                </div>
                <div class="oe_title">
                  <label class="oe_edit_only" for="name" string="Template Name"/>
                  <h1><field name="name" placeholder="Template Name"/></h1>
                </div>                
                <notebook>
                  <page string="Questions">                   
                      <field name="content" widget="json_tree" />                    
                  </page>
                </notebook>
            </sheet>
        </form>
    </field>
</record>

And be sure to add your javascript file to Odoo’s bundle.

<template id="beolla_survey_json_tree_field" name="beolla_survey assets" inherit_id="web.assets_backend">
    <xpath expr="." position="inside">
        <script type="text/javascript"
                src="/beolla_survey/static/src/js/template.js"></script>
    </xpath>
</template>

And that is what we need to have a custom json based tree view.

4. Preact view classes

QuestionList

For more information on how to use custom js view frameworks in Odoo, please refer to the post. Though we used PreactJs, the same idea can be applied to ReactJs or VueJs.

class QuestionList extends preact.Component {
    render(props, state) {
        let questions = []
        for (let i = 0; i < props.items.length; i++) {
            let item = props.items[i];
            questions.push(<tr >
                <td>{'Question ' + (i + 1)}</td><td>{item.excerpt}</td>
            </tr>)
        }
        if (!props.readonly) {
            questions.push(<tr><td colspan={2}><a href='#' role='button' onClick={props.createQuestion}>Add a question</a></td></tr>)
        }
        if (questions.length < 4) {
            let empty = 4 - questions.length;
            for (let i = 0; i < empty; i++) {
                questions.push(<tr><td colspan={2}>&nbsp;</td></tr>)
            }
        }
        return (<div class='table-responsive'>
                <table class='o_list_view table table-sm table-hover table-striped o_list_view_ungrouped o_editable_list'>
                    <thead>
                        <tr><th class='o_column'>Order</th><th class='o_column'>Content</th></tr>
                    </thead>
                    <tbody class='ui-sortable'>                     
                        {questions}
                    </tbody>
                    <tfoot>
                        <tr><td></td><td></td></tr>
                    </tfoot>
        </table></div>);
    }
}

QuestionEditDialog

var Dialog = require('web.Dialog');
var QuestionEditDialog = Dialog.extend({
    dialog_title: _t("Edit Question"),
    template: 'beolla_survey.QuestionEditDialog',

    /**
     * @override
     * @param {integer|string} channelID id of the channel,
     *      a string for static channels (e.g. 'mailbox_inbox').
     */
    init: function (parent, question, onSave) {
        this._question = question;
        this._onSave = onSave;

        this._super(parent, {
            size: 'medium',
            buttons: [{
                text: _t("Save"),
                close: true,
                classes: 'btn-primary',
                click: this._saveQuestion.bind(this),
            }, {
                text: _t('Cancel'),
                close: true
            }],
        });


    },
    /**
     * @override
     */
    start: function () {
        var self = this;
        // QuestionForm is a Preact View class
        // which is pretty simple to implement.
        preact.render(<QuestionForm question={this._question} ref={quesionForm=>this.quesionForm=quesionForm}/>, this.$el[0]);
        return this._super.apply(this, arguments);
    }

    _saveQuestion: function() {
        // get question
        
        var questionData = this.quesionForm.getQuestion();
        // console.log('Article data: ', outputData)
            // validate data
        let outputData = questionData.outputData,
            answers = questionData.answers;
        
        this._onSave({
            content: outputData,
            excerpt: excerpt,
            answers: answers,
            id: this._question.id
        })
        
    }
}

Source code of this demo will be uploaded after code cleaning up.