Custom Field Controllers
Extend or override built-in Frappe field types to create new input behaviors without changing how values are stored.
On this page
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
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.
No core modifications — The
optionsfield on Float is already available. The custom behavior activates only whenoptions="Fraction"is set.Backward compatible — All existing Float fields without
options="Fraction"behave identically to before.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";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.