Se rendre au contenu

OWL composants custom Odoo 19

Saison « Dépassement tech v19 » · Article 3/5
26 avril 2026 par
OWL composants custom Odoo 19
OdooBot

Saison « Dépassement tech v19 » · Article 3/5

Field widget OWL custom en Odoo 19 — registry, Component et XML inherit

Construire un field widget OWL de zéro en Odoo 19 — déclaration registry.category("fields"), classe Component issue de @odoo/owl, template XML, feuille SCSS, et remplacement propre du widget natif via xpath position="attributes". Exemple concret : une pastille colorée SLA sur le form du ticket helpdesk.

~14 minutes de lecture · niveau avancé · parcours Web/UI

Widget field OWL custom sla_badge en état breach — pastille rouge avec icône d'alerte et animation pulse
Objectif de l'article — remplacer le 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 :

  • standardFieldProps est spreadé pour hériter des {id, name, readonly, record} que tout field widget reçoit du renderer — définis en dur dans odoo/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 à appeler render() 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 de Component).
  • 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 XML options="{…}". Utilisé par Studio pour construire l'UI de config.
  • extractPropsla 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-out et pas t-esc. En v19, t-esc est 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 de class. Utile pour composer des classes dynamiques comme pillClass ci-dessus.
  • Le nom du template (odooskills_helpdesk.SlaBadge) doit matcher exactement static template dans 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 :

Widget SLA Badge en état ok — pastille verte avec icône check
sla_status == "ok" — pastille verte
Widget SLA Badge en état warning — pastille orange avec icône clock
sla_status == "warning" — pastille orange
Widget SLA Badge en état breach — pastille rouge avec icône alerte et animation pulse
sla_status == "breach" — pastille rouge pulsante

6. 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. @string n'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 ». Un null ou 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"/> :

Diagramme Mermaid — cycle de vie d'un field widget OWL custom en Odoo 19, du parse XML au rendu réactif
Flow complet — registry lookup, extractProps, instanciation du composant, setup, render, et réactivité sur changement du record.

Points de passage obligés :

  1. Lookup registryregistry.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.
  2. extractProps — appelé à chaque render, reçoit l'info statique du field (fieldInfo) et l'info dynamique (dynamicInfo). On y transforme les options="{…}" XML en props typés.
  3. Instanciationnew SlaBadgeField(props) avec standardFieldProps pour l'essentiel (record, name, readonly) et nos props personnalisés par-dessus.
  4. setup() — hooks OWL (useState, useService, useRef, onWillStart…) si besoin. Ici notre widget est stateless côté client, donc pas de setup déclaré.
  5. Render — le template OWL est évalué. Les getters value, pillClass, iconClass, label sont 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.

Form ticket helpdesk Odoo 19 — widget sla_badge en état breach sur un ticket Serveur ERP inaccessible
Form complet d'un ticket en état breach — la pastille rouge pulsante est immédiatement identifiable parmi les autres champs SLA, contrairement au 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 un widget="badge" par son widget custom via position="attributes" ne supprime pas les decoration-success / decoration-warning hé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 Component de @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.
  • extractProps est la fonction charnière entre les options="{…}" 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 des decoration-* fantômes.
  • Les patterns legacy sont mortsodoo.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
Migration Odoo 18 → 19 — scripts pre, post, end et 7 breaking changes
Saison « Dépassement tech v19 » · Article 2/5