Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Custom Field Controllers

Extend or override built-in Frappe field types to create new input behaviors without changing how values are stored.

Updated
Tags
  • frappe
  • javascript
  • controls
  • client-side
Reading time
4 min

Extend or override Frappe's built-in field types to create entirely new input behaviors while maintaining backward compatibility with the database and existing field infrastructure.

Pattern: Conditional Field Extension

The core technique is to monkey-patch the existing field class, conditionally delegating to a mixin when a specific options value is set. This avoids creating a new fieldtype while reusing all existing infrastructure.

Example: Fraction Input for Float Fields

When a Float field has options="Fraction", it renders as dual numerator/denominator inputs while storing a decimal value in the database.

File: myapp/public/js/fraction_field.bundle.js

The .bundle.js suffix tells Frappe's asset builder to process it through esbuild, resolving imports.

Step 1: Save the Original Class

const OriginalControlFloat = frappe.ui.form.ControlFloat;

Critical to avoid infinite recursion when the new class calls super.

Step 2: Replace the Global Class

frappe.ui.form.ControlFloat = class ControlFloat extends OriginalControlFloat {
    // All Float fields now use this class
};

Step 3: Conditional Delegation

Each overridden method checks this.df.options and delegates to the mixin or falls back to super:

frappe.ui.form.ControlFloat = class ControlFloat extends OriginalControlFloat {
    make_input() {
        if (this.df.options === "Fraction") {
            return frappe.ui.form.ControlFraction.prototype.make_input.call(this);
        }
        return super.make_input();
    }

    get_input_value() {
        if (this.df.options === "Fraction") {
            return frappe.ui.form.ControlFraction.prototype.get_input_value.call(this);
        }
        return super.get_input_value();
    }

    set_formatted_input(value) {
        if (this.df.options === "Fraction") {
            return frappe.ui.form.ControlFraction.prototype.set_formatted_input.call(this, value);
        }
        return super.set_formatted_input(value);
    }

    set_input(value) {
        if (this.df.options === "Fraction") {
            return frappe.ui.form.ControlFraction.prototype.set_input.call(this, value);
        }
        return super.set_input(value);
    }
};

The .call(this) pattern runs the mixin method in the context of the current Float control instance — effectively a mixin without multiple inheritance.

Step 4: Define the Mixin Class

frappe.ui.form.ControlFraction = class ControlFraction {
    make_input() {
        // Build custom DOM structure
        // Using hast-util-to-dom for declarative DOM creation:
        const tree = {
            type: "element",
            tagName: "div",
            properties: { className: ["fraction-field-wrapper"] },
            children: [
                {
                    type: "element",
                    tagName: "input",
                    properties: {
                        type: "number",
                        className: ["fraction-numerator", "input-xs"],
                    },
                },
                {
                    type: "element",
                    tagName: "span",
                    properties: { className: ["fraction-separator"] },
                    children: [{ type: "text", value: " / " }],
                },
                {
                    type: "element",
                    tagName: "input",
                    properties: {
                        type: "number",
                        className: ["fraction-denominator", "input-xs"],
                    },
                },
            ],
        };

        const wrapper = toDom(tree);
        this.input_area.innerHTML = "";
        this.input_area.appendChild(wrapper);

        // Store references for base class compatibility
        this.numerator = wrapper.querySelector(".fraction-numerator");
        this.denominator = wrapper.querySelector(".fraction-denominator");
        this.$input = $(this.numerator);  // Base class expects jQuery

        // Wire change events
        [this.numerator, this.denominator].forEach((input) => {
            input.addEventListener("change", () => {
                this.parse_validate_and_set_in_model(this.get_input_value());
            });
        });
    }

    get_input_value() {
        const n = parseFloat(this.numerator?.value);
        const d = parseFloat(this.denominator?.value);
        if (isNaN(n) || isNaN(d) || d === 0) return null;
        return n / d;
    }

    set_formatted_input(value) {
        if (value == null || value === "") {
            if (this.numerator) this.numerator.value = "";
            if (this.denominator) this.denominator.value = "";
            return;
        }
        // Use fraction.js to convert decimal to fraction
        const frac = new Fraction(value);
        this.numerator.value = frac.n * (frac.s < 0 ? -1 : 1);
        this.denominator.value = frac.d;
    }

    set_input(value) {
        this.value = value;
        this.last_value = value;
        this.set_formatted_input(value);
    }
};

Step 5: Override the Formatter

For list views and read-only display:

const original_float_formatter = frappe.form.formatters.Float;

frappe.form.formatters.Float = function (value, df, options, doc) {
    if (df && df.options === "Fraction") {
        if (value == null || value === 0) return "";
        return new Fraction(value).toFraction(true);  // "3/4"
    }
    return original_float_formatter(value, df, options, doc);
};

Activating the Custom Behavior

Set options="Fraction" on any Float field via:

  • Property Setter in a custom JSON:

    {
        "property_setters": [{
            "doc_type": "UOM Conversion Detail",
            "field_name": "conversion_factor",
            "property": "options",
            "value": "Fraction"
        }]
    }
  • Custom Field definition:

    {
        "fieldname": "my_fraction_field",
        "fieldtype": "Float",
        "options": "Fraction"
    }

Key Design Principles

  1. Decimal storage — The database always stores the float value (0.75). The UI converts to/from fractions. This preserves compatibility with calculations, queries, and export.

  2. No core modifications — The options field on Float is already available. The custom behavior activates only when options="Fraction" is set.

  3. Backward compatible — All existing Float fields without options="Fraction" behave identically to before.

  4. Dependencies in bundles — External libraries (fraction.js, hast-util-to-dom) are imported at the top and bundled by Frappe's esbuild:

    import Fraction from "fraction.js";
    import { toDom } from "hast-util-to-dom";
  5. Base class compatibility — Setting this.$input = $(this.numerator) ensures Frappe's base form control methods (focus management, validation feedback, mandatory styling) work correctly with the custom DOM.

Using Custom Controls in Reports

The same custom controls work in report editors via frappe.ui.form.make_control:

let control = frappe.ui.form.make_control({
    df: { fieldtype: "Float", fieldname: "my_field", options: "Fraction" },
    parent: parentElement,
    render_input: true,
});
control.toggle_label(false);
control.toggle_description(false);

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.