This covers everything you need to customize Frappe's Desk UI through client-side JavaScript. You'll use it when adding custom buttons to forms, dynamically filtering link fields, building dialogs, or scripting form behavior like conditional validation and field visibility. The guide walks through form scripts (both app-level and Client Script DocTypes), list view customization with indicators and bulk actions, and injecting scripts into other apps' DocTypes via hooks. It's comprehensive on the mechanics but assumes you already understand Frappe's architecture. The child table event handling and realtime events sections are especially useful since those patterns aren't always obvious from the framework docs alone.
npx -y skills add lubusin/agent-skills --skill frappe-desk-customization --agent claude-codeInstalls into .claude/skills of the current project.
Customize the Frappe Desk admin UI with form scripts, list views, dialogs, and client-side APIs.
| Type | Location | Version Controlled | Use Case |
|---|---|---|---|
| App-level form script | <app>/<module>/doctype/<doctype>/<doctype>.js | Yes | Standard app behavior |
| Client Script | DocType: Client Script | No (DB) | Site-specific customization |
| Hook-injected script | Via doctype_js in hooks.py | Yes | Extend other apps' DocTypes |
frappe.ui.form.on("My DocType", {
// Called once during form setup
setup(frm) {
frm.set_query("customer", function() {
return {
filters: { "status": "Active" }
};
});
},
// Called every time form loads or refreshes
refresh(frm) {
if (frm.doc.status === "Draft") {
frm.add_custom_button(__("Submit for Review"), function() {
frappe.call({
method: "my_app.api.submit_for_review",
args: { name: frm.doc.name },
callback(r) {
frm.reload_doc();
}
});
}, __("Actions"));
}
// Toggle field visibility
frm.toggle_display("discount_section", frm.doc.grand_total > 1000);
// Set field properties
frm.set_df_property("notes", "read_only", frm.doc.docstatus === 1);
},
// Called before save — return false to cancel
validate(frm) {
if (frm.doc.end_date < frm.doc.start_date) {
frappe.msgprint(__("End date must be after start date"));
frappe.validated = false;
}
},
// Field change handler (use fieldname as key)
customer(frm) {
if (frm.doc.customer) {
frappe.db.get_value("Customer", frm.doc.customer, "territory",
function(r) {
frm.set_value("territory", r.territory);
}
);
}
},
// Before save hook
before_save(frm) {
frm.doc.full_name = `${frm.doc.first_name} ${frm.doc.last_name}`;
},
// After save hook
after_save(frm) {
frappe.show_alert({
message: __("Document saved successfully"),
indicator: "green"
});
}
});
// Child table events
frappe.ui.form.on("My DocType Item", {
qty(frm, cdt, cdn) {
let row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "amount", row.qty * row.rate);
calculate_total(frm);
},
items_remove(frm) {
calculate_total(frm);
}
});
function calculate_total(frm) {
let total = 0;
(frm.doc.items || []).forEach(row => {
total += row.amount || 0;
});
frm.set_value("grand_total", total);
}
// Simple prompt
frappe.prompt(
{ fieldname: "reason", fieldtype: "Small Text", label: "Reason", reqd: 1 },
function(values) {
frappe.call({
method: "my_app.api.reject",
args: { name: frm.doc.name, reason: values.reason }
});
},
__("Rejection Reason"),
__("Reject")
);
// Multi-field dialog
let d = new frappe.ui.Dialog({
title: __("Configure Settings"),
fields: [
{ fieldname: "email", fieldtype: "Data", options: "Email", label: "Email", reqd: 1 },
{ fieldname: "frequency", fieldtype: "Select", options: "Daily\nWeekly\nMonthly", label: "Frequency" },
{ fieldname: "active", fieldtype: "Check", label: "Active", default: 1 }
],
primary_action_label: __("Save"),
primary_action(values) {
frappe.call({
method: "my_app.api.save_settings",
args: values,
callback() {
d.hide();
frappe.show_alert({ message: __("Settings saved"), indicator: "green" });
}
});
}
});
d.show();
// Confirmation dialog
frappe.confirm(
__("Are you sure you want to delete this?"),
function() { /* Yes */ },
function() { /* No */ }
);
// Standard call (callback)
frappe.call({
method: "my_app.api.get_stats",
args: { customer: frm.doc.customer },
freeze: true,
freeze_message: __("Loading..."),
callback(r) {
if (r.message) {
frm.set_value("total_orders", r.message.total);
}
}
});
// Promise-based call
let result = await frappe.xcall("my_app.api.get_stats", {
customer: frm.doc.customer
});
// my_app/public/js/sample_doc_list.js
// or via hooks: doctype_list_js = {"Sample Doc": "public/js/sample_doc_list.js"}
frappe.listview_settings["Sample Doc"] = {
// Status indicator colors
get_indicator(doc) {
if (doc.status === "Open") return [__("Open"), "orange", "status,=,Open"];
if (doc.status === "Closed") return [__("Closed"), "green", "status,=,Closed"];
return [__("Draft"), "grey", "status,=,Draft"];
},
// Add bulk actions
onload(listview) {
listview.page.add_action_item(__("Mark as Closed"), function() {
let names = listview.get_checked_items(true);
frappe.call({
method: "my_app.api.bulk_close",
args: { names },
callback() { listview.refresh(); }
});
});
},
// Hide default "New" button
hide_name_column: true
};
// Listen for server-side events
frappe.realtime.on("export_complete", function(data) {
frappe.show_alert({
message: __("Export complete: {0} records", [data.count]),
indicator: "green"
});
});
To extend a DocType from another app without modifying it:
# hooks.py
doctype_js = {
"Sales Order": "public/js/sales_order_custom.js"
}
doctype_list_js = {
"Sales Order": "public/js/sales_order_list_custom.js"
}
# Rebuild assets after adding hook scripts
bench build --app my_app
// Navigate to a document
frappe.set_route("Form", "Sales Order", "SO-001");
// Navigate to list with filters
frappe.route_options = { "status": "Open" };
frappe.set_route("List", "Sales Order");
// Get current route
let route = frappe.get_route();
bench buildrefresh; verify frm.doc.docstatushooks.py path; rebuild assetsfrappe.call failing: Check method path; verify @frappe.whitelist() on serverfrappe-doctype-developmentfrappe-api-developmentfrappe-frontend-developmentfrm.doc not doc directly: Always access document via frm.doc for consistency and reactivityfrm.validate() in validate event, not before_savefrappe.call() is async; use callbacks or async/await for sequential operationsfrm.refresh_field() or frm.refresh_fields() after programmatic changesfrm.is_new() appropriately: Some operations only make sense on saved documents| Mistake | Why It Fails | Fix |
|---|---|---|
Missing frm.refresh_field() after set_value | UI doesn't update | Call frm.refresh_field('fieldname') after frm.set_value() |
| Wrong event hook name | Event never fires | Use exact names: refresh, validate, onload, before_save |
| Blocking UI with sync calls | Page freezes | Use frappe.call() with async: true (default) |
Using cur_frm instead of frm | Breaks in dialogs/multiple forms | Always use the frm parameter passed to handlers |
Not checking frm.doc.docstatus | Buttons appear on submitted docs | Check frm.doc.docstatus == 0 before showing edit actions |
console.log(frm.doc) showing stale data | Debugging confusion | Use frm.reload_doc() or check network responses |
sickn33/antigravity-awesome-skills
moizibnyousaf/ai-agent-skills
github/awesome-copilot