Skip to content
•13 min read

Odoo 15 JavaScript Tutorial: OWL View migration guide.

Philippe L'ATTENTION
Odoo 15 JavaScript Tutorial: OWL View migration guide

Odoo 15 is out, and big internal rework has been done to the WebClient. You can still write Views in the old MVC Architecture but now you also can write pure OWL Component Views.

The architecture is a bit different from the old MVC but with some helpers, and the good rewrite of the WebClient, it is actually not that hard, as you will see in this follow-along tutorial.

As an example, we will completely migrate the module we created in our JavaScript 101 Series, Creating an OWL View that was a Hierarchical Tree View, let's go!

Introduction

This article is meant to be followed along, so please consider pulling the Odoo 14 version of our Module and coding along with each change. To pull directly the main branch:

git clone https://github.com/Coding-Dodo/owl_tutorial_views.git

Then cd into the folder and create a new branch that will be your basis of work:

cd owl_tutorial_views
git checkout -b 15.0-mig-owl_tutorial_views

Finally, try to run the module again often so you can see from your own eyes what breaks and how we fix it.

Updating our assets bundling to the manifest

The first action is to use the new assets key in the __manifest__ file. Let's take everything that was inside our assets.xml (and remove the call to that file) and put it inside the manifest file inside the web.assets_backend bundle.

--- a/__manifest__.py
+++ b/__manifest__.py
@@ -4,14 +4,24 @@
     "author": "Coding Dodo",
     "website": "https://codingdodo.com",
     "category": "Tools",
-    "version": "14.0.1",
+    "version": "15.0.1",
     "depends": ["base", "web", "mail", "product"],
     "qweb": [
         "static/src/components/tree_item/TreeItem.xml",
         "static/src/xml/owl_tree_view.xml",
     ],
     "data": [
-        "views/assets.xml",
         "views/product_views.xml",
     ],
+    "assets": {
+        "web.assets_backend": [
+            "/owl_tutorial_views/static/src/components/tree_item/tree_item.scss",
+            "/owl_tutorial_views/static/src/owl_tree_view/owl_tree_view.scss",
+            "/owl_tutorial_views/static/src/components/tree_item/TreeItem.js",
+            "/owl_tutorial_views/static/src/owl_tree_view/owl_tree_view.js",
+            "/owl_tutorial_views/static/src/owl_tree_view/owl_tree_model.js",
+            "/owl_tutorial_views/static/src/owl_tree_view/owl_tree_controller.js",
+            "/owl_tutorial_views/static/src/owl_tree_view/owl_tree_renderer.js",
+        ],
+    },
 }

Now for the Qweb XML files

The qweb key in the manifest should also be emptied, instead, the QWeb templates will go inside the web.assets_qweb bundle:

--- a/__manifest__.py
+++ b/__manifest__.py
@@ -6,14 +6,14 @@
     "category": "Tools",
     "version": "15.0.1",
     "depends": ["base", "web", "mail", "product"],
-    "qweb": [
-        "static/src/components/tree_item/TreeItem.xml",
-        "static/src/xml/owl_tree_view.xml",
-    ],
     "data": [
         "views/product_views.xml",
     ],
     "assets": {
+        "web.assets_qweb": [
+            "/owl_tutorial_views/static/src/components/tree_item/TreeItem.xml",
+            "/owl_tutorial_views/static/src/xml/owl_tree_view.xml",
+        ],
         "web.assets_backend": [
             "/owl_tutorial_views/static/src/components/tree_item/tree_item.scss",
             "/owl_tutorial_views/static/src/owl_tree_view/owl_tree_view.scss",

We would be tempted to try and run the module right now but we would get an error:

Missing dependencies:     ["web.patchMixin"]

PatchMixin disappeared!

The patchMixin doesn't exist anymore in Odoo v15, it was a temporary solution in v14. So we have to remove import statements and usage reference from our OWLTreeRenderer and our TreeItem Components:

--- a/static/src/components/tree_item/TreeItem.js
+++ b/static/src/components/tree_item/TreeItem.js
@@ -3,7 +3,6 @@ odoo.define(
   function (require) {
     "use strict";
     const { Component } = owl;
-    const patchMixin = require("web.patchMixin");
     const { useState } = owl.hooks;

     class TreeItem extends Component {
@@ -67,6 +66,6 @@ odoo.define(
       template: "owl_tutorial_views.TreeItem",
     });

-    return patchMixin(TreeItem);
+    return TreeItem;
   }
 );

Same for the OWLTreeRenderer:

--- a/static/src/owl_tree_view/owl_tree_renderer.js
+++ b/static/src/owl_tree_view/owl_tree_renderer.js
@@ -2,7 +2,6 @@ odoo.define("owl_tutorial_views.OWLTreeRenderer", function (require) {
   "use strict";

   const AbstractRendererOwl = require("web.AbstractRendererOwl");
-  const patchMixin = require("web.patchMixin");
   const QWeb = require("web.QWeb");
   const session = require("web.session");

@@ -52,5 +51,5 @@ odoo.define("owl_tutorial_views.OWLTreeRenderer", function (require) {
     template: "owl_tutorial_views.OWLTreeRenderer",
   });

-  return patchMixin(OWLTreeRenderer);
+  return OWLTreeRenderer;
 });

Migrating to the Native JavaScript Module system

Odoo 15 introduced a new way of defining our JavaScript module instead of the usual odoo.define, we can now use a syntax similar to ES6 modules and import.

According to the official documentation:

Most new Odoo javascript code should use the native javascript module system. This is simpler, and brings the benefits of a better developer experience with a better integration with IDE.

So let's migrate the syntax.

Basic conversion for the TreeItem Component

So, let's change our TreeItem Component accordingly.

--- a/static/src/components/tree_item/TreeItem.js
+++ b/static/src/components/tree_item/TreeItem.js
@@ -1,11 +1,8 @@
-odoo.define(
-  "owl_tutorial_views/static/src/components/tree_item/TreeItem.js",
-  function (require) {
-    "use strict";
+/** @odoo-module **/
 const { Component } = owl;
 const { useState } = owl.hooks;

-    class TreeItem extends Component {
+export class TreeItem extends Component {
   /**
    * @override
    */
@@ -65,7 +62,3 @@ odoo.define(
   },
   template: "owl_tutorial_views.TreeItem",
 });
-
-    return TreeItem;
-  }
-);

Now that our TreeItem is converted to odoo-module how do we import it? Let's update OwlTreeRenderer:

--- a/static/src/owl_tree_view/owl_tree_renderer.js
+++ b/static/src/owl_tree_view/owl_tree_renderer.js
@@ -4,6 +4,9 @@ odoo.define("owl_tutorial_views.OWLTreeRenderer", function (require) {
   const AbstractRendererOwl = require("web.AbstractRendererOwl");
   const QWeb = require("web.QWeb");
   const session = require("web.session");
+  const {
+    TreeItem,
+  } = require("@owl_tutorial_views/components/tree_item/TreeItem");

   const { useState } = owl.hooks;

@@ -23,9 +26,7 @@ odoo.define("owl_tutorial_views.OWLTreeRenderer", function (require) {
     }
   }

-  const components = {
-    TreeItem: require("owl_tutorial_views/static/src/components/tree_item/TreeItem.js"),
-  };
+  const components = { TreeItem };
   Object.assign(OWLTreeRenderer, {
     components,
     defaultProps: {

Notice the conversion to @owl_tutorial_views/components/tree_item/TreeItem

This is the rule:

  • @name_of_your_module
  • + relative path beginning inside static/src.
  • don't put the .js extension

So for a file inside owl_tutorial_views/static/src/components/tree_item/TreeItem.js, we get @owl_tutorial_views/components/tree_item/TreeItem

Aliased JavaScript Module conversion for the OWLTreeRender Component.

To reduce some of the friction caused by redefining the import/require that are already here in the code, as described before, you can alias your odoo-module so it keeps the same name as before.

Let's update our OWLTreeRenderer to the new odoo-module syntax but, this time, we use an alias corresponding to the old name of the module.

First, for the owl_tree_renderer.js file:

--- a/static/src/owl_tree_view/owl_tree_renderer.js
+++ b/static/src/owl_tree_view/owl_tree_renderer.js
@@ -1,16 +1,11 @@
-odoo.define("owl_tutorial_views.OWLTreeRenderer", function (require) {
-  "use strict";
-
-  const AbstractRendererOwl = require("web.AbstractRendererOwl");
-  const QWeb = require("web.QWeb");
-  const session = require("web.session");
-  const {
-    TreeItem,
-  } = require("@owl_tutorial_views/components/tree_item/TreeItem");
-
+/** @odoo-module alias=owl_tutorial_views.OWLTreeRenderer default=0 **/
 const { useState } = owl.hooks;
+import AbstractRendererOwl from "web.AbstractRendererOwl";
+import QWeb from "web.QWeb";
+import session from "web.session";
+import { TreeItem } from "@owl_tutorial_views/components/tree_item/TreeItem";

-  class OWLTreeRenderer extends AbstractRendererOwl {
+export default class OWLTreeRenderer extends AbstractRendererOwl {
   constructor(parent, props) {
     super(...arguments);
     this.qweb = new QWeb(this.env.isDebug(), { _s: session.origin });
@@ -51,6 +46,3 @@ odoo.define("owl_tutorial_views.OWLTreeRenderer", function (require) {
   },
   template: "owl_tutorial_views.OWLTreeRenderer",
 });
-
-  return OWLTreeRenderer;
-});

Notice that compared to our last example, here we use export default. The default will allow us to directly require the object and not have to destructure it like { ComponentToImport } = require(...). There is less friction like that.

As you can see we also transformed every require into import.

Now inside the main View file owl_tree_view.js we don't have to change anything, our old require will still work.

const OWLTreeRenderer = require("owl_tutorial_views.OWLTreeRenderer");

Refresh your page and your module should now work correctly.

That's it for the minimal migration path that we could take on our JavaScript Module. The module is functional and no features were lost in the process but it still uses the old MVC architecture...

Transitioning from old MVC View to full OWL

This is where things are going to change a lot in our code. Let's have an overview of what we will do, then we will go through each step:

  • Migrate our Model to the @web/views/helpers/model and use the useModel hook.
  • Use the Layout Component, so our View has access to the SearchPanel and ControlPanel.
  • Getting rid of the Controller, the View will now react to events and call Model functions.

Doing this will bring a lot of changes mainly in the Model and View files but it is important to note that all the other files should be very lightly impacted by this transition.

What I want to highlight here is that, if you wrote your OWL Component from Odoo 14 version, you should be able to migrate your code to v15 with minimal friction.

Migrating to the new Model class

Switch to odoo-module and new Model Class.

Let's migrate our OWLTreeModel Class to the new Model from @web/views/helpers/model and transform the file into an odoo-module file.

First, let's update the file into an odoo-module and update the imports:

--- a/static/src/owl_tree_view/owl_tree_model.js
+++ b/static/src/owl_tree_view/owl_tree_model.js
@@ -1,9 +1,8 @@
-odoo.define("owl_tutorial_views.OWLTreeModel", function (require) {
-  "use strict";
+/** @odoo-module alias=owl_tutorial_views.OWLTreeModel default=0 **/
+import { Model } from "@web/views/helpers/model";
+import { KeepLast } from "@web/core/utils/concurrency";

-  var AbstractModel = require("web.AbstractModel");
-
-  const OWLTreeModel = AbstractModel.extend({
+export class OWLTreeModel extends Model {
   /**
    * Make an RPC 'write' method call to update the parent_id of
    * an existing record.

With this new Model class, we have to define a setup function that will:

  • Inject the orm service dependency so we can make the RPC calls.
  • Set properties like the res.model name and the concurrency keepLast utility.
setup(params, { orm }) {
    this.modelName = params.resModel;
	this.orm = orm;
	this.keepLast = new KeepLast();
}

Implementing the load function

The load function is the only one that will react to changes (coupled with the useModel hook). You can delete these old functions, they are useless now:

Delete:

  • __get
  • __load
  • __reload
  • _fetchData
  • _fetchFilteredData

Add the load function:

async load(params) {
  let isSearch = false;
  let domain = [["parent_id", "=", false]];
  if (params.domain && params.domain.length > 0) {
    isSearch = true;
    domain = params.domain;
  }
  let result = await this.keepLast.add(
    this.orm.searchRead(this.modelName, domain, [])
  );
  if (isSearch) {
    for (const item of result) {
      this.data = await this._recursivelyOpenParents(item);
    }
  } else {
    this.data = result;
  }
  this.notify();
}

The logic is the same as before, the initial domain fetches the root item with no parent. And if params are coming from the SearchPanel, the domain is filled for the search, then we recursively get to the parent.

Converting the other functions

The last step is to convert all the functions declarations now that we are in an ES6 Class and use this orm service instead of the old rpc.

The items will be put directly into the this.data this time, so it will be more readable later.

Also, don't forget to add the notify call at the end of your function so the Component associated will be refreshed.

Example with a write RPC call.

-    changeParent: async function (id, parent_id) {
-      await this._rpc({
-        model: this.modelName,
-        method: "write",
-        args: [
-          [id],
-          {
-            parent_id: parent_id,
-          },
-        ],
-      });
-    },
-
+  async changeParent(id, parent_id) {
+    await this.orm.write(this.modelName, [id], { parent_id: parent_id });
+    this.notify();
+  }

Example with a read RPC call.

-    refreshNode: async function (id) {
+  async refreshNode(id) {
     var self = this;
     var result = null;
-      await this._rpc({
-        model: this.modelName,
-        method: "read",
-        args: [[id], []],
-      }).then(function (itemUpdated) {
+    let itemUpdated = await this.orm.read(this.modelName, [id], []);
+
     let path = itemUpdated[0].parent_path;
     let target_node = self.__target_parent_node_with_path(
       path.split("/").filter((i) => i),
-          self.data.items
+      self.data
     );
     target_node = itemUpdated[0];
     result = itemUpdated[0];
-      });
+    this.notify();
     return result;
-    },
+  }

Example with a search_read RPC call.

-    expandChildrenOf: async function (parentId, path) {
+  async expandChildrenOf(parentId, path) {
     var self = this;
-      await this._rpc({
-        model: this.modelName,
-        method: "search_read",
-        kwargs: {
-          domain: [["parent_id", "=", parentId]],
-        },
-      }).then(function (children) {
+    let children = await this.orm.searchRead(this.modelName, [
+      ["parent_id", "=", parentId],
+    ]);
     var target_node = self.__target_parent_node_with_path(
       path.split("/").filter((i) => i),
-          self.data.items
+      self.data
     );
     target_node.children = children;
     target_node.child_id = children.map((i) => i.id);
     target_node.childrenVisible = true;
-      });
-    },
+    this.notify();
+  }

You get the idea, convert the function definitions, use data instead of data.items to store results, convert old RPC calls to orm service function, and don't forget to use the notify function. The full code of the Model class is available on branch 15.0 of the repo (link at the end of the article).

Direct link to the commit migrating the Model class.

Transform the main View class to an OWL Component

In the old JavaScript MVC pattern, our main View class was used to wire together with the main 3 blocks that are the Model, Controller, and Renderer.

In Odoo 15, thanks to some helper and re-write of the WebClient, the main View class can be transformed into an OWL Component. This Component will still orchestrate the instantiation of the Model and the Renderer, but it will also take some of the old responsibility of the Controller in our case.

Showing the "diff view" between the old and new version of the View would be too confusing here because the two are very different, so we will rewrite the "static/src/owl_tree_view/owl_tree_view.js" directly.

Create the basic View structure

The main View is actually quite a simple OWL Component, but with some specificities.

/** @odoo-module **/
import { registry } from "@web/core/registry";
import { XMLParser } from "@web/core/utils/xml";
import { useModel } from "@web/views/helpers/model";
import { standardViewProps } from "@web/views/helpers/standard_view_props";
import { Layout } from "@web/views/layout";
import { OWLTreeModel } from "@owl_tutorial_views/owl_tree_view/owl_tree_model";
import { OWLTreeRenderer } from "owl_tutorial_views.OWLTreeRenderer";

class OWLTreeView extends owl.Component {
  _parseArchOptions() {
    const parser = new XMLParser();
    const arch = parser.parseXML(this.props.arch);
    const options = {};
    for (const attr of arch.getAttributeNames()) {
      options[attr] = arch.getAttribute(attr);
    }
    return options;
  }

  setup() {
    this.archOptions = this._parseArchOptions();
    this.model = useModel(OWLTreeModel, {
      resModel: this.props.resModel,
      domain: this.props.domain,
    });
  }
}
OWLTreeView.type = "owl_tree";
OWLTreeView.display_name = "OWLTreeView";
OWLTreeView.icon = "fa-indent";
OWLTreeView.multiRecord = true;
OWLTreeView.searchMenuTypes = ["filter", "favorite"];
OWLTreeView.components = { Layout, OWLTreeRenderer };
OWLTreeView.props = {
  ...standardViewProps,
};
OWLTreeView.template = owl.tags.xml/* xml */ `
<Layout viewType="'owl_tree'">
    <OWLTreeRenderer archOptions="archOptions" model="model" t-on-tree_item_clicked="_onTreeItemClicked" t-on-change_item_tree="_onChangeItemTree"/>
</Layout>`;

registry.category("views").add("owl_tree", OWLTreeView);

Let's detail some of the things here:

The main View component has some familiar properties

When you create an OWL View, you will see some old properties come back like the icon, the mode (multi/mono record), etc...

-  const OWLTreeView = AbstractView.extend({
-    accesskey: "m",
-    display_name: _lt("OWLTreeView"),
-    multiRecord: true,
-    icon: "fa-indent",
-    viewType: "owl_tree",
-    searchMenuTypes: ["filter", "favorite"],
+OWLTreeView.type = "owl_tree";
+OWLTreeView.display_name = "OWLTreeView";
+OWLTreeView.icon = "fa-indent";
+OWLTreeView.multiRecord = true;
+OWLTreeView.searchMenuTypes = ["filter", "favorite"];

Be careful that viewType became just type.

The new OWL Views have some standard props.

OWL Views get props passed to them from the rest of the UI. To have your own created view receive these props, Odoo gives us an interface of standard props that we de-structure into our props:

import { standardViewProps } from "@web/views/helpers/standard_view_props";
//...
OWLTreeView.props = {
  ...standardViewProps,
};
//...

Using the Layout and calling the renderer

The template of our main view is very basic, we need to call the Layout helper Component and inject our Renderer that will contain the actual UI of our View.

import { Layout } from "@web/views/layout";
import { OWLTreeRenderer } from "owl_tutorial_views.OWLTreeRenderer";

// ...

OWLTreeView.components = { Layout, OWLTreeRenderer };

And this is ou XML Template

<Layout viewType="'owl_tree'">
    <OWLTreeRenderer archOptions="archOptions" model="model"/>
</Layout>

You may notice that we pass the model object to our Renderer, let's explain that/

Use the "setup" function to instantiate the Model

// ... top of the file
import { useModel } from "@web/views/helpers/model";
import { OWLTreeModel } from "@owl_tutorial_views/owl_tree_view/owl_tree_model";
// ... top of the file

/**
 * Parse the props.arch Root node and transform the attributes into a JS object
 * the will correspond to the options
 *
 * @private
 * @returns {Object} Options
 **/
_parseArchOptions() {
  const parser = new XMLParser();
  const arch = parser.parseXML(this.props.arch);
  const options = {};
  for (const attr of arch.getAttributeNames()) {
    options[attr] = arch.getAttribute(attr);
  }
  return options;
}

/**
 * Standard setup function of OWL Component, here we parse the XML
 * template to get the options and instantiate the Model.
 **/
setup() {
  this.archOptions = this._parseArchOptions();
  this.model = useModel(OWLTreeModel, {
    resModel: this.props.resModel,
    domain: this.props.domain,
  });
}

The parseArchOptions helps us get the count_field attribute options that will be used to show a "count" badge on the Tree elements. The XML parsing has to be done by us, as the props.arch is a pure string when it arrives in our Component.

Next in the setup, we declare our Model via the useModel hook to ensure reactivity. Every time the associated Model will call the notifyfunction, our View will update. And since this.model is then passed as props to the Renderer, it will trigger its update (via the integrated willUpdateProps hook, that you don't have to add).

Finally, register the view

Use the new registry to add your View:

import { registry } from "@web/core/registry";
// ...
registry.category("views").add("owl_tree", OWLTreeView);

In place of the Controller, the View will listen to events and call the Model functions

In the Odoo 14 version of our View, the Controller was listening to 2 events:

  • The click on an item to expand/collapse it and fetch children.
  • The drop (after drag) of an item inside its new parent.

These two events will now be directly coded into our main OWLTreeView Component since it holds the Model class.

The content of the functions is the same, except for the ev object. The data is now inside the detail property. So you have to write ev.detail.XXX

/**
 * @param {Object} ev
 * @param {Object} ev.data contains the payload
 */
async _onTreeItemClicked(ev) {
  ev.stopPropagation();
  if (ev.detail.data.children === undefined) {
    await this.model.expandChildrenOf(
      ev.detail.data.id,
      ev.detail.data.parent_path
    );
  } else {
    this.model.toggleChildrenVisibleForItem(ev.detail.data);
  }
}

/**
 * @param {Object} ev
 * @param {Object} ev.data.itemMoved is the item that has been dragged
 * @param {Object} ev.data.newParent is the new target parent of the dropped item
 */
async _onChangeItemTree(ev) {
  ev.stopPropagation();
  let itemMoved = ev.detail.itemMoved;
  let newParent = ev.detail.newParent;
  await this.model.changeParent(itemMoved.id, newParent.id);

  // Refresh old parent
  let oldParent = await this.model.refreshNode(itemMoved.parent_id[0]);
  await this.model.expandChildrenOf(oldParent.id, oldParent.parent_path);

  // Refresh new parent
  await this.model.expandChildrenOf(
    ev.detail.newParent.id,
    ev.detail.newParent.parent_path
  );
}

Then add the listening of events inside the View template:

<Layout viewType="'owl_tree'">
    <OWLTreeRenderer 
    	archOptions="archOptions" 
		model="model"
		t-on-tree_item_clicked="_onTreeItemClicked"
		t-on-change_item_tree="_onChangeItemTree"
	/>
</Layout>

That's it for the View, the full commit for that file is visible here.

Update the Renderer to reflect the change of the View

Since we upgrade our main View into an OWL Component, we have some tweaks to do inside our Renderer so it accepts the 2 new props model and archOptions that we created earlier.

--- a/static/src/owl_tree_view/owl_tree_renderer.js
+++ b/static/src/owl_tree_view/owl_tree_renderer.js
@@ -1,21 +1,17 @@
 /** @odoo-module alias=owl_tutorial_views.OWLTreeRenderer default=0 **/
 const { useState } = owl.hooks;
 import AbstractRendererOwl from "web.AbstractRendererOwl";
-import QWeb from "web.QWeb";
-import session from "web.session";
 import { TreeItem } from "@owl_tutorial_views/components/tree_item/TreeItem";

 export class OWLTreeRenderer extends AbstractRendererOwl {
   constructor(parent, props) {
     super(...arguments);
-    this.qweb = new QWeb(this.env.isDebug(), { _s: session.origin });
     this.state = useState({
-      localItems: props.items || [],
       countField: "",
     });
-    if (this.props.arch.attrs.count_field) {
+    if (this.props.archOptions.count_field) {
       Object.assign(this.state, {
-        countField: this.props.arch.attrs.count_field,
+        countField: this.props.archOptions.count_field,
       });
     }
   }
@@ -25,23 +21,15 @@ const components = { TreeItem };
 Object.assign(OWLTreeRenderer, {
   components,
   defaultProps: {
-    items: [],
+    archOptions: {},
   },
   props: {
-    arch: {
+    archOptions: {
       type: Object,
       optional: true,
     },
-    items: {
-      type: Array,
-    },
-    isEmbedded: {
-      type: Boolean,
-      optional: true,
-    },
-    noContentHelp: {
-      type: String,
-      optional: true,
+    model: {
+      type: Object,
     },
   },
   template: "owl_tutorial_views.OWLTreeRenderer",

Nothing special here, we cleaned up the old props and unused imports.

And for the template, since the Model directly holds data and no longer have the items property, we update accordingly:

--- a/static/src/xml/owl_tree_view.xml
+++ b/static/src/xml/owl_tree_view.xml
@@ -4,7 +4,7 @@
     <div t-name="owl_tutorial_views.OWLTreeRenderer" class="o_owl_tree_view" owl="1">
         <div class="d-flex p-2 flex-row owl-tree-root">
             <div class="list-group">
-                <t t-foreach="props.items" t-as="item" t-key="item.id">
+                <t t-foreach="props.model.data" t-as="item" t-key="item.id">
                     <TreeItem item="item" countField="state.countField"/>
                 </t>
             </div>

Conclusion

And that's it, our module is fully migrated to the new OWL View system in Odoo 15. If you are interested in seeing the whole process as a succession of GitHub commits this is the direct link to the migration branch:

Commits · Coding-Dodo/owl_tutorial_views
Odoo JavaScript Tutorial to create a new OWL View. Contribute to Coding-Dodo/owl_tutorial_views development by creating an account on GitHub.

Thank you for reading, and supporting Coding Dodo! See you soon for other tutorials about Odoo 15.