$npx -y skills add lubusIN/frappe-skills --skill frappe-desk-customizationCustomize Frappe Desk UI with form scripts, list view scripts, report scripts, dialogs, and client-side JavaScript APIs. Use when building interactive Desk experiences, adding custom buttons, or scripting form behavior.
| 1 | # Frappe Desk Customization |
| 2 | |
| 3 | Customize the Frappe Desk admin UI with form scripts, list views, dialogs, and client-side APIs. |
| 4 | |
| 5 | ## When to use |
| 6 | |
| 7 | - Adding custom buttons or actions to forms |
| 8 | - Filtering Link fields dynamically |
| 9 | - Toggling field visibility based on conditions |
| 10 | - Customizing list view indicators and bulk actions |
| 11 | - Building interactive dialogs and prompts |
| 12 | - Adding client-side validation before save |
| 13 | - Injecting scripts into other apps' DocTypes via hooks |
| 14 | |
| 15 | ## Inputs required |
| 16 | |
| 17 | - Target DocType for customization |
| 18 | - Whether script is app-level (version controlled) or Client Script (site-specific) |
| 19 | - Events to hook into (refresh, validate, field change, etc.) |
| 20 | - UI behavior requirements (buttons, filters, visibility) |
| 21 | |
| 22 | ## Procedure |
| 23 | |
| 24 | ### 0) Choose script type |
| 25 | |
| 26 | | Type | Location | Version Controlled | Use Case | |
| 27 | |------|----------|-------------------|----------| |
| 28 | | App-level form script | `<app>/<module>/doctype/<doctype>/<doctype>.js` | Yes | Standard app behavior | |
| 29 | | Client Script | DocType: Client Script | No (DB) | Site-specific customization | |
| 30 | | Hook-injected script | Via `doctype_js` in `hooks.py` | Yes | Extend other apps' DocTypes | |
| 31 | |
| 32 | ### 1) Write form scripts |
| 33 | |
| 34 | ```javascript |
| 35 | frappe.ui.form.on("My DocType", { |
| 36 | // Called once during form setup |
| 37 | setup(frm) { |
| 38 | frm.set_query("customer", function() { |
| 39 | return { |
| 40 | filters: { "status": "Active" } |
| 41 | }; |
| 42 | }); |
| 43 | }, |
| 44 | |
| 45 | // Called every time form loads or refreshes |
| 46 | refresh(frm) { |
| 47 | if (frm.doc.status === "Draft") { |
| 48 | frm.add_custom_button(__("Submit for Review"), function() { |
| 49 | frappe.call({ |
| 50 | method: "my_app.api.submit_for_review", |
| 51 | args: { name: frm.doc.name }, |
| 52 | callback(r) { |
| 53 | frm.reload_doc(); |
| 54 | } |
| 55 | }); |
| 56 | }, __("Actions")); |
| 57 | } |
| 58 | |
| 59 | // Toggle field visibility |
| 60 | frm.toggle_display("discount_section", frm.doc.grand_total > 1000); |
| 61 | |
| 62 | // Set field properties |
| 63 | frm.set_df_property("notes", "read_only", frm.doc.docstatus === 1); |
| 64 | }, |
| 65 | |
| 66 | // Called before save — return false to cancel |
| 67 | validate(frm) { |
| 68 | if (frm.doc.end_date < frm.doc.start_date) { |
| 69 | frappe.msgprint(__("End date must be after start date")); |
| 70 | frappe.validated = false; |
| 71 | } |
| 72 | }, |
| 73 | |
| 74 | // Field change handler (use fieldname as key) |
| 75 | customer(frm) { |
| 76 | if (frm.doc.customer) { |
| 77 | frappe.db.get_value("Customer", frm.doc.customer, "territory", |
| 78 | function(r) { |
| 79 | frm.set_value("territory", r.territory); |
| 80 | } |
| 81 | ); |
| 82 | } |
| 83 | }, |
| 84 | |
| 85 | // Before save hook |
| 86 | before_save(frm) { |
| 87 | frm.doc.full_name = `${frm.doc.first_name} ${frm.doc.last_name}`; |
| 88 | }, |
| 89 | |
| 90 | // After save hook |
| 91 | after_save(frm) { |
| 92 | frappe.show_alert({ |
| 93 | message: __("Document saved successfully"), |
| 94 | indicator: "green" |
| 95 | }); |
| 96 | } |
| 97 | }); |
| 98 | |
| 99 | // Child table events |
| 100 | frappe.ui.form.on("My DocType Item", { |
| 101 | qty(frm, cdt, cdn) { |
| 102 | let row = locals[cdt][cdn]; |
| 103 | frappe.model.set_value(cdt, cdn, "amount", row.qty * row.rate); |
| 104 | calculate_total(frm); |
| 105 | }, |
| 106 | |
| 107 | items_remove(frm) { |
| 108 | calculate_total(frm); |
| 109 | } |
| 110 | }); |
| 111 | |
| 112 | function calculate_total(frm) { |
| 113 | let total = 0; |
| 114 | (frm.doc.items || []).forEach(row => { |
| 115 | total += row.amount || 0; |
| 116 | }); |
| 117 | frm.set_value("grand_total", total); |
| 118 | } |
| 119 | ``` |
| 120 | |
| 121 | ### 2) Build dialogs and prompts |
| 122 | |
| 123 | ```javascript |
| 124 | // Simple prompt |
| 125 | frappe.prompt( |
| 126 | { fieldname: "reason", fieldtype: "Small Text", label: "Reason", reqd: 1 }, |
| 127 | function(values) { |
| 128 | frappe.call({ |
| 129 | method: "my_app.api.reject", |
| 130 | args: { name: frm.doc.name, reason: values.reason } |
| 131 | }); |
| 132 | }, |
| 133 | __("Rejection Reason"), |
| 134 | __("Reject") |
| 135 | ); |
| 136 | |
| 137 | // Multi-field dialog |
| 138 | let d = new frappe.ui.Dialog({ |
| 139 | title: __("Configure Settings"), |
| 140 | fields: [ |
| 141 | { fieldname: "email", fieldtype: "Data", options: "Email", label: "Email", reqd: 1 }, |
| 142 | { fieldname: "frequency", fieldtype: "Select", options: "Daily\nWeekly\nMonthly", label: "Frequency" }, |
| 143 | { fieldname: "active", fieldtype: "Check", label: "Active", default: 1 } |
| 144 | ], |
| 145 | primary_action_label: __("Save"), |
| 146 | primary_action(values) { |
| 147 | frappe.call({ |
| 148 | method: "my_app.api.save_settings", |
| 149 | args: values, |
| 150 | callback() { |
| 151 | d.hide(); |
| 152 | frappe.show_alert({ message: __("Settings saved"), indicator: "green" }); |
| 153 | } |
| 154 | }); |
| 155 | } |
| 156 | }); |
| 157 | d.show(); |
| 158 | |
| 159 | // Confirmation dialog |
| 160 | frappe.confirm( |
| 161 | __("Are you sure you want to delete this?"), |