La couche présentation d'Odoo : déclarer des formulaires, listes, kanbans en XML, et créer des composants OWL custom.
Pour qu'un modèle soit utilisable dans l'interface, il faut 3 briques XML :
ir.actions.act_window) — déclare quoi ouvrir (modèle + vues + filtres)ir.ui.menu) — déclare où dans la barre de navigationChaque record XML a un id appelé XML ID. Format : module_name.record_id. Permet de référencer un record depuis un autre fichier sans connaître son ID numérique. Exemple : base.user_admin.
La vue formulaire — celle qu'on voit en édition. Structure type :
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_library_book_form" model="ir.ui.view">
<field name="name">library.book.form</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<form string="Livre">
<header>
<button name="action_borrow" string="Emprunter"
type="object" class="btn-primary"
invisible="state != 'available'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,available,borrowed"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="title" placeholder="Titre du livre"/></h1>
</div>
<group>
<group string="Identification">
<field name="isbn"/>
<field name="author_id"/>
<field name="publish_date"/>
</group>
<group string="Classement">
<field name="tag_ids" widget="many2many_tags"/>
<field name="category_id"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
<page string="Réservations">
<field name="reservation_ids">
<list>
<field name="member_id"/>
<field name="date_start"/>
<field name="date_end"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/> <!-- Odoo 19 : remplace les anciens div oe_chatter -->
</form>
</field>
</record>
</odoo>
1) Le tag <chatter/> remplace la div oe_chatter de la v17.
2) Les attributs attrs="..." dépréciés depuis la v17 sont totalement supprimés en v19. Utiliser invisible="state != 'draft'" directement.
3) La balise <tree> est officiellement renommée <list> (les deux fonctionnent encore mais list est recommandé).
<record id="view_library_book_list" model="ir.ui.view">
<field name="name">library.book.list</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<list string="Livres" multi_edit="1" sample="1">
<field name="title"/>
<field name="author_id"/>
<field name="publish_date" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'available'"
decoration-warning="state == 'borrowed'"
decoration-danger="state == 'lost'"/>
</list>
</field>
</record>
Attributs utiles :
multi_edit="1" — édition multi-sélection en listesample="1" — affiche des données d'exemple si la liste est vide (UX)optional="show|hide" — l'utilisateur peut masquer la colonnedecoration-success / warning / danger / info / muted — coloration conditionnelleeditable="bottom" — édition inline en liste<record id="view_library_book_kanban" model="ir.ui.view">
<field name="name">library.book.kanban</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<kanban default_group_by="state" sample="1">
<field name="author_id"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card">
<strong><field name="title"/></strong>
<div>par <field name="author_id"/></div>
<div class="text-muted">
ISBN: <field name="isbn"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_library_book_search" model="ir.ui.view">
<field name="name">library.book.search</field>
<field name="model">library.book</field>
<field name="arch" type="xml">
<search>
<field name="title"/>
<field name="author_id"/>
<field name="isbn"/>
<filter name="available" string="Disponibles"
domain="[('state', '=', 'available')]"/>
<filter name="borrowed" string="Empruntés"
domain="[('state', '=', 'borrowed')]"/>
<separator/>
<filter name="recent" string="Récents (30j)"
domain="[('publish_date', '>=', (context_today() - relativedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Grouper par">
<filter name="group_state" string="État"
context="{'group_by': 'state'}"/>
<filter name="group_author" string="Auteur"
context="{'group_by': 'author_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_library_book" model="ir.actions.act_window">
<field name="name">Livres</field>
<field name="res_model">library.book</field>
<field name="view_mode">list,kanban,form</field>
<field name="context">{'search_default_available': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Ajoutez votre premier livre
</p>
</field>
</record>
<menuitem id="menu_library_root" name="Bibliothèque" sequence="10"/>
<menuitem id="menu_library_book" name="Livres"
parent="menu_library_root"
action="action_library_book"
sequence="10"/>
Pour modifier une vue existante (la sienne ou celle d'un module standard), on utilise inherit_id + des opérations xpath.
<record id="view_partner_form_inherit_library" model="ir.ui.view">
<field name="name">res.partner.form.library</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<!-- Ajouter un champ APRÈS un autre -->
<xpath expr="//field[@name='vat']" position="after">
<field name="library_card_id"/>
</xpath>
<!-- Ajouter une page dans le notebook -->
<xpath expr="//notebook" position="inside">
<page string="Bibliothèque">
<field name="book_ids"/>
</page>
</xpath>
<!-- Masquer un champ -->
<xpath expr="//field[@name='website']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- Remplacer complètement un noeud -->
<xpath expr="//field[@name='comment']" position="replace">
<field name="library_notes"/>
</xpath>
</field>
</record>
Positions xpath disponibles : before, after, inside (à l'intérieur), replace, attributes.
En mode dev, ouvrez la vue à modifier dans Settings → Technical → Views → cliquez sur "Edit". Vous voyez l'XML complet et pouvez expérimenter le xpath en direct.
Depuis Odoo 17, le frontend est 100% OWL : un framework type Vue.js/React fait par Odoo. En Odoo 19, c'est OWL 2.
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
class BookCounter extends Component {
static template = "library.BookCounter";
static props = {};
setup() {
this.orm = useService("orm");
this.state = useState({ count: 0 });
onWillStart(async () => {
this.state.count = await this.orm.searchCount("library.book", []);
});
}
}
registry.category("actions").add("library_book_counter", BookCounter);
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="library.BookCounter">
<div class="o_book_counter">
<h2>Total livres : <t t-esc="state.count"/></t>
</div>
</t>
</templates>
Pour qu'OWL trouve les fichiers, déclarer dans le __manifest__.py :
'assets': {
'web.assets_backend': [
'library/static/src/components/book_counter.js',
'library/static/src/components/book_counter.xml',
],
},
| Hook | Usage |
|---|---|
useState({...}) | État réactif (re-render auto) |
useService("orm") | Accès au service ORM (API JSON-RPC) |
useService("notification") | Toasts/notifications |
useService("action") | Lancer une action (ouvrir vue, wizard) |
onWillStart(fn) | Avant le 1er render (async OK) |
onMounted(fn) | Après l'insertion dans le DOM |
useRef("name") | Réf vers un élément DOM |
<t t-esc="variable"/> <!-- texte échappé -->
<t t-out="htmlVariable"/> <!-- HTML brut -->
<div t-if="state.count > 0">...</div>
<div t-elif="state.count === 0">...</div>
<div t-else="">...</div>
<t t-foreach="books" t-as="book" t-key="book.id">
<li><t t-esc="book.title"/></li>
</t>
<button t-on-click="onBorrow">Emprunter</button>
<input t-model="state.searchTerm"/> <!-- 2-way binding -->
<div t-att-class="{ active: state.isActive }">...</div>
| widget="..." | Champ idéal | Effet |
|---|---|---|
statusbar | Selection | Barre d'étapes en haut du formulaire |
many2many_tags | Many2many | Tags colorés en pillules |
radio | Selection | Boutons radio |
handle | Integer (sequence) | Drag & drop pour réordonner |
badge | Char / Selection | Badge coloré (en list) |
monetary | Monetary | Formatage devise |
email | Char | Lien mailto, validation visuelle |
phone | Char | Lien tel: |
url | Char | Lien cliquable |
image | Binary | Affiche en image |
signature | Binary | Champ signature dessinable |
progressbar | Float / Integer | Barre de progression |
percentage | Float | Affichage % |
html | Html | Éditeur riche |
🏋️ Exercice de validation
library.book du module 1, créer 4 vues : form, list, kanban, search.act_window + un menu racine "Bibliothèque" + un sous-menu "Livres".statusbar, un bouton "Emprunter" visible uniquement si état = available, des groupes de champs, un notebook avec onglet "Description" et onglet "Tags".res.partner pour ajouter un onglet "Mes livres" affichant tous les livres dont la personne est auteur.