Creating JavaScript classes and widgets in Odoo
Skilled
This tutorial will teach you the fundamentals of the two basic building blocks provided by the Odoo framework: classes and widgets.
This is a large topic, and a tutorial such as this one is not enough to cover it properly. To make the most of these topics we'll do a few tasks that explain the most important parts. We will complete the following task: make a quiz application, in which the user will be able to select a topic, answer a few multiple choices questions and then get a score.
In this tutorial you will learn how to create your own JavaScript classes, your own widgets, how to use them and add the end a detailed explanation about how communication between widgets works.
Tip: this tutorial is rather long and covers multiple subjects. Take your time to finish it or even plan a break in between. If the tutorial gets confusing - as there is a lot of code - you can download the example app for easier understanding of the structure.
Before working on our Quiz, let us talk a little bit about Odoo classes. Odoo was made before the javascript ecosystem introduced the class system (sometimes known as ES6 classes), so Odoo had to make its own custom class system. Odoo will eventually use ES6 classes (starting in version 14), but Odoo classes will clearly be around for a while. The odoo framework provide the Class abstraction under the web.Class javascript module (see previous tutorial for a discussion on odoo modules). Like most JS abstractions, they can be tested easily in the browser: in a Odoo web client tab in your favorite browser, open the developer tools (usually by pressing F12). In the console, you can type the following lines:
const Class = odoo.__DEBUG__.services["web.Class"]
This line simply gets a reference to the base class object exported by web.Class. Most things in Odoo are just subclasses from this one (and in particular, all widgets are!). We can now create subclasses with the .extend method. Paste the following in the console:
const Car = Class.extend({ speed: 0, accelerate() { this.speed += 5; console.log('accelerating... Current speed: ' + this.speed); }, stop() { this.speed = 0; console.log('stopped'); },});const car = new Car();car.accelerate();car.stop();
As you can see our "car" class will print out that it is accelerating and that it stopped:
Note that this is a different syntax from standard javascript classes. In particular, this is a normal object, so each method need a comma at the end! We can now make a fancy car. Paste the following into the browser console:
const FancyCar = Car.extend({ honk() { console.log('honk honk') }, accelerate() { console.log('vroom'); this._super(); },});const myCar = new FancyCar();myCar.honk();
Notice that the call to "this._super()" is why "FancyCar" can call the method with the same name in the "Car" class. Finally, it is certainly important to mention that we can also define a constructor with the "init" function:
const ColoredCar = FancyCar.extend({ init(color) { this.color = color; console.log('creating a ' + color + ' car'); }});const redCar = new ColoredCar('red');
Odoo classes also have a mixins system (see the official documentation if you are interested), but we won't need more than simple inheritance for this tutorial. Odoo classes have a very important functionality though: widgets! One of the classes is 'Widget' and it is often used in Odoo.
Widgets are the basic UI building blocks that are used everywhere in the Odoo web client to display something to the screen. They are similar to React or Vue components, but also different. Most importantly, they are a lower level abstraction, which means that they are quite powerful, but requires more work from the developer to properly use and coordinate them.
Let's create a client action in JS - which is a widget - with a simple template key:
odoo.define('tutorial.ClientAction', function (require) { const AbstractAction = require('web.AbstractAction'); const core = require('web.core'); const QuizAction = AbstractAction.extend({ template: "tutorial.ClientAction", }); // Adds it to the registry so that the action is loaded by Odoo itself core.action_registry.add('tutorial.quiz', QuizAction);});
Now let's also add an XML template for it:
A widget with a template is not much more than a simple template. But obviously, we can define additional properties or methods to this template.
As a learning exercise, let us define and use a simple "Counter" widget that displays an initial value, and allows the user to increment it. First, let us add a QWeb template (which should be located in a static xml file, in our case, tutorial_quiz.xml under the 'views' folder):
We can now define a "Counter" widget and add it to our client action:
odoo.define('tutorial.ClientAction', function (require) { // We use the require() to import the widgets we need for our own widget. const AbstractAction = require('web.AbstractAction'); const Widget = require('web.Widget'); const core = require('web.core'); // Extend the AbstractAction (base class) to define our own template const QuizAction = AbstractAction.extend({ template: "tutorial.ClientAction", start() { const counter = new Counter(this, 47); counter.appendTo(this.$el); } }); // Create the Counter widget by extending the default web widget. const Counter = Widget.extend({ template: "tutorial.Counter", init(parent, initialValue) { this._super(parent); this.value = initialValue; }, }); // Adds it to the registry so that the action is loaded by Odoo core.action_registry.add('tutorial.quiz', QuizAction);});
If you would now update your module and reload the client action you would see an already working action. So, a lot of code was added but let us take a minute to understand it. We've defined two widgets: our "QuizAction" widget, which is a client action (as it inherits from "AbstractAction") and a "Counter" widget. The "Counter" widget defines a init method: this is its constructor. Since it is a widget it ALWAYS needs to have a parent defined in its first argument (with one exception: the root widget itself). This parent argument has to be given to the _super method, to let the framework properly maintain the tree structure defined by widgets. After the first argument a constructor can have any amount of arguments though. We just set an initial value in the value property. Note that this value property is used by the template.
Another very important point is the way we added our "Counter" widget to the client action: this is done in the start method, where we instantiate a new counter widget (lowercase, since it is an instance) and then we add it to the client action DOM with the following line:
counter.appendTo(this.$el);
Once a widget is started (so, in the "start" method or after), we have a reference to the root element of the widget: "this.$el" (this is a jQuery element!) or this.el (as an HTMLElement). So, the above line tells the counter to append itself inside the $el element of the client action. Notice that there are some widget lifecycle methods but they're out of the scope of this tutorial. If you want to you can read more about them here.
If you would now click on our "Counter" widget, you will probably notice that the button does not do anything. This makes sense, since we did not write the code for that yet! Let us do that right now by adding this logic inside our "Counter" widget:
const Counter = Widget.extend({ template: "tutorial.Counter", events: { 'click .increment': 'increment' }, init(parent, value) { this._super(parent); this.value = value; }, increment() { this.value++; this.$el.find('span.o-value').text(this.value); }, });
In this code, we now have a new "events" property and an "increment" method. The events property is used to describe event handlers that we want set up by the Odoo framework. It is a simple object that maps an event type and selector to the name of a method that will be called. Here, we just express the fact that we want to listen on all click events on elements with the increment CSS class, and when it happens, the increment method should be called. This simply means that if somebody clicks on our button "Increment" that Odoo will detect the click because of the "events" definition. It will then trigger the function "increment" to execute code.
Because of the way events are handled in Odoo, it is common to add CSS classes in a widget template to make it easy to define an handler on the desired HTML element.
The increment method need to do two things: update the widget it's internal state and then update the DOM (its representation on the screen). This is necessary because our Odoo widgets are not reactive. In our case, updating the DOM is done by first getting a reference to the span element which contains the value (with the jquery find method defined on this.$el), and then, setting its content. That's it! Our widget is now completely interactive. If we now click on the button we can see that the value is being updated!
Now let us first create a menuitem so that we can actually access our content from the main Odoo screen. This can be easily done by creating an action and a menuitem. The menuitem should trigger the action, which is then being handled by the JavaScript code (that we wrote in the Widgets chapter). Create a new XML file under '/views' in your custom app and add the following code to it:
Even before writing the first line of code, it is very useful to take a step back and to think about what we want, and how we will organize the code. This is by the way not only the case for JavaScript code but for any code! Our OdooQuiz application will have the following screens:
There are many ways to organize the code in various widgets, each with some pros and cons. Usually, more widgets means that each widgets is simpler, individually, but then we have more complexity in the way they need to communicate. On the opposite, less widgets means that communication is simpler, but they need to be more complex, since they have to do more work.
For our task, we will choose a structure with more widgets. This is probably a little bit overkill for this tutorial, but this will be a good opportunity to explain how we can handle communication.
The parent widget will be our client action. Inside, we will have the following sub widgets:
Before starting working on these widgets, let us define some demo data, to make it easier to test our code. This is very useful when working on a project to have some data. It allows us to focus on the code without having to solve the issue of fetching/storing the data. Add the following demo data at the top of your JavaScript file (right after your 'require' imports and within your odoo.define function):
const DINOSAUR_QUIZ = [ { question: "Which is the best dinosaur?", choices: { a: "tyrannosaurus", b: "diplodocus", c: "ankylosaurus", d: "iguanodon" }, answer: "d" }, { question: "Which of these animals is not a dinosaur?", choices: { a: "a triceratops", b: "a cow" }, answer: "b" },];const COLOR_QUIZ = [ { question: "What is your favorite color?", choices: { a: "red", b: "blue", c: "green", }, answer: "c" }, { question: "How many colors are there?", choices: { a: "a lot", b: "100", c: "over 9000", }, answer: "c" }];const QUIZZES = { dinosaurs: DINOSAUR_QUIZ, colors: COLOR_QUIZ,};
Now that you know the basics about classes and widgets it is time to create our very first real widget. This widget has one job: presenting the user a list of possible quizzes and sending the choice from the user to the parent it's widget. Let's first change our "Counter" widget into a "QuizSelection" widget:
const QuizSelection = Widget.extend({ template: "tutorial.QuizSelection", events: { "click button": "selectQuiz", }, quizzes: Object.keys(QUIZZES), selectQuiz(ev) { const quiz = ev.target.dataset.quiz; console.log(quiz); // todo: communicate here to our parent the user selection },});
Now let us also create a new XML template in our XML file:
Let's also create a CSS file to add some basic styling for our quiz. Create a new CSS file in your custom module under static/src/css named "tutorial_quiz.css" and add this CSS:
.tutorial-quiz { margin: auto; margin-top: 50px; padding: 30px; width: 450px; min-height: 300px; border: 1px solid gray; background-color: #ddd;}
We obviously also need to update our ClientAction widget to instantiate our "QuizSelection" widget instead of a "Counter":
const QuizAction = AbstractAction.extend({ template: "tutorial.ClientAction", start() { const quizSelection = new QuizSelection(this); quizSelection.appendTo(this.$el); },});
Notice that we bound an event handler to the click event. In that handler, we read the data attribute on the html button element. This is a common pattern, sometimes named event delegation: there is one handler for a list of elements, and we simply get the required information from the event target. QWeb makes it easy to add a dynamic data-quiz attribute.
Now, this leads to a big question: how will this sub widget communicate with its parent? There are many possible answers to that question, including:
For this tutorial, we will trigger a special Odoo event on a widget (solution 6). This is done by using the method trigger_up, then listening to the event with the special key custom_event. More information can be found in the official documentation. Let us use this way. Look at this code first and I'll explain it afterwards:
const QuizAction = AbstractAction.extend({ template: "tutorial.ClientAction", custom_events: { quiz_selected: "selectQuiz", }, start() { const quizSelection = new QuizSelection(this, 47); quizSelection.appendTo(this.$el); }, selectQuiz(ev) { console.log(ev.data.quiz) }});const QuizSelection = Widget.extend({ template: "tutorial.QuizSelection", events: { "click button": "selectQuiz", }, quizzes: Object.keys(QUIZZES), selectQuiz(ev) { const quiz = ev.target.dataset.quiz; this.trigger_up("quiz_selected", {quiz}) }});
The "selectQuiz" method in the "QuizSelection" widget simply triggers an event with the trigger_up method. It takes an event name and an optional object argument. The event name can be anything, but we usually choose a short description of what happened.
Now, a parent can listen to an event by defining a custom_events object, which maps event names to handler names. The "selectQuiz" method of "QuizAction" then gets the event as his unique argument, which holds the desired information in its data property. Clicking on the quiz button should now log its name in the console.
Events dispatched by the trigger_up method bubble up the widget tree, just like DOM events bubble up the DOM tree. This means that they are first dispatched on the target component, then its parent, then its parent's parent, ..., until they are stopped or they reach the root widget.
Now that the parent widget is aware of the desired user action, we need to perform a transition: remove the QuizSelection widget and display the Quiz widget in its place. A good first step is to create our Quiz widget:
// In your JavaScript file const Quiz = Widget.extend({ template: "tutorial.Quiz", init(parent, quizName) { this._super(parent); // here we have the widget "static" state: this.quizName = quizName; this.questions = QUIZZES[quizName]; // and here the widget "running" state: this.current = 0; // index of current question this.answers = []; // list of user answers }, });
Currently, this Quiz widget has only a constructor. We will add more methods later on. For now, notice that I separated the initialization in two parts: a static part (the information received by the parent that will not change), and a dynamic part: the actual widget state. If you are familiar with a component system such as React, you will probably recognize the concept of props: state owned by a parent. Now, let's display that widget in the place of QuizSelection:
selectQuiz(ev) { const quizWidget = new Quiz(this, ev.data.quiz); // add Quiz widget to DOM quizWidget.appendTo(this.$el); // remove previous QuizSelection widget ev.target.destroy(); },
This is done in three steps:
The next step - and almost the final one - is to display the current question, give a way to the user to select an answer, and display the next questions. All of this should be done in our "Quiz" widget. Have a look at this code and I'll explain it a bit after:
This code should now be mostly clear. Let us however highlight the way we handle a question transition in the "selectChoice" method: the "this.current" counter is incremented, and then the widget "renderElement" method is called. This is a Widget method that will rerender the template and replace the content of the widget with the result of that rendering.
Replacing the content of the widget is sometimes not a good idea, since it may lose some user state. For example, if a widget has an input, replacing its content with a new content will remove the content of the input. Modern frameworks solve this issue with a virtual dom algorithm. In our case this works perfect though. We just need to replace the content and the renderElement does exactly that.
Another noteworthy thing is that we communicate the result of the quiz by triggering an event. This event "quiz_completed" will be useful for the parent widget.
Phew, we already did quite a lot now! We're almost done, this is the final step. Let us finally display a summary of the quiz taken by the user. We already covered all the necessary material to perform this task: creating a new widget with a constructor, rendering a template, binding events and triggering custom events. If you feel confident you've got this then give it a try first and otherwise you can look at the code. We all need to learn ;-)
The JavaScript:
// add this in your JS file const QuizSummary = Widget.extend({ template: "tutorial.QuizSummary", events: { "click button": "backToSelection", }, init(parent, result) { this._super(parent); this.result = result; this.questions = QUIZZES[result.name]; }, backToSelection() { this.trigger_up("back_to_selection"); }, });
The XML template:
Let us also add a tiny bit more CSS for styling purpose:
.quiz-correction { margin: 15px;}.quiz-correction div:first-child { font-weight: bold;}
And finally, we need to modify "QuizAction" to properly instantiate "QuizSummary" and react to events:
const QuizAction = AbstractAction.extend({ template: "tutorial.ClientAction", custom_events: { quiz_selected: "selectQuiz", quiz_completed: "quizCompleted", back_to_selection: "backToSelection", }, ... backToSelection(ev) { const quizSelection = new QuizSelection(this); quizSelection.appendTo(this.$el); ev.target.destroy(); }, });
That's it, we are done! If you now install or update your module you will see a fully working Quiz app:
In this tutorial, we covered a LOT of material. How Odoo classes and widgets work, how they interact with QWeb templates, how to bind event handlers, how to manage states, to transition from one widget to another, how to communicate between widgets and more! We built a fully functional Quiz application along the way. It displays a quiz selection interface, then transitions to a quiz, and then to a summary. All of it in about 100 lines of code!
It is not perfect however: to make it simple, we added all the code in a single file. In a real application, we will probably split our code in a few files. There is also a little bit of code duplication in the way we managed the screen transitions. Another shortcoming is that our application only use a static definition for our quiz, and does not interact with the server in any way. As this tutorial was already big and pretty long we decided to keep this part sample. We will explain server interactions, also known as RPC widgets, in another tutorial.
This tutorial should show you a lot about the JavaScript framework in Odoo and it also shows how flexible the framework is. The things you can do are incredible but with great power comes a great responsibility. Take your time to master the JavaScript framework as it can be a very valuable asset.