| name | frappe-client-script-generator |
| description | Generate JavaScript client-side form scripts for Frappe DocTypes. Use when creating form customizations, field validations, custom buttons, or client-side logic for Frappe/ERPNext forms. |
Frappe Client Script Generator
Generate production-ready JavaScript form scripts for Frappe DocTypes with proper event handlers, validations, and custom functionality.
Global Rules
These Frappe conventions apply to everything this skill generates, and override any conflicting example below.
- Bench commands: use bare
bench (never ./env/bin/bench or a full path). Always pass --site <site> explicitly — never run a bare bench migrate / bench run-tests. Run bench start in the background and only if it isn't already running. Don't run discovery commands (which bench, bench --version).
- DocType files live at
apps/<app>/<app>/<module>/doctype/<name>/<name>.json — the app name appears twice (directory + Python package) — with an empty __init__.py alongside. Never mkdir the folder; write the JSON and run bench --site <site> migrate to create the structure. Don't add creation, modified, owner, modified_by, or docstatus as fields — Frappe manages them.
- Database & ORM: prefer
frappe.qb.get_query() over raw frappe.db.sql(). Use frappe.db.get_all() for server logic (ignores permissions) and frappe.db.get_list() for user-facing APIs (enforces them). Never use frappe.db.set_value() on a field with validation or lifecycle logic — load the doc and doc.save() so controller hooks run. Batch-fetch related records; never query inside a loop (N+1).
- Never call
frappe.db.commit() in controllers, request handlers, background jobs, or patches — Frappe auto-commits on success and rolls back on uncaught errors. Flush manually only to make a write visible to a subsequent frappe.enqueue() (or pass enqueue_after_commit=True).
- Permissions & APIs: put permission checks inside controller methods (enforced on every call path), not in API wrappers. Type-hint every
@frappe.whitelist() parameter so Frappe validates and casts it, and pass methods=[...] to pin the HTTP verb.
When to Use This Skill
Claude should invoke this skill when:
- User wants to add client-side form customizations
- User needs field validations or calculations
- User requests custom buttons or actions on forms
- User wants to filter or fetch data dynamically
- User mentions form scripts, client scripts, or JavaScript for DocTypes
- User wants to show/hide fields conditionally
- User needs to set field values based on other fields
Capabilities
1. Form Event Handlers
Generate event handlers for DocType forms following Frappe patterns from core apps.
Refresh Event (runs when form loads):
frappe.ui.form.on('Sales Invoice', {
refresh: function(frm) {
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__('Create Payment'), function() {
frm.events.make_payment_entry(frm);
});
}
frm.set_df_property('customer', 'reqd', 1);
frm.toggle_display('discount_section', frm.doc.apply_discount);
}
});
Setup Event (runs once when form is created):
frappe.ui.form.on('Stock Entry', {
setup: function(frm) {
frm.set_query('item_code', 'items', function() {
return {
filters: {
'is_stock_item': 1,
'has_serial_no': 0
}
};
});
}
});
Onload Event (runs on form load, before refresh):
frappe.ui.form.on('Payment Entry', {
onload: function(frm) {
if (frm.is_new()) {
frm.set_value('posting_date', frappe.datetime.get_today());
}
}
});
2. Field Change Handlers
Single Field Change:
frappe.ui.form.on('Sales Order', {
customer: function(frm) {
if (frm.doc.customer) {
frappe.db.get_value('Customer', frm.doc.customer, 'customer_group')
.then(r => {
if (r.message) {
frm.set_value('customer_group', r.message.customer_group);
}
});
}
}
});
Multiple Field Dependencies:
frappe.ui.form.on('Sales Invoice', {
customer: function(frm) {
frm.events.set_dynamic_field_label(frm);
},
currency: function(frm) {
frm.events.set_dynamic_field_label(frm);
},
set_dynamic_field_label: function(frm) {
if (frm.doc.currency) {
frm.set_currency_labels(['total', 'grand_total'], frm.doc.currency);
}
}
});
3. Child Table (Grid) Events
Child Table Row Events:
frappe.ui.form.on('Sales Invoice Item', {
item_code: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.item_code) {
frappe.call({
method: 'erpnext.stock.get_item_details.get_item_details',
args: {
item_code: row.item_code,
company: frm.doc.company
},
callback: function(r) {
if (r.message) {
frappe.model.set_value(cdt, cdn, 'rate', r.message.price_list_rate);
frappe.model.set_value(cdt, cdn, 'uom', r.message.stock_uom);
}
}
});
}
},
qty: function(frm, cdt, cdn) {
frm.events.calculate_totals(frm, cdt, cdn);
},
rate: function(frm, cdt, cdn) {
frm.events.calculate_totals(frm, cdt, cdn);
}
});
Grid Operations:
frappe.ui.form.on('Stock Entry', {
items_add: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
row.s_warehouse = frm.doc.from_warehouse;
row.t_warehouse = frm.doc.to_warehouse;
},
items_remove: function(frm) {
frm.events.calculate_totals(frm);
}
});
4. Custom Buttons and Actions
Standard Button Patterns:
frappe.ui.form.on('Sales Invoice', {
refresh: function(frm) {
if (frm.doc.docstatus === 1 && frm.doc.outstanding_amount > 0) {
frm.add_custom_button(__('Payment'), function() {
frm.events.make_payment_entry(frm);
}, __('Create'));
}
if (frm.doc.docstatus === 0) {
frm.add_custom_button(__('Get Items from Sales Order'), function() {
erpnext.utils.map_current_doc({
method: 'erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice',
source_doctype: 'Sales Order',
target: frm,
setters: {
customer: frm.doc.customer || undefined
},
get_query_filters: {
docstatus: 1,
status: ['not in', ['Closed', 'On Hold']]
}
});
});
}
},
make_payment_entry: function(frm) {
return frappe.call({
method: 'erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry',
args: {
dt: frm.doc.doctype,
dn: frm.doc.name
},
callback: function(r) {
let doc = frappe.model.sync(r.message);
frappe.set_route('Form', doc[0].doctype, doc[0].name);
}
});
}
});
5. Data Fetching and API Calls
Two ways to reach the server. Use frm.call("<method>") to invoke a @frappe.whitelist() method on the current DocType controller (Frappe passes the document automatically). Use frappe.call({ method, args }) for a standalone whitelisted module function. In both cases the args keys and values must match the method's type-hinted parameter signature — Frappe validates and casts each argument against those hints, so a mismatch will raise.
frm.call("get_summary").then(r => frappe.msgprint(r.message));
frappe.call({
method: 'my_app.api.get_expenses',
args: { status: 'Draft' },
callback: r => console.log(r.message)
});
Fetch from Database:
frappe.ui.form.on('Item', {
item_group: function(frm) {
if (frm.doc.item_group) {
frappe.db.get_value('Item Group', frm.doc.item_group, 'default_warehouse')
.then(r => {
if (r.message && r.message.default_warehouse) {
frm.set_value('default_warehouse', r.message.default_warehouse);
}
});
}
}
});
Server Method Calls:
frappe.ui.form.on('Payment Entry', {
party: function(frm) {
if (frm.doc.party_type && frm.doc.party) {
frappe.call({
method: 'erpnext.accounts.party.get_party_details',
args: {
party: frm.doc.party,
party_type: frm.doc.party_type,
company: frm.doc.company
},
callback: function(r) {
if (r.message) {
frm.set_value('party_name', r.message.party_name);
frm.set_value('party_account', r.message.party_account);
}
}
});
}
}
});
6. Form Validations
Client-side validation is UX only. It gives the user immediate feedback but can be bypassed (API calls, scripts, imports). The authoritative validation must live in the Python controller's validate() method. Mirror critical rules in both places, but never rely on the client script alone to enforce data integrity.
Before Save Validation:
frappe.ui.form.on('Sales Invoice', {
validate: function(frm) {
if (frm.doc.posting_date > frappe.datetime.get_today()) {
frappe.throw(__('Posting Date cannot be future date'));
}
if (!frm.doc.items || frm.doc.items.length === 0) {
frappe.throw(__('Please add at least one item'));
}
if (frm.doc.grand_total <= 0) {
frappe.throw(__('Grand Total must be greater than 0'));
}
}
});
Before Submit Validation:
frappe.ui.form.on('Stock Entry', {
before_submit: function(frm) {
let has_qty = false;
frm.doc.items.forEach(function(item) {
if (item.qty > 0) {
has_qty = true;
}
});
if (!has_qty) {
frappe.throw(__('Please enter quantity for at least one item'));
}
}
});
7. Conditional Field Display
Show/Hide Fields:
frappe.ui.form.on('Payment Entry', {
payment_type: function(frm) {
frm.events.toggle_fields(frm);
},
toggle_fields: function(frm) {
let is_receive = (frm.doc.payment_type === 'Receive');
let is_pay = (frm.doc.payment_type === 'Pay');
frm.toggle_display('paid_from', is_pay);
frm.toggle_display('paid_to', is_receive);
frm.toggle_reqd('paid_from', is_pay);
frm.toggle_reqd('paid_to', is_receive);
}
});
Field Property Changes:
frappe.ui.form.on('Sales Order', {
refresh: function(frm) {
frm.set_df_property('customer', 'read_only', frm.doc.docstatus === 1);
frm.set_df_property('delivery_date', 'label',
frm.doc.order_type === 'Sales' ? __('Delivery Date') : __('Delivery By'));
frm.toggle_reqd('delivery_date', frm.doc.order_type === 'Sales');
}
});
8. Calculations and Totals
Calculate Child Table Totals:
frappe.ui.form.on('Sales Invoice', {
calculate_totals: function(frm) {
let total = 0;
frm.doc.items.forEach(function(item) {
item.amount = flt(item.qty) * flt(item.rate);
total += item.amount;
});
frm.set_value('total', total);
let tax_amount = flt(total * frm.doc.tax_rate / 100);
frm.set_value('total_taxes_and_charges', tax_amount);
frm.set_value('grand_total', total + tax_amount);
}
});
frappe.ui.form.on('Sales Invoice Item', {
qty: function(frm, cdt, cdn) {
let item = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, 'amount',
flt(item.qty) * flt(item.rate));
frm.events.calculate_totals(frm);
},
rate: function(frm, cdt, cdn) {
let item = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, 'amount',
flt(item.qty) * flt(item.rate));
frm.events.calculate_totals(frm);
}
});
9. Link Field Filters (set_query)
Filter Link Field Options:
frappe.ui.form.on('Stock Entry', {
setup: function(frm) {
frm.set_query('item_code', 'items', function(doc, cdt, cdn) {
return {
filters: {
'item_group': ['in', ['Raw Material', 'Sub Assemblies']],
'is_stock_item': 1
}
};
});
frm.set_query('warehouse', function() {
return {
filters: {
'company': frm.doc.company,
'is_group': 0
}
};
});
}
});
Complex Query Filters:
frappe.ui.form.on('Payment Entry', {
setup: function(frm) {
frm.set_query('party', function() {
let party_type = frm.doc.party_type;
if (party_type === 'Customer') {
return {query: 'erpnext.controllers.queries.customer_query'};
} else if (party_type === 'Supplier') {
return {query: 'erpnext.controllers.queries.supplier_query'};
}
});
frm.set_query('reference_doctype', 'references', function() {
let doctypes = [];
if (frm.doc.party_type === 'Customer') {
doctypes = ['Sales Invoice', 'Sales Order'];
} else if (frm.doc.party_type === 'Supplier') {
doctypes = ['Purchase Invoice', 'Purchase Order'];
}
return {
filters: {
'name': ['in', doctypes]
}
};
});
}
});
10. Dialogs and Prompts
Create Custom Dialog:
frappe.ui.form.on('Stock Entry', {
get_items: function(frm) {
let dialog = new frappe.ui.Dialog({
title: __('Get Items'),
fields: [
{
fieldtype: 'Link',
label: __('Warehouse'),
fieldname: 'warehouse',
options: 'Warehouse',
reqd: 1,
get_query: function() {
return {
filters: {
'company': frm.doc.company
}
};
}
},
{
fieldtype: 'Link',
label: __('Item Group'),
fieldname: 'item_group',
options: 'Item Group'
}
],
primary_action_label: __('Get Items'),
primary_action: function(values) {
frappe.call({
method: 'get_items',
doc: frm.doc,
args: values,
callback: function(r) {
dialog.hide();
frm.refresh_field('items');
}
});
}
});
dialog.show();
}
});
References
Frappe Core Client Script Examples (Primary Reference)
Learn from Frappe Framework:
ERPNext Client Script Examples:
Official Documentation (Secondary Reference)
Best Practices
- Event Handler Organization: Group related handlers together
- Reusable Functions: Extract common logic into reusable methods
- Null Checks: Always validate data before using it
- Async Operations: Use callbacks for database and API calls
- User Feedback: Use
frappe.show_alert() for success/error messages
- Performance: Debounce expensive operations in change handlers
- Translation: Use
__() for translatable strings
- Error Handling: Wrap risky operations in try-catch
- Child Tables: Use
locals[cdt][cdn] to access child table rows
- Field Updates: Use
frappe.model.set_value() for child table fields
File Output Format
Generated client scripts should be saved at (the app name appears twice — directory + Python package):
apps/<app>/<app>/<module>/doctype/<doctype_name>/<doctype_name>.js
Always include:
- Clear comments explaining functionality
- Proper indentation (4 spaces or tab as per project)
- Event handler grouping
- Error handling where appropriate
- Translation wrappers for user-facing strings
Common Patterns Summary
- refresh: Add buttons, set field properties
- setup: One-time setup, set query filters
- onload: Initialize values for new docs
- validate: Pre-save validations
- before_submit: Pre-submission checks
- field_name: Handle field changes
- child_table_field: Handle child table field changes
- items_add/remove: Handle row additions/removals