mcpField
D
desk-customization

desk-customization

github.com/lubusin開発69110 回インストール12 週間前に更新
リポジトリ

desk-customization について

Frappe 開発者向けの Desk UI カスタマイズスキルです。フォームスクリプト、リストビュー、ダイアログ、クライアント API を扱い、アプリスクリプト、サイト固有の Client Script、hooks で UI を拡張できます。

主な機能

  1. 01フォームボタンと独自アクションの追加
  2. 02Link フィールドの絞り込みと表示切り替え
  3. 03対話型ダイアログとプロンプトの作成
  4. 04リストのインジケーターと一括操作のカスタマイズ
  5. 05hooks によるスクリプト注入とサーバーメソッド呼び出し

活用シーン

  1. 01条件に応じたフォーム操作の表示
  2. 02他フィールドに応じた Link フィールド候補の絞り込み
  3. 03DocType の保存前検証の追加
  4. 04別アプリの DocType UI の拡張

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/lubusin/frappe-skills --skill desk-customization

インストール後、Claude Code で/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 ScriptDocType: 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() が設定されているか検証します

エスカレーション

  • サーバー側のコントローラーロジックについては → doctype-development
  • RPC エンドポイントの実装については → api-development
  • Frappe UI(Vue 3)フロントエンドについては → frontend-development

参考資料

ガードレール

  • 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() を使用するか、ネットワーク応答を確認します

コメント