frappe-desk-customization
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFrappe 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) 选择脚本类型
| Type | Location | Version Controlled | Use Case |
|---|---|---|---|
| App-level form script | | Yes | Standard app behavior |
| Client Script | DocType: Client Script | No (DB) | Site-specific customization |
| Hook-injected script | Via | Yes | Extend other apps' DocTypes |
| 类型 | 位置 | 是否版本控制 | 适用场景 |
|---|---|---|---|
| 应用级表单脚本 | | 是 | 标准应用行为 |
| 客户端脚本 | DocType: Client Script | 否(存储于数据库) | 站点特定自定义 |
| 钩子注入脚本 | 通过 | 是 | 扩展其他应用的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
undefinedhooks.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"
}
```bashdoctype_js = {
"Sales Order": "public/js/sales_order_custom.js"
}
doctype_list_js = {
"Sales Order": "public/js/sales_order_list_custom.js"
}
```bashRebuild assets after adding hook scripts
Rebuild assets after adding hook scripts
bench build --app my_app
undefinedbench build --app my_app
undefined7) 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 ; verify
refreshfrm.doc.docstatus - Event not firing: Verify event name matches exactly (case-sensitive)
- Hook script ignored: Check path; rebuild assets
hooks.py - failing: Check method path; verify
frappe.callon server@frappe.whitelist()
- 脚本未加载:检查文件路径是否与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 not
frm.docdirectly: Always access document viadocfor consistency and reactivityfrm.doc - Validate before save: Use in
frm.validate()event, notvalidatebefore_save - Async awareness: is async; use callbacks or async/await for sequential operations
frappe.call() - Refresh after field changes: Call or
frm.refresh_field()after programmatic changesfrm.refresh_fields() - Check appropriately: Some operations only make sense on saved documents
frm.is_new()
- 使用而非直接使用
frm.doc:始终通过doc访问文档,以保证一致性和响应性frm.doc - 在保存前进行验证:在事件中使用
validate,而非frm.validate()before_save - 异步操作意识:是异步的;使用回调或async/await处理顺序操作
frappe.call() - 字段变更后刷新:通过编程方式修改字段后,调用或
frm.refresh_field()frm.refresh_fields() - 合理使用:部分操作仅对已保存的文档有意义
frm.is_new()
Common Mistakes
常见错误
| Mistake | Why It Fails | Fix |
|---|---|---|
Missing | UI doesn't update | Call |
| Wrong event hook name | Event never fires | Use exact names: |
| Blocking UI with sync calls | Page freezes | Use |
Using | Breaks in dialogs/multiple forms | Always use the |
Not checking | Buttons appear on submitted docs | Check |
| Debugging confusion | Use |
| 错误 | 失败原因 | 修复方法 |
|---|---|---|
调用 | UI未更新 | 在 |
| 事件钩子名称错误 | 事件永远不会触发 | 使用准确的事件名称: |
| 使用同步调用阻塞UI | 页面冻结 | 使用默认异步的 |
使用 | 在对话框/多表单场景中失效 | 始终使用事件处理函数传入的 |
未检查 | 按钮在已提交的文档上显示 | 显示编辑操作前检查 |
| 调试混淆 | 使用 |