What You'll Build
Two versions of a counter widget:
- Standalone counter: manages its own state, accepts
initialValue,min,max,stepprops, emits change events - Odoo field widget: registered in
fieldsregistry, reads/writes an integer field on a form view record
Standalone Counter
Component Code
// Counter.js
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
export class Counter extends Component {
static template = "my_module.Counter";
static props = {
initialValue: { type: Number, optional: true },
min: { type: Number, optional: true },
max: { type: Number, optional: true },
step: { type: Number, optional: true },
onChange: { type: Function, optional: true },
label: { type: String, optional: true },
};
static defaultProps = {
initialValue: 0,
min: -Infinity,
max: Infinity,
step: 1,
onChange: () => {},
label: "Count",
};
setup() {
this.state = useState({ value: this.props.initialValue });
}
increment() {
const next = this.state.value + this.props.step;
if (next > this.props.max) return;
this.state.value = next;
this.props.onChange(this.state.value);
}
decrement() {
const next = this.state.value - this.props.step;
if (next < this.props.min) return;
this.state.value = next;
this.props.onChange(this.state.value);
}
reset() {
this.state.value = this.props.initialValue;
this.props.onChange(this.state.value);
}
get isAtMin() { return this.state.value - this.props.step < this.props.min; }
get isAtMax() { return this.state.value + this.props.step > this.props.max; }
}
Template
<!-- Counter.xml -->
<templates>
<t t-name="my_module.Counter">
<div class="owl-counter">
<span class="counter-label" t-esc="props.label" />
<div class="counter-controls">
<button
class="counter-btn counter-decrement"
t-on-click="decrement"
t-att-disabled="isAtMin"
aria-label="Decrease"
>−</button>
<span class="counter-value" t-esc="state.value" aria-live="polite" />
<button
class="counter-btn counter-increment"
t-on-click="increment"
t-att-disabled="isAtMax"
aria-label="Increase"
>+</button>
</div>
<button class="counter-reset" t-on-click="reset">Reset</button>
</div>
</t>
</templates>
Usage
<!-- Use Counter in a parent template -->
<Counter
label="Quantity"
initialValue="1"
min="1"
max="99"
step="1"
onChange.bind="onQuantityChange"
/>
// Parent component
onQuantityChange(newValue) {
console.log("New quantity:", newValue);
this.state.quantity = newValue;
}
CSS
.owl-counter {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fafafa;
}
.counter-controls {
display: flex;
align-items: center;
gap: 12px;
}
.counter-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #2271b1;
background: white;
color: #2271b1;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.counter-btn:hover:not(:disabled) {
background: #2271b1;
color: white;
}
.counter-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.counter-value {
font-size: 28px;
font-weight: 700;
min-width: 48px;
text-align: center;
color: #1d2327;
}
.counter-reset {
font-size: 11px;
color: #666;
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
}
Controlled Counter (No Internal State)
When the parent needs full control (e.g., form validation gate), use a controlled pattern: no internal state, value comes from props.
// ControlledCounter.js — no useState
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class ControlledCounter extends Component {
static template = "my_module.ControlledCounter";
static props = {
value: Number,
min: { type: Number, optional: true },
max: { type: Number, optional: true },
step: { type: Number, optional: true },
onUpdate: Function, // parent owns state; must provide this
};
static defaultProps = { min: -Infinity, max: Infinity, step: 1 };
increment() {
const next = this.props.value + this.props.step;
if (next <= this.props.max) this.props.onUpdate(next);
}
decrement() {
const next = this.props.value - this.props.step;
if (next >= this.props.min) this.props.onUpdate(next);
}
}
<t t-name="my_module.ControlledCounter">
<div class="owl-counter">
<button t-on-click="decrement">−</button>
<span t-esc="props.value" />
<button t-on-click="increment">+</button>
</div>
</t>
Odoo Field Widget
An Odoo field widget is an OWL component registered in the fields registry. It receives record, name (field name), and readonly props from the form view infrastructure.
// CounterField.js
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class CounterField extends Component {
static template = "my_module.CounterField";
static props = {
...standardFieldProps, // record, name, readonly, required, etc.
step: { type: Number, optional: true },
min: { type: Number, optional: true },
max: { type: Number, optional: true },
};
static defaultProps = {
step: 1,
min: 0,
max: Infinity,
};
get value() {
return this.props.record.data[this.props.name] ?? 0;
}
increment() {
const next = this.value + this.props.step;
if (next <= this.props.max) {
this.props.record.update({ [this.props.name]: next });
}
}
decrement() {
const next = this.value - this.props.step;
if (next >= this.props.min) {
this.props.record.update({ [this.props.name]: next });
}
}
get isAtMin() { return this.value - this.props.step < this.props.min; }
get isAtMax() { return this.value + this.props.step > this.props.max; }
}
// Register as field widget named "counter"
registry.category("fields").add("counter", CounterField);
<!-- CounterField.xml -->
<templates>
<t t-name="my_module.CounterField">
<div class="owl-counter-field">
<t t-if="!props.readonly">
<button
class="counter-btn"
t-on-click="decrement"
t-att-disabled="isAtMin"
>−</button>
</t>
<span class="counter-value" t-esc="value" />
<t t-if="!props.readonly">
<button
class="counter-btn"
t-on-click="increment"
t-att-disabled="isAtMax"
>+</button>
</t>
</div>
</t>
</templates>
Use in Views
Reference the widget name "counter" in your view XML:
<!-- views/product_form.xml -->
<record id="product_form_view" model="ir.ui.view">
<field name="model">product.template</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name" />
<field
name="qty_on_hand"
widget="counter"
step="1"
min="0"
max="9999"
/>
</group>
</form>
</field>
</record>
How record.update Works
// record.update() — updates local record data, marks field as dirty
// Odoo saves automatically on form Save button or via onSave hook
this.props.record.update({ qty_on_hand: 42 });
// For immediate save (unusual):
await this.props.record.save();
// Warning: save() triggers a full round-trip to the server
Manifest and Assets
# __manifest__.py
{
"name": "Counter Widget",
"version": "17.0.1.0.0",
"depends": ["web"],
"assets": {
"web.assets_backend": [
"my_module/static/src/components/CounterField.js",
"my_module/static/src/components/CounterField.xml",
],
},
}
Testing the Widget
// tests/counter_test.js — OWL component test with @web/../tests/helpers
/** @odoo-module **/
import { Counter } from "@my_module/components/Counter";
import { mount } from "@odoo/owl";
import { getFixture } from "@web/../tests/helpers/utils";
QUnit.module("Counter", () => {
QUnit.test("increments by step", async (assert) => {
const target = getFixture();
const counter = await mount(Counter, target, {
props: { initialValue: 5, step: 2, max: 10 },
env: {},
});
// click +
target.querySelector(".counter-increment").click();
await nextTick();
assert.strictEqual(
target.querySelector(".counter-value").textContent,
"7"
);
});
QUnit.test("disables + at max", async (assert) => {
const target = getFixture();
await mount(Counter, target, {
props: { initialValue: 10, max: 10 },
env: {},
});
assert.ok(target.querySelector(".counter-increment").disabled);
});
});