Way4Tech

Part 8. Three builds, end to end · lesson 39 of 40

Build: a colour and size grid field widget

After this lesson you can: Build a field widget with a real editing interface that behaves correctly in form, list and readonly.

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

A grid entry widget: colours down, sizes across, quantities typed in. Apparel distributors need this, and it exercises every part of a field widget that a single input does not.

The field

# models/sale_order_line.py
from odoo import fields, models


class SaleOrderLine(models.Model):
    _inherit = 'sale.order.line'

    size_grid = fields.Json(
        string='Size grid',
        help="Quantities per colour and size, as "
             "{'Red': {'S': 2, 'M': 4}, 'Blue': {'S': 1}}.",
    )

A Json field keeps this out of a second table. For a real product catalogue you would use variants and one line each, but the widget pattern is the same and this keeps the lesson about the client.

The widget

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

const DEFAULT_SIZES = ["XS", "S", "M", "L", "XL", "XXL"];

export class SizeGridField extends Component {
    static template = "owl_course.SizeGridField";
    static props = {
        ...standardFieldProps,
        sizes: { type: Array, element: String, optional: true },
        colourField: { type: String, optional: true },
    };
    static defaultProps = { sizes: DEFAULT_SIZES };

    setup() {
        this.state = useState({ grid: this.readValue(), error: null });

        onWillUpdateProps((next) => {
            // Only resync on a different record, or typing is wiped by any
            // unrelated re-render of the form.
            if (next.record.resId !== this.props.record.resId) {
                this.state.grid = this.readValue(next);
                this.state.error = null;
            }
        });
    }

    readValue(props = this.props) {
        const raw = props.record.data[props.name];
        if (!raw) return {};
        return typeof raw === "string" ? JSON.parse(raw) : { ...raw };
    }

    get colours() {
        const fromField = this.props.colourField
            ? this.props.record.data[this.props.colourField]
            : null;
        const declared = Array.isArray(fromField)
            ? fromField.map((c) => (Array.isArray(c) ? c[1] : c))
            : [];
        // Colours already carrying quantities must always be shown, or a
        // value the user cannot see is a value they cannot correct.
        return [...new Set([...declared, ...Object.keys(this.state.grid)])];
    }

    qty(colour, size) {
        return this.state.grid[colour]?.[size] ?? "";
    }

    get total() {
        return Object.values(this.state.grid)
            .flatMap((row) => Object.values(row))
            .reduce((sum, n) => sum + (Number(n) || 0), 0);
    }

    rowTotal(colour) {
        return Object.values(this.state.grid[colour] || {})
            .reduce((sum, n) => sum + (Number(n) || 0), 0);
    }

    colTotal(size) {
        return Object.values(this.state.grid)
            .reduce((sum, row) => sum + (Number(row[size]) || 0), 0);
    }

    onInput(colour, size, raw) {
        const n = raw === "" ? 0 : Number(raw);
        if (!Number.isFinite(n) || n < 0) {
            this.state.error = "Quantities must be zero or more";
            return;
        }
        this.state.error = null;
        const row = { ...(this.state.grid[colour] || {}) };
        if (n === 0) {
            delete row[size];
        } else {
            row[size] = n;
        }
        const grid = { ...this.state.grid };
        if (Object.keys(row).length) {
            grid[colour] = row;
        } else {
            delete grid[colour];
        }
        this.state.grid = grid;
    }

    async commit() {
        if (this.state.error) return;
        await this.props.record.update({ [this.props.name]: this.state.grid });
    }

    onKeydown(ev) {
        // Enter moves down a row rather than submitting the form.
        if (ev.key === "Enter") {
            ev.preventDefault();
            const inputs = [...ev.target.closest("table").querySelectorAll("input")];
            const cols = this.props.sizes.length;
            const next = inputs[inputs.indexOf(ev.target) + cols];
            next ? next.focus() : ev.target.blur();
        }
    }
}

export const sizeGridField = {
    component: SizeGridField,
    displayName: "Size Grid",
    supportedTypes: ["json"],
    supportedOptions: [
        { label: "Sizes", name: "sizes", type: "string" },
        { label: "Colour field", name: "colour_field", type: "field" },
    ],
    extractProps: ({ options }) => ({
        sizes: options.sizes ? options.sizes.split(",").map((s) => s.trim()) : undefined,
        colourField: options.colour_field,
    }),
    isEmpty: (record, name) => !Object.keys(record.data[name] || {}).length,
};

registry.category("fields").add("size_grid", sizeGridField);

The template

<templates xml:space="preserve">
<t t-name="owl_course.SizeGridField">
    <div class="o_size_grid" style="min-width: 320px;">

        <div t-if="!colours.length" class="text-muted small">
            Pick colours on this line first.
        </div>

        <table t-else="" class="table table-sm table-bordered mb-1 text-center">
            <thead>
                <tr>
                    <th class="text-start">Colour</th>
                    <th t-foreach="props.sizes" t-as="size" t-key="size" t-esc="size"/>
                    <th>Total</th>
                </tr>
            </thead>
            <tbody>
                <tr t-foreach="colours" t-as="colour" t-key="colour">
                    <th class="text-start fw-normal" t-esc="colour"/>
                    <td t-foreach="props.sizes" t-as="size" t-key="size" class="p-1">
                        <span t-if="props.readonly" t-esc="qty(colour, size) or '-'"/>
                        <input t-else="" type="number" min="0"
                               class="form-control form-control-sm text-center border-0 p-0"
                               t-att-value="qty(colour, size)"
                               t-on-input="(ev) => this.onInput(colour, size, ev.target.value)"
                               t-on-blur="commit"
                               t-on-keydown="onKeydown"/>
                    </td>
                    <td class="fw-semibold" t-esc="rowTotal(colour)"/>
                </tr>
            </tbody>
            <tfoot>
                <tr>
                    <th class="text-start">Total</th>
                    <th t-foreach="props.sizes" t-as="size" t-key="size" t-esc="colTotal(size)"/>
                    <th t-esc="total"/>
                </tr>
            </tfoot>
        </table>

        <div t-if="state.error" class="text-danger small" t-esc="state.error"/>
    </div>
</t>
</templates>

Using it

<field name="size_grid" widget="size_grid"
       options="{'sizes': 'S,M,L,XL', 'colour_field': 'colour_ids'}"/>

The decisions worth noticing

Write on blur, not on input. record.update triggers onchanges. One per keystroke across thirty cells is thirty round trips.

Local draft state plus a guarded resync. The onWillUpdateProps identity check is the difference between a usable widget and one that eats input.

Colours with quantities are always shown. If someone removes a colour from the line, its quantities still exist in the Json. Hiding them hides data the user cannot then correct.

Zero deletes the key. Storing {"Red": {"S": 0}} leaves rubbish in the field and makes isEmpty wrong.

Readonly renders text, not disabled inputs. A greyed input invites typing that will not save.

Enter moves down. Inside a form, Enter would otherwise submit. A grid that cannot be filled in from the keyboard will not be used by the people who fill in grids all day.

A test

import { expect, test } from "@odoo/hoot";
import { click, edit, queryAll } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";

class SaleOrderLine extends models.Model {
    _name = "sale.order.line";
    size_grid = fields.Json();
    _records = [{ id: 1, size_grid: { Red: { S: 2 } } }];
}
defineModels([SaleOrderLine]);

test("typing a quantity writes the grid on blur", async () => {
    onRpc("web_save", ({ args }) => {
        expect(args[1].size_grid).toEqual({ Red: { S: 2, M: 3 } });
        expect.step("saved");
    });

    await mountView({
        type: "form",
        resModel: "sale.order.line",
        resId: 1,
        arch: `<form><field name="size_grid" widget="size_grid"
                           options="{'sizes': 'S,M'}"/></form>`,
    });

    const inputs = queryAll(".o_size_grid input");
    await click(inputs[1]);
    await edit("3", { confirm: "blur" });
    await animationFrame();
    await click(".o_form_button_save");
    await animationFrame();

    expect.verifySteps(["saved"]);
});

Exercises

Add a paste handler accepting a block copied from a spreadsheet. Add a ratio pack button filling a row from a preset. Make the total a fieldDependencies driven comparison against the line quantity and warn when they disagree.

What is next

The last build: a complete module, from folder to store listing.

Back to the full course