frappe-desk-customization について
Frappe Desk の画面をカスタマイズする開発者向けスキルです。フォームスクリプト、リストビュー、ダイアログ、クライアント API を扱い、アプリまたはサイト単位で管理画面を拡張できます。
主な機能
- 01フォームボタンとクライアント側検証の追加
- 02Link フィールドの絞り込みと表示制御
- 03ダイアログの作成とサーバーメソッド呼び出し
- 04リストのインジケーターと一括操作のカスタマイズ
- 05hooks によるスクリプト注入とリアルタイムイベント監視
活用シーン
- 01条件に応じたフォーム操作の表示
- 02フィールド値に基づく関連レコードの絞り込み
- 03入力を受け付けて RPC メソッドを呼び出す
- 04リストのステータス表示や操作のカスタマイズ
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ lubusin/ frappe-skills --skill frappe-desk-customizationインストール後、Claude Code で/frappe-desk-customizationを入力して読み込まれたか確認します。
同梱ファイルreferences/desk.mdreferences/js-api.md
詳細
Frappe Desk のカスタマイズ
フォームスクリプト、リストビュー、ダイアログ、クライアント側 API を使って、Frappe Desk の管理 UI をカスタマイズします。
使用する場面
- フォームにカスタムボタンやアクションを追加する
- Link フィールドを動的にフィルタリングする
- 条件に応じてフィールドの表示/非表示を切り替える
- リストビューのインジケーターや一括操作をカスタマイズする
- インタラクティブなダイアログやプロンプトを作成する
- 保存前にクライアント側で検証する
- フックを使って他のアプリの DocType にスクリプトを挿入する
必要な入力
- カスタマイズ対象の DocType
- スクリプトがアプリレベル(バージョン管理あり)か、Client Script(サイト固有)か
- フックするイベント(refresh、validate、フィールド変更など)
- UI の動作要件(ボタン、フィルター、表示設定)
手順
0) スクリプトの種類を選ぶ
| 種類 | 場所 | バージョン管理 | 用途 |
|---|---|---|---|
| アプリレベルのフォームスクリプト | <app>/<module>/doctype/<doctype>/<doctype>.js | はい | 標準的なアプリの動作 |
| Client Script | DocType: Client Script | いいえ(DB) | サイト固有のカスタマイズ |
| フックで挿入するスクリプト | hooks.py の doctype_js 経由 | はい | 他のアプリの DocType を拡張する |
1) フォームスクリプトを作成する
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) ダイアログとプロンプトを作成する
// 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) サーバーを呼び出す
// 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) リストビューをカスタマイズする
// 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) リアルタイムイベントを使用する
// 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) フックでスクリプトを挿入する
変更を加えずに別のアプリの DocType を拡張するには、次のようにします。
# 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
7) ナビゲーションとルーティング
// 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();
検証
- フォームスクリプトが JS コンソールエラーなしで読み込まれる
- カスタムボタンが正しい条件で表示される
- フィールドの表示切り替えが機能する
- Link フィールドのフィルターが正しい選択肢を返す
- 検証によって無効な保存が防止される
- リストビューのインジケーターが正しく表示される
- ダイアログが開き、入力を受け取り、送信する
失敗パターン/デバッグ
- スクリプトが読み込まれない:ファイルパスが DocType と一致しているか確認し、
bench buildを実行します - ボタンが表示されない:
refreshの条件ロジックを確認し、frm.doc.docstatusを検証します - イベントが発火しない:イベント名が完全に一致しているか(大文字と小文字を区別)確認します
- フックスクリプトが無視される:
hooks.pyのパスを確認し、アセットを再ビルドします frappe.callが失敗する:メソッドのパスを確認し、サーバー側で@frappe.whitelist()が指定されているか検証します
エスカレーション
- サーバー側のコントローラーロジックについては →
frappe-doctype-development - RPC エンドポイントの実装については →
frappe-api-development - Frappe UI(Vue 3)フロントエンドについては →
frappe-frontend-development
参考資料
- references/desk.md — Desk UI のビューとスクリプト
- references/js-api.md — JavaScript クライアント API リファレンス
ガードレール
docを直接使わずfrm.docを使用する:一貫性とリアクティビティを保つため、常にfrm.doc経由でドキュメントにアクセスします- 保存前に検証する:
before_saveではなく、validateイベントでfrm.validate()を使用します - 非同期処理を考慮する:
frappe.call()は非同期です。順次処理にはコールバックまたは async/await を使用します - フィールド変更後に更新する:プログラムで変更した後は、
frm.refresh_field()またはfrm.refresh_fields()を呼び出します - 適切に
frm.is_new()を確認する:保存済みのドキュメントでのみ意味のある操作があります
よくある間違い
| 間違い | 失敗する理由 | 修正方法 |
|---|---|---|
set_value 後に frm.refresh_field() を呼び出さない | UI が更新されない | frm.set_value() の後に frm.refresh_field('fieldname') を呼び出します |
| イベントフック名が間違っている | イベントが発火しない | refresh、validate、onload、before_save など、正確な名前を使用します |
| 同期呼び出しで UI をブロックする | ページがフリーズする | async: true(デフォルト)で frappe.call() を使用します |
frm ではなく cur_frm を使用する | ダイアログ/複数フォームで動作しなくなる | ハンドラーに渡される frm パラメーターを常に使用します |
frm.doc.docstatus を確認しない | 送信済みドキュメントにもボタンが表示される | 編集アクションを表示する前に frm.doc.docstatus == 0 を確認します |
console.log(frm.doc) に古いデータが表示される | デバッグ時に混乱する | frm.reload_doc() を使用するか、ネットワーク応答を確認します |
コメント
Markdown 対応