widget="badge" natif par un
field widget sla_badge custom qui affiche une pastille colorée avec
icône, trois variantes d'état (dans les temps / proche échéance / dépassé) et
animation pulse sur l'état critique. 100% OWL, 100% v19, zéro jQuery.Ce que tu vas apprendre
Le registry v19
registry.category("fields").add(name, descriptor) — toutes les clés du descriptor.
La classe Component
static template, static props, setup() — l'anatomie d'un composant OWL v19.
extractProps
Transformer les options="{…}" XML en props typés — la passerelle vue ⇄ composant.
XPath position="attributes"
Remplacer un widget natif proprement, y compris vider les decoration-* hérités.
Prérequis
- Odoo 19 Community installé en local (Ubuntu/Docker — voir T01).
- Un module custom opérationnel avec au minimum un modèle — voir T08 — Modèles de base.
- Vues form déclarées — voir T06 — Vues form, list, search.
- Un peu de JavaScript ES modules (
import/export) — pas besoin d'être expert.
Pourquoi un field widget custom ?
Odoo 19 embarque une trentaine de field widgets natifs — char,
badge, progressbar, many2many_tags,
priority, state_selection… La plupart du temps, ils suffisent.
Mais dès qu'un client demande une présentation spécifique — un badge SLA
avec animation, un sélecteur couleur custom, un sparkline inline — tu rentres dans le
territoire des widgets personnalisés.
En v19, un field widget est un composant OWL standard. Pas de classe
abstraite à étendre, pas de legacy layer à contourner — juste un
Component issu de @odoo/owl, enregistré dans la
registry sous la catégorie "fields". Le form renderer
l'instancie, lui passe record et name via ses props, et c'est
au composant d'afficher ce qu'il veut.
On va construire sla_badge — un widget qui affiche le champ Selection
sla_status du mixin SLA (valeurs ok / warning /
breach) sous forme de pastille colorée avec icône FontAwesome. Il
remplacera le widget="badge" natif déjà en place sur le form du ticket.
1. L'arborescence du widget
Un field widget custom en v19 tient en trois fichiers statiques (JS, XML,
SCSS) plus un fragment XML pour la vue inherit. On les range sous
static/src/views/fields/, en miroir du cœur Odoo (voir
odoo/addons/web/static/src/views/fields/ pour la référence).
addons/odooskills-blog/modules/odooskills_helpdesk/
├── __manifest__.py # bump 19.0.1.17.0 + assets
├── models/helpdesk_ticket.py # (inchangé — on utilise sla_status existant)
├── static/src/views/fields/sla_badge/
│ ├── sla_badge.js # Composant OWL + registry
│ ├── sla_badge.xml # Template QWeb/OWL
│ └── sla_badge.scss # Styles pastille
└── views/helpdesk_ticket_views_t26.xml # Inherit form view (xpath)
Aucune modification du modèle Python. Le widget lit uniquement le champ Selection
sla_status déjà calculé par le mixin — voir
T12 — Computes & contraintes
pour le pattern @api.depends du compute.
2. Le composant OWL
Tout commence par un import depuis @odoo/owl. La classe étend
Component, déclare son template en attribut static,
ses props (typés avec la syntaxe OWL), et quelques getters qui
calculent la classe CSS, l'icône et le label selon la valeur du champ.
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { _t } from "@web/core/l10n/translation";
export class SlaBadgeField extends Component {
static template = "odooskills_helpdesk.SlaBadge";
static props = {
...standardFieldProps,
icon: { type: Boolean, optional: true },
compact: { type: Boolean, optional: true },
};
static defaultProps = {
icon: true,
compact: false,
};
get value() {
return this.props.record.data[this.props.name] || "ok";
}
get label() {
const labels = {
ok: _t("Dans les temps"),
warning: _t("Proche échéance"),
breach: _t("SLA dépassé"),
};
return labels[this.value] || this.value;
}
get iconClass() {
const icons = {
ok: "fa-check-circle",
warning: "fa-clock-o",
breach: "fa-exclamation-triangle",
};
return icons[this.value] || "fa-circle-o";
}
get pillClass() {
const base = this.props.compact ? "o_sla_pill o_sla_pill--compact" : "o_sla_pill";
return `${base} o_sla_pill--${this.value}`;
}
}
Trois choses à noter :
standardFieldPropsest spreadé pour hériter des{id, name, readonly, record}que tout field widget reçoit du renderer — définis en dur dansodoo/addons/web/static/src/views/fields/standard_field_props.js.- Les props OWL sont typées en objets,
pas en chaînes :
{ type: Boolean, optional: true }, pas{ type: "boolean" }. Erreur typique pour qui vient de Vue ou React. - Les getters sont réactifs — à chaque changement de
props.record.data[name], le template se re-rend automatiquement sans qu'on ait à appelerrender()ou à trigger quoi que ce soit.
3. Le descriptor & le registry
Le composant seul ne suffit pas — il faut déclarer un descriptor qui explique à Odoo comment instancier le widget et à quels types de champs il s'applique. On le pose à la suite du composant, dans le même fichier :
export const slaBadgeField = {
component: SlaBadgeField,
displayName: _t("SLA Badge"),
supportedTypes: ["selection"],
supportedOptions: [
{
label: _t("Show icon"),
name: "icon",
type: "boolean",
default: true,
},
{
label: _t("Compact display"),
name: "compact",
type: "boolean",
default: false,
},
],
extractProps: ({ options }) => ({
icon: options.icon !== false,
compact: options.compact === true,
}),
};
registry.category("fields").add("sla_badge", slaBadgeField);
Toutes les clés du descriptor sont validées par le cœur Odoo à l'enregistrement — voir
odoo/addons/web/static/src/views/fields/field.js:65-115. Les principales :
component— la classe OWL (obligatoire, doit hériter deComponent).displayName— nom affiché dans Studio et les sélecteurs de widget.supportedTypes— liste blanche des types de champs Odoo acceptés. Pour nous :["selection"]. Valeurs valides v19 :binary,boolean,char,date,datetime,float,html,integer,json,many2many,many2one,monetary,one2many,reference,selection,text.supportedOptions— catalogue des options reconnues dans le XMLoptions="{…}". Utilisé par Studio pour construire l'UI de config.extractProps— la fonction charnière. Elle reçoit les infos du champ (attrs,options,string…) et le contexte dynamique (readonly,required…), et retourne l'objet de props qui sera spreadé sur le composant au moment du render.
4. Le template OWL
OWL 2 utilise QWeb côté JS — même syntaxe que les templates serveur, exécuté côté
client. Le template vit dans un fichier XML qui doit être enveloppé d'un
<templates> (et pas <odoo>, piège
classique) :
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odooskills_helpdesk.SlaBadge">
<span t-att-class="pillClass" t-att-title="label">
<i t-if="props.icon" class="fa" t-att-class="iconClass"
role="img" aria-hidden="true"/>
<span class="o_sla_pill__text" t-out="label"/>
</span>
</t>
</templates>
Trois bonnes habitudes v19 à graver :
t-outet past-esc. En v19,t-escest déprécié — voir la liste des breaking changes dans T25 — Migration 18 → 19.t-att-classévalue l'expression côté JS et remplace la valeur declass. Utile pour composer des classes dynamiques commepillClassci-dessus.- Le nom du template (
odooskills_helpdesk.SlaBadge) doit matcher exactementstatic templatedans le JS, sinon OWL crashe avec Template not found.
5. Les styles SCSS
Trois variantes de pastille, une animation pulse pour l'état critique, un mode
compact qui cache le texte. Un classique des conventions BEM
(convention de nommage CSS avec block, element,
modifier) : o_sla_pill,
o_sla_pill--breach, o_sla_pill__text.
.o_sla_pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.7rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
transition: transform 120ms ease-out;
&:hover { transform: translateY(-1px); }
.fa { font-size: 0.9em; }
&--compact {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
.o_sla_pill__text { display: none; }
}
&--ok {
background-color: #e6f9ef;
color: #186a3b;
border: 1px solid #a3e4c5;
}
&--warning {
background-color: #fff7e6;
color: #8a5200;
border: 1px solid #ffd48a;
}
&--breach {
background-color: #fdeaea;
color: #a12a2a;
border: 1px solid #f5a3a3;
animation: slaBlink 1.6s ease-in-out infinite;
}
}
@keyframes slaBlink {
0%, 100% { box-shadow: 0 0 0 0 rgba(161, 42, 42, 0.35); }
50% { box-shadow: 0 0 0 6px rgba(161, 42, 42, 0); }
}
Résultat côté client — trois états identiques au rendu, le widget change juste sa classe CSS racine selon la valeur du champ :
sla_status == "ok" — pastille vertesla_status == "warning" — pastille orangesla_status == "breach" — pastille rouge pulsante6. Intégrer — manifest & vue inherit
Pour que les 3 fichiers statiques soient chargés côté client, on les déclare dans le
bundle web.assets_backend du manifest. Bump de version en prime — le
registry-loader d'Odoo détecte qu'on est passé de 19.0.1.16.0 à
19.0.1.17.0 et pousse un upgrade du module.
# addons/odooskills-blog/modules/odooskills_helpdesk/__manifest__.py
{
'name': 'OdooSkills Helpdesk',
'version': '19.0.1.17.0',
# ...
'data': [
# ... (les vues existantes)
'views/helpdesk_ticket_views_t26.xml', # ← nouvelle vue inherit
],
'assets': {
'web.assets_backend': [
'odooskills_helpdesk/static/src/views/fields/sla_badge/sla_badge.js',
'odooskills_helpdesk/static/src/views/fields/sla_badge/sla_badge.xml',
'odooskills_helpdesk/static/src/views/fields/sla_badge/sla_badge.scss',
],
},
'installable': True,
'application': True,
}
Ensuite la vue inherit. L'astuce v19 ici, c'est qu'on veut remplacer
le widget natif (widget="badge") par le nôtre, mais aussi vider
les attributs decoration-* hérités de la vue de base. Sans ça,
les deux systèmes de coloration se battent et tu obtiens une pastille hybride,
souvent incohérente. XPath position="attributes" avec un
<attribute> de contenu vide supprime l'attribut ciblé — c'est le
comportement documenté dans
odoo/tools/template_inheritance.py:303-309.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_helpdesk_ticket_form_t26_sla_badge" model="ir.ui.view">
<field name="name">helpdesk.ticket.form.t26.sla_badge</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="view_helpdesk_ticket_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='sla_status']" position="attributes">
<attribute name="widget">sla_badge</attribute>
<attribute name="options">{'icon': True, 'compact': False}</attribute>
<attribute name="decoration-success"/>
<attribute name="decoration-warning"/>
<attribute name="decoration-danger"/>
</xpath>
</field>
</record>
</odoo>
Deux détails importants :
- Le xpath cible par
@name, jamais par@string.@stringn'est pas un attribut XML valide en v19 — voir T09 — Héritage de vues pour les 5 positions et leurs pièges. - Le contenu vide d'
<attribute>signifie « supprime cet attribut ». Unnullou un espace ne marchent pas — l'élément doit être self-closing ou contenir une chaîne vide explicite.
7. Cycle de vie — du XML au rendu
Ce qui se passe quand le form renderer tombe sur ton <field
widget="sla_badge"/> :
extractProps, instanciation
du composant, setup, render, et réactivité sur changement du record.Points de passage obligés :
- Lookup registry —
registry.category("fields").get("sla_badge")renvoie le descriptor. Si le widget n'est pas enregistré (asset mal déclaré, typo), le renderer remonte au descriptor par défaut du type de champ. extractProps— appelé à chaque render, reçoit l'info statique du field (fieldInfo) et l'info dynamique (dynamicInfo). On y transforme lesoptions="{…}"XML en props typés.- Instanciation —
new SlaBadgeField(props)avecstandardFieldPropspour l'essentiel (record,name,readonly) et nos props personnalisés par-dessus. setup()— hooks OWL (useState,useService,useRef,onWillStart…) si besoin. Ici notre widget est stateless côté client, donc pas desetupdéclaré.- Render — le template OWL est évalué. Les getters
value,pillClass,iconClass,labelsont appelés, OWL trace leurs dépendances (props.record.data[name]) et re-rendra automatiquement à chaque changement.
8. Vérifier le rendu
Après -u odooskills_helpdesk --stop-after-init local puis rsync VPS, on
recharge un ticket dont le SLA est dans les temps, un proche de l'échéance,
et un dépassé. Les trois captures ci-dessous sont réelles — capturées par Playwright
sur l'instance de démo odooskills_demo_v19.
widget="badge" natif qui se contente d'une couleur uniforme.Un test TransactionCase côté serveur (voir
T24 — Tests
automatisés) suffit à valider la partie Python — le widget lui-même n'a pas
besoin de test unitaire JS puisqu'il est testé visuellement par Odoo.sh / CI en
amont du déploiement.
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSlaBadgeRegistration(TransactionCase):
def test_inherit_view_loads(self):
view = self.env.ref(
'odooskills_helpdesk.view_helpdesk_ticket_form_t26_sla_badge'
)
self.assertEqual(view.model, 'helpdesk.ticket')
self.assertIn('sla_badge', view.arch)
def test_manifest_declares_assets(self):
from odoo.modules.module import get_manifest
backend = get_manifest('odooskills_helpdesk')['assets']['web.assets_backend']
paths = [p if isinstance(p, str) else p[1] for p in backend]
self.assertTrue(any('sla_badge.js' in p for p in paths))
self.assertTrue(any('sla_badge.xml' in p for p in paths))
self.assertTrue(any('sla_badge.scss' in p for p in paths))
9. Ce que tu ne verras plus en v19
⚠️ Patterns morts — à ne pas copier d'un tutoriel v15/v16
Odoo 19 est 100% OWL/ESM côté widgets — la couche legacy qui faisait encore pont en v14/v15 a été complètement retirée. Si tu rencontres ces patterns dans un vieux tuto, passe ton chemin.
| Legacy ≤ v15 | Équivalent v19 | Pourquoi |
|---|---|---|
odoo.define('module.MyWidget', …) |
import { Component } from "@odoo/owl" |
AMD loader supprimé. ES modules uniquement. |
AbstractField.extend({}) |
class extends Component {} |
Plus d'héritage de classe abstraite — le descriptor registry suffit. |
this.$el, this.$(…) |
useRef("elemName") |
jQuery retiré du backend. useRef cible un DOM node par t-ref. |
_render(), _renderEdit(), _renderReadonly() |
Un seul template XML + getters réactifs | OWL gère le diff et le re-render automatiquement. |
field_registry.add(…) |
registry.category("fields").add(…) |
Registry unifié par catégorie. Plus de registry spécifique par type. |
this.trigger_up('event') |
Callback via props ou useService("bus_service") |
Plus de bubbling custom. Les events remontent par props explicites. |
10. Pièges à éviter
⚠️ 5 pièges qui tuent un field widget custom
- Props typées en chaînes.
{ type: "boolean" }passe le parseur OWL mais la validation stricte rejette à l'exécution. Utilise toujours les constructors natifs JS :Boolean,String,Number,Object,Array. - Template enveloppé dans
<odoo>. Le template OWL vit côté client — sa racine doit être<templates xml:space="preserve">. Wrapper dans<odoo>déclenche une Template not found silencieuse. - Nom de template désynchronisé.
static template = "module.Component"dans le JS doit matcher exactement<t t-name="module.Component">dans le XML. Une faute de frappe, et rien ne s'affiche. - Asset non déclaré dans le manifest. Un fichier JS/XML
oublié dans
web.assets_backend= widget absent du registry. Les logs serveur ne remontent rien — il faut inspecter la console navigateur pour voir "No field widget named sla_badge found". - Les
decoration-*qui persistent. Remplacer unwidget="badge"par son widget custom viaposition="attributes"ne supprime pas lesdecoration-success/decoration-warninghérités. Résultat : deux systèmes de coloration en conflit. Vider avec<attribute name="decoration-X"/>self-closing.
À retenir
- Un field widget v19 = 3 fichiers statiques + 1 vue inherit.
Pas de classe abstraite à étendre, pas de legacy layer à contourner — juste un
Componentde@odoo/owl. - Le descriptor est la source de vérité —
{component, displayName, supportedTypes, supportedOptions, extractProps}suffit pour que le renderer et Studio sachent tout ce qu'ils ont besoin. extractPropsest la fonction charnière entre lesoptions="{…}"XML et les props OWL typés. La traiter comme une interface stricte, pas comme un dépotoir.- XPath
position="attributes"avec<attribute>self-closing est la manière propre de remplacer un widget natif sans laisser desdecoration-*fantômes. - Les patterns legacy sont morts —
odoo.define,AbstractField,this.$el,_render(),trigger_up. Si ton tuto les contient, il est pré-v16.
Voir aussi — Parcours Web/UI
T06 — Vues form, list, search
Où on déclare le <field widget="…"/> — base indispensable avant d'attacher un widget custom.
T09 — Héritage de vues (xpath)
Les 5 positions (inside, after, before, replace, attributes) et leurs pièges.
T19 — Wizards & TransientModel
Un autre cas où un composant OWL custom fait la différence — dialog modal avec logique métier côté client.
Articles complémentaires
T24 — Tests automatisés
Tester la bonne déclaration d'un widget côté serveur — manifest, inherit view, régression du compute source.
T25 — Migration 18 → 19
L'épisode précédent de la série — t-esc, attrs, et tous les patterns morts à rattraper.
T23 — Controllers HTTP
Même module fil rouge helpdesk, facette back-end — les API auxquelles un widget OWL peut causer via useService("orm").
Télécharge le Guide Technique Odoo
Module fil rouge, 20+ articles techniques, environnements de dev complet — PDF à télécharger.
Télécharger le guide