Way4Tech

Part 6. Extending Odoo itself · lesson 27 of 40

Writing a custom field widget

After this lesson you can: Write, register and use a custom field widget, and extend a standard one.

Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.

A field widget controls how one field is displayed and edited. It is the most common piece of custom OWL in real modules, and the most contained: it affects only the fields that ask for it.

<field name="progress" widget="my_progress"/>

A complete one

// static/src/fields/progress_field.js
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";

export class ProgressField extends Component {
    static template = "owl_course.ProgressField";
    static props = { ...standardFieldProps };

    get value() {
        return this.props.record.data[this.props.name] || 0;
    }

    get barClass() {
        if (this.value >= 100) return "bg-success";
        if (this.value >= 50) return "bg-info";
        return "bg-warning";
    }

    async setValue(next) {
        const clamped = Math.min(100, Math.max(0, next));
        await this.props.record.update({ [this.props.name]: clamped });
    }
}

export const progressField = {
    component: ProgressField,
    displayName: "Progress Bar",
    supportedTypes: ["float", "integer"],
    extractProps: ({ options }) => ({ showValue: options.show_value ?? true }),
};

registry.category("fields").add("my_progress", progressField);
<t t-name="owl_course.ProgressField">
    <div class="d-flex align-items-center gap-2" style="min-width: 120px;">
        <div class="progress flex-grow-1" style="height: 14px;">
            <div class="progress-bar" t-att-class="barClass"
                 t-attf-style="width: {{ value }}%"/>
        </div>
        <span t-if="!props.readonly" class="small text-muted">
            <input type="number" class="form-control form-control-sm"
                   style="width: 70px;"
                   t-att-value="value"
                   t-on-change="(ev) => this.setValue(parseFloat(ev.target.value))"/>
        </span>
        <span t-else="" class="small text-muted"><t t-esc="value"/>%</span>
    </div>
</t>

standardFieldProps

Every field widget receives the same set of props, and spreading standardFieldProps declares them all:

PropWhat it is
recordThe record. record.data[name] is your value
nameThe field name
readonlyWhether the field is currently read only
idDOM id for label association

Do not invent your own version of these. Views pass them, and a widget that declares different props will fail validation.

Reading and writing

Read through this.props.record.data[this.props.name]. Not from a copy taken in setup, which will not follow changes.

Write through record.update(), which is async and returns a promise:

await this.props.record.update({ [this.props.name]: value });

update is what triggers onchanges, recomputes and dirty tracking. Never assign to record.data directly. It changes the object, the view does not notice, and the value is not saved.

In Odoo 17 and later, pass an object keyed by field name as shown. Older examples pass the bare value.

Registering

export const progressField = {
    component: ProgressField,
    displayName: "Progress Bar",        // shown in Studio and the field list
    supportedTypes: ["float", "integer"],
    supportedOptions: [{
        label: "Show value", name: "show_value", type: "boolean",
    }],
    extractProps: ({ attrs, options, string }) => ({
        showValue: options.show_value ?? true,
    }),
    fieldDependencies: [{ name: "state", type: "selection" }],
    isEmpty: () => false,
};

supportedTypes is checked, so a widget declared for float used on a char gives a clear error rather than a strange render.

extractProps turns view attributes into props. Options come from the view:

<field name="progress" widget="my_progress" options="{'show_value': false}"/>

fieldDependencies asks the view to fetch extra fields your widget needs but which are not on the view. Without it, record.data.state is simply undefined.

isEmpty controls whether the field is treated as empty, which affects required styling and the โ€œno contentโ€ display.

Extending a standard widget

Often cheaper and more upgrade proof than starting from nothing.

import { CharField, charField } from "@web/views/fields/char/char_field";
import { registry } from "@web/core/registry";

export class UpperCharField extends CharField {
    async onInput(ev) {
        ev.target.value = ev.target.value.toUpperCase();
        return super.onInput(ev);
    }
}

registry.category("fields").add("upper_char", {
    ...charField,
    component: UpperCharField,
});

Spreading charField keeps its supportedTypes, extractProps and the rest, so you inherit everything and change one method.

Standard widgets worth knowing as bases: char, text, integer, float, monetary, date, datetime, selection, many2one, many2many_tags, one2many, boolean, badge, image, binary, html.

Using it

<field name="progress" widget="my_progress"/>
<field name="progress" widget="my_progress" options="{'show_value': true}"/>

Works in form, list and kanban views. In a list your widget renders in a table cell, so keep it compact and give it a sensible minimum width, or the column collapses.

Readonly matters

Honour props.readonly. A widget that renders an editable input in a readonly context lets a user type into something that will never be saved, which is worse than showing nothing.

The same applies in a list view where only one row is in edit mode: your widget renders for every row, and all but one are readonly.

Finding how core does it

addons/web/static/src/views/fields/
  char/char_field.js
  many2one/many2one_field.js
  progress_bar/progress_bar_field.js

Before writing a widget, read the nearest standard one. They are short, they are the reference implementation of every convention above, and a good half of the widgets people write already exist.

What is next

A field widget with its own editing interface, where the value is not a simple input.

Back to the full course