frappe-desk-customization

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Frappe Desk Customization

Frappe Desk 自定义

Customize the Frappe Desk admin UI with form scripts, list views, dialogs, and client-side APIs.
使用表单脚本、列表视图、对话框及客户端API自定义Frappe Desk管理UI。

When to use

适用场景

  • Adding custom buttons or actions to forms
  • Filtering Link fields dynamically
  • Toggling field visibility based on conditions
  • Customizing list view indicators and bulk actions
  • Building interactive dialogs and prompts
  • Adding client-side validation before save
  • Injecting scripts into other apps' DocTypes via hooks
  • 为表单添加自定义按钮或操作
  • 动态过滤链接字段
  • 根据条件切换字段可见性
  • 自定义列表视图指示器和批量操作
  • 构建交互式对话框和提示框
  • 在保存前添加客户端验证
  • 通过钩子将脚本注入其他应用的DocType

Inputs required

所需输入

  • Target DocType for customization
  • Whether script is app-level (version controlled) or Client Script (site-specific)
  • Events to hook into (refresh, validate, field change, etc.)
  • UI behavior requirements (buttons, filters, visibility)
  • 要自定义的目标DocType
  • 脚本是应用级(版本控制)还是客户端脚本(站点特定)
  • 要挂钩的事件(刷新、验证、字段变更等)
  • UI行为需求(按钮、过滤器、可见性)

Procedure

操作步骤

0) Choose script type

0) 选择脚本类型

TypeLocationVersion ControlledUse Case
App-level form script
<app>/<module>/doctype/<doctype>/<doctype>.js
YesStandard app behavior
Client ScriptDocType: Client ScriptNo (DB)Site-specific customization
Hook-injected scriptVia
doctype_js
in
hooks.py
YesExtend other apps' DocTypes
类型位置是否版本控制适用场景
应用级表单脚本
<app>/<module>/doctype/<doctype>/<doctype>.js
标准应用行为
客户端脚本DocType: Client Script否(存储于数据库)站点特定自定义
钩子注入脚本通过
hooks.py
中的
doctype_js
扩展其他应用的DocType

1) Write form scripts

1) 编写表单脚本

javascript
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);
}
javascript
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);
}

2) Build dialogs and prompts

2) 构建对话框和提示框

javascript
// 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 */ }
);
javascript
// 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 */ }
);

3) Make server calls

3) 调用服务器接口

javascript
// 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
});
javascript
// 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
});

4) Customize list views

4) 自定义列表视图

javascript
// 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
};
javascript
// 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
};

5) Use realtime events

5) 使用实时事件

javascript
// Listen for server-side events
frappe.realtime.on("export_complete", function(data) {
    frappe.show_alert({
        message: __("Export complete: {0} records", [data.count]),
        indicator: "green"
    });
});
javascript
// Listen for server-side events
frappe.realtime.on("export_complete", function(data) {
    frappe.show_alert({
        message: __("Export complete: {0} records", [data.count]),
        indicator: "green"
    });
});

6) Inject scripts via hooks

6) 通过钩子注入脚本

To extend a DocType from another app without modifying it:
python
undefined
要扩展其他应用的DocType而不修改其代码:
python
undefined

hooks.py

hooks.py

doctype_js = { "Sales Order": "public/js/sales_order_custom.js" }
doctype_list_js = { "Sales Order": "public/js/sales_order_list_custom.js" }

```bash
doctype_js = { "Sales Order": "public/js/sales_order_custom.js" }
doctype_list_js = { "Sales Order": "public/js/sales_order_list_custom.js" }

```bash

Rebuild assets after adding hook scripts

Rebuild assets after adding hook scripts

bench build --app my_app
undefined
bench build --app my_app
undefined

7) Navigation and routing

7) 导航与路由

javascript
// 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();
javascript
// 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();

Verification

验证步骤

  • Form script loads without JS console errors
  • Custom buttons appear in correct conditions
  • Field visibility toggles work
  • Link field filters return correct options
  • Validation prevents invalid saves
  • List view indicators display correctly
  • Dialogs open, collect input, and submit
  • 表单脚本加载时JS控制台无错误
  • 自定义按钮在正确条件下显示
  • 字段可见性切换正常
  • 链接字段过滤器返回正确选项
  • 验证规则可阻止无效保存
  • 列表视图指示器显示正确
  • 对话框可正常打开、收集输入并提交

Failure modes / debugging

故障排查/调试

  • Script not loading: Check file path matches DocType; run
    bench build
  • Button not appearing: Check condition logic in
    refresh
    ; verify
    frm.doc.docstatus
  • Event not firing: Verify event name matches exactly (case-sensitive)
  • Hook script ignored: Check
    hooks.py
    path; rebuild assets
  • frappe.call
    failing
    : Check method path; verify
    @frappe.whitelist()
    on server
  • 脚本未加载:检查文件路径是否与DocType匹配;执行
    bench build
  • 按钮未显示:检查
    refresh
    中的条件逻辑;验证
    frm.doc.docstatus
    的值
  • 事件未触发:确认事件名称完全匹配(区分大小写)
  • 钩子脚本被忽略:检查
    hooks.py
    中的路径;重新构建资源
  • frappe.call
    调用失败
    :检查方法路径;验证服务器端是否添加了
    @frappe.whitelist()
    装饰器

Escalation

问题升级

  • For server-side controller logic →
    frappe-doctype-development
  • For RPC endpoint implementation →
    frappe-api-development
  • For Frappe UI (Vue 3) frontends →
    frappe-frontend-development
  • 服务器端控制器逻辑相关问题 →
    frappe-doctype-development
  • RPC端点实现相关问题 →
    frappe-api-development
  • Frappe UI(Vue 3)前端相关问题 →
    frappe-frontend-development

References

参考资料

  • references/desk.md — Desk UI views and scripting
  • references/js-api.md — JavaScript client API reference
  • references/desk.md — Desk UI视图与脚本开发
  • references/js-api.md — JavaScript客户端API参考

Guardrails

注意事项

  • Use
    frm.doc
    not
    doc
    directly
    : Always access document via
    frm.doc
    for consistency and reactivity
  • Validate before save: Use
    frm.validate()
    in
    validate
    event, not
    before_save
  • Async awareness:
    frappe.call()
    is async; use callbacks or async/await for sequential operations
  • Refresh after field changes: Call
    frm.refresh_field()
    or
    frm.refresh_fields()
    after programmatic changes
  • Check
    frm.is_new()
    appropriately
    : Some operations only make sense on saved documents
  • 使用
    frm.doc
    而非直接使用
    doc
    :始终通过
    frm.doc
    访问文档,以保证一致性和响应性
  • 在保存前进行验证:在
    validate
    事件中使用
    frm.validate()
    ,而非
    before_save
  • 异步操作意识
    frappe.call()
    是异步的;使用回调或async/await处理顺序操作
  • 字段变更后刷新:通过编程方式修改字段后,调用
    frm.refresh_field()
    frm.refresh_fields()
  • 合理使用
    frm.is_new()
    :部分操作仅对已保存的文档有意义

Common Mistakes

常见错误

MistakeWhy It FailsFix
Missing
frm.refresh_field()
after
set_value
UI doesn't updateCall
frm.refresh_field('fieldname')
after
frm.set_value()
Wrong event hook nameEvent never firesUse exact names:
refresh
,
validate
,
onload
,
before_save
Blocking UI with sync callsPage freezesUse
frappe.call()
with async: true (default)
Using
cur_frm
instead of
frm
Breaks in dialogs/multiple formsAlways use the
frm
parameter passed to handlers
Not checking
frm.doc.docstatus
Buttons appear on submitted docsCheck
frm.doc.docstatus == 0
before showing edit actions
console.log(frm.doc)
showing stale data
Debugging confusionUse
frm.reload_doc()
or check network responses
错误失败原因修复方法
调用
set_value
后未调用
frm.refresh_field()
UI未更新
frm.set_value()
后调用
frm.refresh_field('fieldname')
事件钩子名称错误事件永远不会触发使用准确的事件名称:
refresh
validate
onload
before_save
使用同步调用阻塞UI页面冻结使用默认异步的
frappe.call()
使用
cur_frm
而非
frm
在对话框/多表单场景中失效始终使用事件处理函数传入的
frm
参数
未检查
frm.doc.docstatus
按钮在已提交的文档上显示显示编辑操作前检查
frm.doc.docstatus == 0
console.log(frm.doc)
显示陈旧数据
调试混淆使用
frm.reload_doc()
或检查网络响应