
Frappe Syntax Clientscripts
- 58 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Write Frappe client-side JavaScript for form events, field validation, server calls, and child tables using frappe.ui.form.on and frm methods.
About
Provides exact client-side JavaScript syntax for Frappe form events, field manipulation, and server calls. A developer uses it when writing Client Scripts for ERPNext/Frappe forms.
- Exact syntax for frappe.ui.form.on and frm methods
- Covers frappe.call, field validation, and child table handling
Frappe Syntax Clientscripts by the numbers
- 58 all-time installs (skills.sh)
- Ranked #1,226 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-syntax-clientscriptsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Write Frappe client-side JavaScript for form events, field validation, server calls, and child tables using frappe.ui.form.on and frm methods.
Files
Frappe Client Scripts Syntax
Client Scripts run in the browser and control all UI interactions in Frappe/ERPNext. Create them via Setup > Client Script or in custom apps under public/js/.
CRITICAL: Client Script validations ONLY apply in the browser form view. API calls and System Console bypass them. ALWAYS pair with Server Scripts for security-critical validation.
Quick Reference
| Action | Code |
|---|---|
| Set value | frm.set_value('field', value) |
| Get value | frm.doc.fieldname |
| Hide field | frm.toggle_display('field', false) |
| Make mandatory | frm.toggle_reqd('field', true) |
| Make read-only | frm.toggle_enable('field', false) |
| Set field property | frm.set_df_property('field', 'options', [...]) |
| Filter Link field | frm.set_query('field', () => ({filters: {}})) |
| Call server | frappe.call({method: 'path.to.fn', args: {}}) |
| Call doc method | frm.call('method_name', {args}) |
| Prevent save | frappe.throw(__('Error message')) |
| Add button | frm.add_custom_button(__('Label'), callback, group) |
| Add child row | frm.add_child('table', {values}); frm.refresh_field('table') |
| Show alert | frappe.show_alert({message: __('Done'), indicator: 'green'}) |
| Translate string | __('Text') or __('Hello {0}', [name]) |
Event Decision Tree
What do you need to do?
│
├─ One-time setup (queries, formatters)?
│ └─ ALWAYS use setup — runs once per form instance
│
├─ Show/hide fields, add buttons, update UI?
│ └─ ALWAYS use refresh — fires after every load/reload
│
├─ Validate data before save?
│ └─ ALWAYS use validate — use frappe.throw() to block save
│
├─ Modify data right before server save?
│ └─ Use before_save — last chance to change values
│
├─ Run logic after successful save?
│ └─ Use after_save — document is persisted
│
├─ React to a field value change?
│ └─ Use the fieldname as the event name
│
├─ Intercept workflow state change?
│ └─ Use before_workflow_action / after_workflow_action
│
└─ Manipulate DOM after full render?
└─ Use onload_post_render — NEVER use jQuery selectors directlySee references/events.md for complete event list and execution order.
Form Event Registration
// Parent form events
frappe.ui.form.on('Sales Order', {
setup(frm) { }, // Once per form instance
refresh(frm) { }, // After every load/reload
validate(frm) { }, // Before save — throw to block
fieldname(frm) { } // On field value change
});
// Child table events — ALWAYS register on the CHILD doctype
frappe.ui.form.on('Sales Order Item', {
qty(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, 'amount', row.qty * row.rate);
},
items_add(frm, cdt, cdn) { }, // Row added
items_remove(frm) { }, // Row removed (no cdt/cdn)
items_move(frm) { } // Row reordered
});Value Manipulation
// ALWAYS use frm.set_value() — NEVER assign frm.doc.field directly
frm.set_value('status', 'Approved'); // Single
frm.set_value({status: 'Approved', priority: 'High'}); // Multiple
// Read values (read-only — NEVER write via frm.doc)
let val = frm.doc.fieldname;
let items = frm.doc.items; // Child table arrayField Properties
// Show/hide (accepts single field or array)
frm.toggle_display(['priority', 'due_date'], frm.doc.status === 'Open');
// Mandatory toggle
frm.toggle_reqd('due_date', true);
// Read-only toggle
frm.toggle_enable('amount', false); // false = read-only
// Arbitrary property change
frm.set_df_property('status', 'options', ['New', 'Open', 'Closed']);
frm.set_df_property('amount', 'read_only', 1);
frm.set_df_property('notes', 'label', 'Internal Notes');
// Intro message at form top
frm.set_intro('This document is pending review', 'orange');Link Field Filters
// ALWAYS set queries in setup event — NEVER in refresh
frappe.ui.form.on('Sales Order', {
setup(frm) {
// Simple filter
frm.set_query('customer', () => ({
filters: { disabled: 0 }
}));
// Child table filter
frm.set_query('item_code', 'items', (doc, cdt, cdn) => {
let row = locals[cdt][cdn];
return { filters: { is_sales_item: 1 } };
});
// Server-side query for complex logic
frm.set_query('customer', () => ({
query: 'myapp.queries.get_filtered_customers',
filters: { region: frm.doc.region }
}));
}
});Server Communication
// frappe.call — whitelisted Python method
let r = await frappe.call({
method: 'myapp.api.process_data',
args: { customer: frm.doc.customer },
freeze: true,
freeze_message: __('Processing...')
});
if (r.message) { /* use r.message */ }
// frm.call — document controller method
let result = await frm.call('calculate_taxes', { include_shipping: true });
// frappe.db shortcuts
let val = await frappe.db.get_value('Customer', name, 'credit_limit');
let list = await frappe.db.get_list('Sales Order', {
filters: { customer: frm.doc.customer },
fields: ['name', 'grand_total'],
order_by: 'creation desc',
limit: 10
});Child Table Operations
// Add row — ALWAYS call refresh_field after
let row = frm.add_child('items', { item_code: 'ITEM-001', qty: 5 });
frm.refresh_field('items');
// Clear all rows
frm.clear_table('items');
frm.refresh_field('items');
// Modify existing rows — refresh_field ONCE after loop
frm.doc.items.forEach(row => {
row.discount = row.qty > 10 ? 5 : 0;
});
frm.refresh_field('items');
// Set child row value (inside child event handler)
frappe.model.set_value(cdt, cdn, 'amount', row.qty * row.rate);
// Mark form dirty after programmatic changes
frm.dirty();Custom Buttons
refresh(frm) {
if (frm.doc.docstatus === 1) {
// Grouped dropdown
frm.add_custom_button(__('Invoice'), () => {
frappe.model.open_mapped_doc({
method: 'erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice',
frm: frm
});
}, __('Create'));
// Primary action
frm.page.set_primary_action(__('Process'), () => {
frm.call('process').then(() => frm.reload_doc());
});
}
// ALWAYS guard buttons with state checks
if (!frm.is_new() && frm.doc.docstatus === 0) {
frm.add_custom_button(__('Validate'), () => { /* ... */ });
}
}List View Customization
frappe.listview_settings['Task'] = {
add_fields: ['status', 'priority'],
filters: [['status', '!=', 'Cancelled']],
hide_name_column: true,
get_indicator(doc) {
// ALWAYS return [label, color, filter_field + ',' + filter_value]
if (doc.status === 'Open') return [__('Open'), 'orange', 'status,=,Open'];
if (doc.status === 'Closed') return [__('Closed'), 'green', 'status,=,Closed'];
},
button: {
show(doc) { return doc.status === 'Open'; },
get_label() { return __('Close'); },
action(doc) { frappe.call({method: 'myapp.api.close', args: {name: doc.name}}); }
},
formatters: {
priority(val) { return val === 'High' ? `<b>${val}</b>` : val; }
},
onload(listview) { /* runs once */ },
refresh(listview) { /* runs on every refresh */ }
};Dialogs and Prompts
// Quick prompt
frappe.prompt({label: 'Reason', fieldname: 'reason', fieldtype: 'Data'},
(values) => { console.log(values.reason); },
__('Enter Reason')
);
// Full dialog
let d = new frappe.ui.Dialog({
title: __('Enter Details'),
fields: [
{label: 'Name', fieldname: 'name', fieldtype: 'Data', reqd: 1},
{label: 'Date', fieldname: 'date', fieldtype: 'Date'}
],
size: 'small',
primary_action_label: __('Submit'),
primary_action(values) { d.hide(); /* use values */ }
});
d.show();
// Progress indicator
frappe.show_progress(__('Importing'), 45, 100, __('Please wait'));Critical Rules
1. ALWAYS call frm.refresh_field('table') after ANY child table modification 2. NEVER assign frm.doc.field = value — ALWAYS use frm.set_value() 3. ALWAYS use __('text') for every user-facing string 4. ALWAYS place set_query in setup — NEVER in refresh 5. NEVER use async: false — it freezes the browser 6. ALWAYS check frm.is_new() before adding action buttons 7. NEVER use direct jQuery selectors for field manipulation — use Frappe API 8. NEVER store state in global variables — attach to frm object instead 9. ALWAYS check r.message before using server call responses 10. ALWAYS use frappe.throw() inside validate to block save — NEVER return false in async handlers
See references/methods.md for complete API reference.
See references/examples.md for real-world patterns.
See references/anti-patterns.md for common mistakes.
Related Skills
frappe-impl-clientscripts— Implementation workflows and decision treesfrappe-errors-clientscripts— Error handling and debugging patternsfrappe-syntax-whitelisted— Server-side methods called from client scriptsfrappe-syntax-doctypes— DocType field definitions referenced in scripts
Client Script Anti-Patterns
AP-01: Direct Field Value Assignment
WRONG:
frm.doc.customer_name = 'Test'; // NEVER do thisCORRECT:
frm.set_value('customer_name', 'Test');Why: frm.set_value() triggers the dirty flag, field change events, dependent field updates, and UI refresh. Direct assignment skips all of these, leading to stale UI and lost data.
---
AP-02: Child Table Modification Without refresh_field
WRONG:
let row = frm.add_child('items', { item_code: 'TEST' });
// UI does NOT show the new rowCORRECT:
let row = frm.add_child('items', { item_code: 'TEST' });
frm.refresh_field('items'); // ALWAYS requiredWhy: Child table UI is not automatically synchronized. ALWAYS call frm.refresh_field() after add_child, clear_table, or direct row modifications.
---
AP-03: set_query in refresh Instead of setup
WRONG:
frappe.ui.form.on('Sales Order', {
refresh(frm) {
frm.set_query('customer', () => ({ // Runs on EVERY refresh
filters: { disabled: 0 }
}));
}
});CORRECT:
frappe.ui.form.on('Sales Order', {
setup(frm) {
frm.set_query('customer', () => ({ // Runs ONCE
filters: { disabled: 0 }
}));
}
});Why: setup runs once per form instance. refresh fires on every load, reload, and save. Queries set in refresh are redundantly re-registered. The callback function inside set_query already reads frm.doc dynamically at execution time.
---
AP-04: Synchronous Server Calls
WRONG:
frappe.call({
method: 'myapp.api.get_data',
async: false, // NEVER use async: false
callback: (r) => { /* ... */ }
});CORRECT:
let r = await frappe.call({
method: 'myapp.api.get_data'
});
// Or use callback pattern (still async)Why: async: false blocks the entire browser thread. The UI freezes, the user cannot interact, and browser may show "page unresponsive" warnings. ALWAYS use async patterns.
---
AP-05: Hardcoded Strings Without Translation
WRONG:
frappe.msgprint('Operation completed');
frm.add_custom_button('Generate Report', () => {});CORRECT:
frappe.msgprint(__('Operation completed'));
frm.add_custom_button(__('Generate Report'), () => {});Why: Without __() wrapper, strings are not translatable. ALWAYS wrap every user-facing string in __().
---
AP-06: Callback Hell in Server Calls
WRONG:
frappe.call({
method: 'method1',
callback: (r1) => {
frappe.call({
method: 'method2',
callback: (r2) => {
frappe.call({
method: 'method3',
callback: (r3) => { /* deeply nested */ }
});
}
});
}
});CORRECT:
async function processData() {
let r1 = await frappe.call({ method: 'method1' });
let r2 = await frappe.call({ method: 'method2' });
let r3 = await frappe.call({ method: 'method3' });
}
// Or for independent calls — use Promise.all
let [r1, r2, r3] = await Promise.all([
frappe.call({ method: 'method1' }),
frappe.call({ method: 'method2' }),
frappe.call({ method: 'method3' })
]);---
AP-07: No Error Handling on Server Calls
WRONG:
frappe.call({
method: 'myapp.api.risky_operation',
callback: (r) => {
frm.set_value('result', r.message); // Crashes if r.message is undefined
}
});CORRECT:
frappe.call({
method: 'myapp.api.risky_operation',
callback: (r) => {
if (r.message) {
frm.set_value('result', r.message);
}
},
error: (r) => {
frappe.msgprint(__('Operation failed'));
}
});Why: Server calls can fail due to permissions, validation errors, or network issues. ALWAYS check r.message before use and provide an error handler.
---
AP-08: Non-Awaited frappe.call in validate Event
WRONG:
frappe.ui.form.on('Sales Order', {
validate(frm) {
frappe.call({
method: 'myapp.api.check_credit',
callback: (r) => {
if (!r.message.ok) {
frappe.throw(__('Credit exceeded')); // TOO LATE — save already proceeded
}
}
});
}
});CORRECT:
frappe.ui.form.on('Sales Order', {
async validate(frm) {
let r = await frappe.call({
method: 'myapp.api.check_credit',
args: { customer: frm.doc.customer }
});
if (!r.message.ok) {
frappe.throw(__('Credit exceeded')); // Blocks save correctly
}
}
});Why: Without await, the validate function returns immediately and the save proceeds. The callback fires after the save is already in progress. ALWAYS use async/await when validate needs server data.
---
AP-09: refresh_field Inside Loops
WRONG:
frm.doc.items.forEach(item => {
item.amount = item.qty * item.rate;
frm.refresh_field('items'); // Called N times — performance disaster
});CORRECT:
frm.doc.items.forEach(item => {
item.amount = item.qty * item.rate;
});
frm.refresh_field('items'); // Called ONCE after all modifications---
AP-10: Global Variables for Form State
WRONG:
var current_customer = null; // Global scope
frappe.ui.form.on('Sales Order', {
customer(frm) {
current_customer = frm.doc.customer;
}
});CORRECT:
frappe.ui.form.on('Sales Order', {
customer(frm) {
frm._customer_cache = {}; // Attach to frm object
}
});Why: Global variables are shared across all open form tabs. If two Sales Orders are open, they overwrite each other's state. ALWAYS attach state to the frm object.
---
AP-11: Direct DOM/jQuery Manipulation
WRONG:
$('[data-fieldname="customer"]').hide();
$('.form-group[data-fieldname="amount"] input').prop('disabled', true);CORRECT:
frm.toggle_display('customer', false);
frm.toggle_enable('amount', false);Why: Direct DOM manipulation bypasses Frappe's rendering cycle. Frappe may overwrite your changes on the next refresh. ALWAYS use Frappe API methods.
---
AP-12: Blocking Loops for Server Calls
WRONG:
frm.doc.items.forEach(item => {
frappe.call({
method: 'myapp.api.process_item',
args: { item: item.name },
async: false // Blocks for EVERY item
});
});CORRECT:
// Option 1: Batch call (preferred — single server round trip)
await frappe.call({
method: 'myapp.api.process_items',
args: { items: frm.doc.items.map(i => i.name) }
});
// Option 2: Parallel execution
await Promise.all(frm.doc.items.map(item =>
frappe.call({
method: 'myapp.api.process_item',
args: { item: item.name }
})
));Why: ALWAYS prefer a single batch call. If batch is not possible, use Promise.all for parallel execution. NEVER use async: false in a loop.
---
AP-13: Buttons Without State Guards
WRONG:
frappe.ui.form.on('Sales Order', {
refresh(frm) {
frm.add_custom_button(__('Process'), () => {
frm.call('process'); // Fails on new/cancelled documents
});
}
});CORRECT:
frappe.ui.form.on('Sales Order', {
refresh(frm) {
if (!frm.is_new() && frm.doc.docstatus === 0) {
frm.add_custom_button(__('Process'), () => {
frm.call('process');
});
}
}
});Why: Buttons appear on every document state (new, draft, submitted, cancelled). ALWAYS check frm.is_new(), frm.doc.docstatus, and relevant field values before adding action buttons.
---
AP-14: frappe.model.set_value Outside Child Context
WRONG:
// In parent form event — cdt/cdn are not available
frappe.model.set_value(cdt, cdn, 'qty', 10);CORRECT:
// In child table event — cdt/cdn are parameters
frappe.ui.form.on('Sales Order Item', {
item_code(frm, cdt, cdn) {
frappe.model.set_value(cdt, cdn, 'qty', 10);
}
});
// From parent context — use direct access + refresh
frm.doc.items[0].qty = 10;
frm.refresh_field('items');---
Pre-Deployment Checklist
ALWAYS verify before deploying a client script:
- [ ] All user-facing strings wrapped in
__() - [ ] No
async: falseanywhere - [ ]
refresh_field()called after every child table modification - [ ]
refresh_field()called ONCE after loops, not inside loops - [ ] Error handling on all server calls (
if (r.message)+ error callback) - [ ]
frm.is_new()/docstatuschecks before action buttons - [ ]
set_queryplaced insetup, notrefresh - [ ] No global state variables — state attached to
frmobject - [ ] No direct DOM/jQuery manipulation — Frappe API used
- [ ]
validateevent usesasync/awaitfor any server calls - [ ] No
frm.doc.field = value— onlyfrm.set_value()
Client Script Events Reference
Event Execution Order
On Form Load (new or existing document)
setup → onload → refresh → onload_post_renderOn Save (new document)
validate → before_save → [server save] → after_save → refreshOn Save (existing document)
validate → before_save → [server save] → after_save → refreshOn Submit (docstatus 0 → 1)
validate → before_submit → [server submit] → on_submit → refreshOn Cancel (docstatus 1 → 2)
before_cancel → [server cancel] → after_cancel → refreshOn Amend (creates new doc from cancelled)
after_cancel → [new doc created] → setup → onload → refreshOn Field Change
{fieldname} handler → dependent field handlers (if any)On frm.refresh() / frm.reload_doc()
before_load → onload → refresh → onload_post_renderComplete Form Event Reference
| Event | Fires When | Parameters | Typical Usage |
|---|---|---|---|
setup | Once per form instance creation | (frm) | set_query, formatters, one-time config |
onload | Form data loaded, before render | (frm) | Data pre-processing, defaults |
refresh | After every load, reload, save | (frm) | Buttons, visibility, UI updates |
onload_post_render | DOM fully rendered | (frm) | DOM-dependent operations |
validate | Before save/submit | (frm) | Validation — frappe.throw() blocks save |
before_save | After validate, before server call | (frm) | Last-minute value changes |
after_save | After successful server save | (frm) | Notifications, cleanup |
before_submit | Before document submission | (frm) | Pre-submit validation |
on_submit | After successful submission | (frm) | Post-submit actions |
before_cancel | Before cancellation | (frm) | Pre-cancel checks |
after_cancel | After successful cancellation | (frm) | Cleanup, notifications |
before_workflow_action | Before workflow state change | (frm) | Workflow interception |
after_workflow_action | After workflow state change | (frm) | Post-workflow actions |
timeline_refresh | After timeline section render | (frm) | Timeline customization |
Field Change Events
ALWAYS use the exact fieldname as the event name:
frappe.ui.form.on('Sales Invoice', {
// Fires when 'customer' field value changes
customer(frm) {
if (frm.doc.customer) {
// Fetch related data
}
},
// Fires when 'posting_date' field value changes
posting_date(frm) {
// Recalculate due date
}
});CRITICAL: Field change events fire when:
- User changes the value in the UI
frm.set_value()is called programmaticallyfrappe.model.set_value()is called on a child row field
They do NOT fire when:
frm.doc.field = valueis assigned directly (which is why you NEVER do this)
Child Table Events
ALWAYS register child table events on the CHILD DocType name, not the parent:
// CORRECT — register on child doctype 'Sales Order Item'
frappe.ui.form.on('Sales Order Item', {
// Field change in child row
qty(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, 'amount', row.qty * row.rate);
},
// Row added to 'items' table
items_add(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
// Set defaults for new row
frappe.model.set_value(cdt, cdn, 'warehouse', frm.doc.set_warehouse);
},
// Row removed from 'items' table
items_remove(frm) {
// Recalculate totals — no cdt/cdn available
calculate_totals(frm);
},
// Row moved (drag & drop reorder)
items_move(frm) {
// Update idx or recalculate
},
// Before row removal [v14+]
items_before_remove(frm, cdt, cdn) {
// Return false to prevent removal
}
});Child Table Event Naming Convention
Format: {tablefieldname}_{action}
| Event Pattern | Description | Parameters |
|---|---|---|
{table}_add | Row added | (frm, cdt, cdn) |
{table}_remove | Row removed | (frm) |
{table}_move | Row reordered | (frm) |
{table}_before_remove | Before row removal | (frm, cdt, cdn) |
Child Table Event Parameters
frappe.ui.form.on('Sales Order Item', {
qty(frm, cdt, cdn) {
// frm — parent form object (Sales Order)
// cdt — child doctype name ('Sales Order Item')
// cdn — child row name/ID (e.g., 'abc123def4')
// Get the row data object
let row = frappe.get_doc(cdt, cdn);
// Or equivalently:
let row2 = locals[cdt][cdn];
}
});setup vs refresh — When to Use Which
| Aspect | setup | refresh |
|---|---|---|
| Frequency | Once per form instance | On every load, reload, save |
| Timing | Before data is loaded | After data is loaded and rendered |
| Use for | set_query, formatters, one-time config | Buttons, field visibility, dynamic UI |
| frm.doc available? | Partially (may be empty on new) | Yes, fully populated |
frappe.ui.form.on('Sales Order', {
setup(frm) {
// GOOD: set_query runs once, filter callback reads frm.doc dynamically
frm.set_query('customer', () => ({
filters: { disabled: 0 }
}));
},
refresh(frm) {
// GOOD: buttons depend on document state
if (!frm.is_new() && frm.doc.docstatus === 0) {
frm.add_custom_button(__('Validate'), () => { /* ... */ });
}
}
});Async Events
Events support async/await. This is CRITICAL for validate when you need server-side checks:
frappe.ui.form.on('Sales Order', {
// CORRECT: async validate with await
async validate(frm) {
let r = await frappe.call({
method: 'myapp.api.check_credit',
args: { customer: frm.doc.customer }
});
if (!r.message.approved) {
frappe.throw(__('Credit limit exceeded'));
}
// If no throw, save proceeds
},
// CORRECT: async refresh for data fetching
async refresh(frm) {
if (!frm.is_new()) {
let data = await frappe.call({
method: 'myapp.api.get_dashboard_data',
args: { name: frm.doc.name }
});
// Update UI with data
}
}
});NEVER use a non-awaited frappe.call inside validate — the save will proceed before the callback fires.
Triggering Events Programmatically
// Trigger the refresh event manually
frm.trigger('refresh');
// Trigger a field change event
frm.trigger('customer');This is useful when a field change handler should re-run after you set a value that indirectly affects display logic.
Client Script Examples
Example 1: Complete Form Setup with Filters and Defaults
frappe.ui.form.on('Sales Order', {
setup(frm) {
// ALWAYS place set_query in setup — runs once
frm.set_query('customer', () => ({
filters: { disabled: 0 }
}));
frm.set_query('item_code', 'items', () => ({
filters: { is_sales_item: 1, disabled: 0 }
}));
// Server-side query for complex filtering
frm.set_query('delivery_warehouse', 'items', () => ({
query: 'myapp.queries.get_warehouses_for_company',
filters: { company: frm.doc.company }
}));
},
onload(frm) {
if (frm.is_new()) {
frm.set_value('delivery_date',
frappe.datetime.add_days(frappe.datetime.now_date(), 7));
}
}
});Example 2: Conditional Field Visibility and Mandatory
frappe.ui.form.on('Sales Invoice', {
refresh(frm) {
let has_shipping = frm.doc.shipping_amount > 0;
frm.toggle_display(['shipping_address', 'shipping_method'], has_shipping);
frm.toggle_reqd('shipping_address', has_shipping);
// Status-based read-only
let is_submitted = frm.doc.docstatus === 1;
frm.toggle_enable('customer', !is_submitted);
frm.toggle_enable('posting_date', !is_submitted);
},
shipping_amount(frm) {
// Re-trigger visibility when shipping amount changes
frm.trigger('refresh');
}
});Example 3: Custom Buttons with State Guards
frappe.ui.form.on('Sales Order', {
refresh(frm) {
// ALWAYS guard buttons with document state checks
if (frm.doc.docstatus === 1) {
// Grouped buttons under "Create" dropdown
frm.add_custom_button(__('Sales Invoice'), () => {
frappe.model.open_mapped_doc({
method: 'erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice',
frm: frm
});
}, __('Create'));
frm.add_custom_button(__('Delivery Note'), () => {
frappe.model.open_mapped_doc({
method: 'erpnext.selling.doctype.sales_order.sales_order.make_delivery_note',
frm: frm
});
}, __('Create'));
}
// Button for draft documents only
if (!frm.is_new() && frm.doc.docstatus === 0) {
frm.add_custom_button(__('Validate Stock'), async () => {
let r = await frappe.call({
method: 'myapp.api.check_stock',
args: { sales_order: frm.doc.name },
freeze: true,
freeze_message: __('Checking stock...')
});
if (r.message.all_available) {
frappe.show_alert({
message: __('All items available'),
indicator: 'green'
});
} else {
frappe.msgprint({
title: __('Stock Warning'),
message: __('Unavailable: {0}',
[r.message.unavailable.join(', ')]),
indicator: 'orange'
});
}
});
}
}
});Example 4: Form Validation with frappe.throw
frappe.ui.form.on('Sales Invoice', {
validate(frm) {
// Validate total
if (frm.doc.grand_total <= 0) {
frappe.throw(__('Grand total must be greater than zero'));
}
// Validate items exist
if (!frm.doc.items || frm.doc.items.length === 0) {
frappe.throw(__('At least one item is required'));
}
// Validate date logic
if (frm.doc.due_date && frm.doc.due_date < frm.doc.posting_date) {
frappe.throw(__('Due date cannot be before posting date'));
}
// Validate each row
frm.doc.items.forEach((item, idx) => {
if (item.qty <= 0) {
frappe.throw(__('Row {0}: Quantity must be positive', [idx + 1]));
}
if (!item.rate) {
frappe.throw(__('Row {0}: Rate is required', [idx + 1]));
}
});
}
});Example 5: Async Validation with Server Check
frappe.ui.form.on('Sales Order', {
// CRITICAL: async validate with await — NEVER use callback pattern here
async validate(frm) {
if (frm.doc.customer) {
let r = await frappe.call({
method: 'myapp.api.check_credit_limit',
args: {
customer: frm.doc.customer,
amount: frm.doc.grand_total
}
});
if (r.message && !r.message.approved) {
frappe.throw(__('Credit limit exceeded. Limit: {0}, Outstanding: {1}',
[r.message.limit, r.message.outstanding]));
}
}
}
});Example 6: Fetching Data on Field Change
frappe.ui.form.on('Sales Order', {
customer(frm) {
if (!frm.doc.customer) return;
frappe.db.get_value('Customer', frm.doc.customer,
['customer_group', 'territory', 'default_currency', 'credit_limit'])
.then(r => {
if (r.message) {
frm.set_value({
customer_group: r.message.customer_group,
territory: r.message.territory,
currency: r.message.default_currency
});
if (r.message.credit_limit) {
frappe.show_alert({
message: __('Credit limit: {0}',
[format_currency(r.message.credit_limit)]),
indicator: 'blue'
});
}
}
});
}
});Example 7: Child Table Auto-Calculation
frappe.ui.form.on('Sales Invoice Item', {
qty(frm, cdt, cdn) {
calculate_row_amount(frm, cdt, cdn);
},
rate(frm, cdt, cdn) {
calculate_row_amount(frm, cdt, cdn);
},
discount_percentage(frm, cdt, cdn) {
calculate_row_amount(frm, cdt, cdn);
},
items_remove(frm) {
calculate_totals(frm);
}
});
function calculate_row_amount(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let amount = row.qty * row.rate;
if (row.discount_percentage) {
amount = amount * (1 - row.discount_percentage / 100);
}
frappe.model.set_value(cdt, cdn, 'amount', amount);
calculate_totals(frm);
}
function calculate_totals(frm) {
let total = 0;
(frm.doc.items || []).forEach(item => {
total += item.amount || 0;
});
frm.set_value('net_total', total);
frm.set_value('grand_total', total * 1.21); // Example: 21% VAT
}Example 8: Populating Child Table from Server
frappe.ui.form.on('Project', {
refresh(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__('Add Standard Tasks'), async () => {
let r = await frappe.call({
method: 'myapp.api.get_standard_tasks',
args: { project_type: frm.doc.project_type }
});
if (r.message && r.message.length) {
frm.clear_table('tasks');
r.message.forEach(task => {
frm.add_child('tasks', {
title: task.title,
description: task.description,
expected_time: task.expected_time,
status: 'Open'
});
});
frm.refresh_field('tasks'); // ALWAYS after child table changes
frm.dirty(); // Mark form as modified
frappe.show_alert({
message: __('Added {0} tasks', [r.message.length]),
indicator: 'green'
});
}
});
}
}
});Example 9: Parallel API Calls with Promise.all
frappe.ui.form.on('Customer', {
async refresh(frm) {
if (frm.is_new()) return;
try {
let [order_count, invoice_count, balance] = await Promise.all([
frappe.db.count('Sales Order', { customer: frm.doc.name }),
frappe.db.count('Sales Invoice', { customer: frm.doc.name }),
frappe.call({
method: 'erpnext.accounts.utils.get_balance_on',
args: { party_type: 'Customer', party: frm.doc.name }
})
]);
frm.dashboard.add_indicator(__('Orders: {0}', [order_count]), 'blue');
frm.dashboard.add_indicator(__('Invoices: {0}', [invoice_count]), 'green');
frm.dashboard.add_indicator(
__('Balance: {0}', [format_currency(balance.message)]),
balance.message > 0 ? 'orange' : 'green'
);
} catch (e) {
console.error('Dashboard load error:', e);
}
}
});Example 10: Confirmation Dialog Before Action
frappe.ui.form.on('Sales Invoice', {
refresh(frm) {
if (frm.doc.docstatus === 1 && frm.doc.outstanding_amount > 0) {
frm.add_custom_button(__('Write Off'), () => {
frappe.confirm(
__('Write off {0}?',
[format_currency(frm.doc.outstanding_amount, frm.doc.currency)]),
async () => {
let r = await frappe.call({
method: 'myapp.api.write_off_invoice',
args: { invoice: frm.doc.name },
freeze: true
});
if (r.message) {
frappe.show_alert({
message: __('Invoice written off'),
indicator: 'green'
});
frm.reload_doc();
}
}
);
});
}
}
});Example 11: Dynamic Field Properties Based on Type
frappe.ui.form.on('Purchase Order', {
refresh(frm) {
let is_draft = frm.doc.docstatus === 0;
frm.toggle_enable('supplier', is_draft);
},
order_type(frm) {
let is_shopping_cart = frm.doc.order_type === 'Shopping Cart';
frm.set_df_property('shipping_rule', 'reqd', is_shopping_cart ? 1 : 0);
frm.set_df_property('shipping_rule', 'hidden', is_shopping_cart ? 0 : 1);
},
supplier(frm) {
if (!frm.doc.supplier) return;
frappe.db.get_value('Supplier', frm.doc.supplier, 'payment_terms')
.then(r => {
if (r.message.payment_terms) {
frm.set_value('payment_terms_template', r.message.payment_terms);
}
});
}
});Example 12: Custom Dialog with Table Field
frappe.ui.form.on('Sales Order', {
refresh(frm) {
if (frm.doc.docstatus === 0 && !frm.is_new()) {
frm.add_custom_button(__('Bulk Add Items'), () => {
let d = new frappe.ui.Dialog({
title: __('Add Items'),
fields: [
{
fieldname: 'item_list',
fieldtype: 'Table',
label: __('Items'),
in_place_edit: true,
fields: [
{ fieldname: 'item_code', fieldtype: 'Link',
options: 'Item', label: __('Item'), in_list_view: 1, reqd: 1 },
{ fieldname: 'qty', fieldtype: 'Float',
label: __('Qty'), in_list_view: 1, default: 1 }
]
}
],
size: 'large',
primary_action_label: __('Add'),
primary_action(values) {
(values.item_list || []).forEach(row => {
if (row.item_code) {
frm.add_child('items', {
item_code: row.item_code,
qty: row.qty || 1
});
}
});
frm.refresh_field('items');
frm.dirty();
d.hide();
}
});
d.show();
});
}
}
});Example 13: List View Customization
// In {doctype}_list.js or via Client Script
frappe.listview_settings['Task'] = {
add_fields: ['status', 'priority', 'assigned_to'],
filters: [['status', '!=', 'Cancelled']],
hide_name_column: true,
get_indicator(doc) {
const indicators = {
'Open': [__('Open'), 'orange', 'status,=,Open'],
'Working': [__('Working'), 'blue', 'status,=,Working'],
'Completed': [__('Completed'), 'green', 'status,=,Completed'],
'Cancelled': [__('Cancelled'), 'red', 'status,=,Cancelled']
};
return indicators[doc.status] || [__(doc.status), 'grey'];
},
button: {
show(doc) { return doc.status === 'Open'; },
get_label() { return __('Start'); },
get_description(doc) { return __('Mark {0} as Working', [doc.name]); },
action(doc) {
frappe.call({
method: 'frappe.client.set_value',
args: { doctype: 'Task', name: doc.name, fieldname: 'status', value: 'Working' }
});
}
},
formatters: {
priority(val) {
const colors = { 'High': 'red', 'Medium': 'orange', 'Low': 'green' };
return `<span class="indicator-pill ${colors[val] || 'grey'}">${val || ''}</span>`;
}
},
onload(listview) {
// Runs once when list view loads
}
};Example 14: MultiSelectDialog for Linking Documents
frappe.ui.form.on('Purchase Order', {
refresh(frm) {
if (frm.doc.docstatus === 0 && !frm.is_new()) {
frm.add_custom_button(__('Get Items from MR'), () => {
new frappe.ui.form.MultiSelectDialog({
doctype: 'Material Request',
target: frm,
setters: {
schedule_date: null,
status: 'Pending'
},
date_field: 'transaction_date',
get_query() {
return { filters: { docstatus: 1, status: ['!=', 'Stopped'] } };
},
action(selections) {
if (selections.length) {
frappe.call({
method: 'myapp.api.get_items_from_mr',
args: { material_requests: selections },
callback(r) {
if (r.message) {
r.message.forEach(item => {
frm.add_child('items', item);
});
frm.refresh_field('items');
frm.dirty();
}
}
});
}
}
});
}, __('Get Items'));
}
}
});Client Script Methods Reference
frm Object Methods
Value Manipulation
frm.set_value(fieldname, value)
Sets field value. Triggers change events, dirty flag, and UI refresh. Returns Promise.
// Single field
frm.set_value('status', 'Approved');
// Multiple fields at once
frm.set_value({
status: 'Approved',
priority: 'High',
due_date: frappe.datetime.add_days(frappe.datetime.now_date(), 7)
});
// With Promise handling
frm.set_value('status', 'Approved').then(() => {
console.log('Value set and change events fired');
});
// Await pattern
await frm.set_value('status', 'Approved');frm.doc.fieldname
Direct read access to field values. NEVER write to frm.doc directly.
let customer = frm.doc.customer; // String field
let items = frm.doc.items; // Child table (array of objects)
let total = frm.doc.grand_total; // Currency field
let status = frm.doc.docstatus; // 0=Draft, 1=Submitted, 2=CancelledField Display Properties
frm.toggle_display(fieldname, show)
Shows or hides fields. Accepts string or array.
frm.toggle_display('priority', frm.doc.status === 'Open');
frm.toggle_display(['priority', 'due_date', 'assigned_to'], condition);frm.toggle_reqd(fieldname, required)
Makes fields mandatory or optional. Accepts string or array.
frm.toggle_reqd('due_date', true);
frm.toggle_reqd(['email', 'phone'], frm.doc.customer_type === 'Company');frm.toggle_enable(fieldname, enable)
Makes fields editable or read-only. Accepts string or array.
frm.toggle_enable('amount', false); // Read-only
frm.toggle_enable('amount', true); // Editablefrm.set_df_property(fieldname, property, value)
Sets any docfield property dynamically.
// Common properties
frm.set_df_property('status', 'options', ['New', 'Open', 'Closed']);
frm.set_df_property('amount', 'read_only', 1);
frm.set_df_property('description', 'hidden', 1);
frm.set_df_property('priority', 'reqd', 1);
frm.set_df_property('rate', 'precision', 4);
frm.set_df_property('notes', 'label', 'Internal Notes');
frm.set_df_property('description', 'description', 'Enter detailed notes here');frm.set_intro(message, color)
Displays intro text at the top of the form.
frm.set_intro('This document requires approval', 'orange');
// Colors: 'blue', 'red', 'orange', 'green', 'yellow'
frm.set_intro(''); // Clear introLink Field Queries
frm.set_query(fieldname, [tablename], callback)
Filters options in Link fields. ALWAYS call in setup event.
// Form-level Link filter
frm.set_query('customer', () => ({
filters: { disabled: 0, customer_type: 'Company' }
}));
// With dynamic filters (callback reads frm.doc at execution time)
frm.set_query('customer', () => ({
filters: { territory: frm.doc.territory }
}));
// Child table Link filter
frm.set_query('item_code', 'items', (doc, cdt, cdn) => {
let row = locals[cdt][cdn];
return {
filters: {
is_sales_item: 1,
item_group: row.item_group || undefined
}
};
});
// Server-side query (for complex filtering logic)
frm.set_query('customer', () => ({
query: 'myapp.queries.get_customers_by_region',
filters: { region: frm.doc.region }
}));Child Table Methods
frm.add_child(tablename, values)
Adds a row to child table. Returns the new row object.
let row = frm.add_child('items', {
item_code: 'ITEM-001',
qty: 5,
rate: 100
});
frm.refresh_field('items'); // ALWAYS required after modificationfrm.clear_table(tablename)
Removes all rows from a child table.
frm.clear_table('items');
frm.refresh_field('items'); // ALWAYS requiredfrm.refresh_field(fieldname)
Redraws field in the UI. ALWAYS call after child table modifications.
frm.refresh_field('items'); // After child table change
frm.refresh_field('grand_total'); // After computed field updatefrm.get_selected()
Returns selected rows in table fields.
let selected = frm.get_selected();
// Returns: { items: ['row-id-1', 'row-id-2'] }Form State Methods
frm.is_new()
Returns true if document has never been saved.
if (frm.is_new()) {
frm.set_value('status', 'Draft');
}frm.is_dirty()
Returns true if form has unsaved changes.
if (frm.is_dirty()) {
frappe.warn(__('Warning'), __('You have unsaved changes'));
}frm.dirty()
Marks form as modified. Triggers "Not Saved" indicator. Use after programmatic changes.
frm.doc.items.forEach(row => { row.discount = 5; });
frm.refresh_field('items');
frm.dirty(); // Show unsaved indicatorfrm.enable_save() / frm.disable_save()
Toggles save button availability.
frm.disable_save(); // Prevent saving
frm.enable_save(); // Allow savingNavigation and Refresh
frm.reload_doc()
Reloads document from server and calls frm.refresh().
await frm.reload_doc();frm.refresh()
Re-renders form with current data. Triggers: before_load > onload > refresh > onload_post_render.
frm.save(action)
Saves document. Optional action: 'Submit', 'Cancel', 'Update'.
frm.save().then(() => frappe.show_alert(__('Saved!')));
frm.save('Submit'); // Submit the documentfrm.trigger(event)
Programmatically triggers a form event handler.
frm.trigger('refresh'); // Re-run refresh handler
frm.trigger('customer'); // Re-run customer change handlerCustom Buttons
frm.add_custom_button(label, action, [group])
Adds button to inner toolbar. Returns jQuery element.
// Standalone button
frm.add_custom_button(__('Generate Report'), () => {
frappe.call({ method: 'myapp.api.generate_report', args: { name: frm.doc.name } });
});
// Grouped button (dropdown)
frm.add_custom_button(__('Sales Invoice'), () => { /* create invoice */ }, __('Create'));
frm.add_custom_button(__('Delivery Note'), () => { /* create DN */ }, __('Create'));frm.remove_custom_button(label, [group])
Removes a custom button.
frm.remove_custom_button(__('Generate Report'));
frm.remove_custom_button(__('Sales Invoice'), __('Create'));frm.clear_custom_buttons()
Removes all custom buttons.
frm.change_custom_button_type(label, group, type)
Changes button styling.
frm.change_custom_button_type(__('Approve'), null, 'primary');frm.page.set_primary_action(label, action)
Sets the primary action button (blue, top-right).
frm.page.set_primary_action(__('Process'), () => {
frm.call('process').then(() => frm.reload_doc());
});Communication
frm.call(method, args)
Calls a whitelisted method on the document controller. Returns Promise.
let r = await frm.call('calculate_taxes', { include_shipping: true });
console.log(r.message);frm.email_doc(message)
Opens email dialog for this document.
frm.email_doc('Please review this document.');---
frappe.* Client-Side Methods
Server Communication
frappe.call(options)
Calls a whitelisted Python method via AJAX. Returns Promise.
let r = await frappe.call({
method: 'myapp.api.get_customer_data', // Full dotted path
args: { customer: frm.doc.customer }, // Arguments
freeze: true, // Show loading overlay
freeze_message: __('Loading...'), // Custom message
// Or use callback style:
callback: (r) => { if (r.message) { /* use */ } },
error: (r) => { frappe.msgprint(__('Error')); }
});NEVER use `async: false` — it blocks the browser thread.
frappe.db.get_value(doctype, name, fieldname)
Gets field value(s) from a document.
// Single field
let r = await frappe.db.get_value('Customer', frm.doc.customer, 'credit_limit');
console.log(r.message.credit_limit);
// Multiple fields
let r = await frappe.db.get_value('Customer', frm.doc.customer,
['credit_limit', 'territory']);
// With filters instead of name
let r = await frappe.db.get_value('Customer',
{ customer_name: 'Acme Corp' }, 'name');frappe.db.get_list(doctype, args)
Gets a list of documents matching criteria.
let orders = await frappe.db.get_list('Sales Order', {
filters: { customer: frm.doc.customer, docstatus: 1 },
fields: ['name', 'grand_total', 'status'],
order_by: 'creation desc',
limit: 10
});
// Returns array of objectsfrappe.db.count(doctype, filters)
Counts documents matching filters.
let count = await frappe.db.count('Sales Order', { customer: frm.doc.customer });User Interface
frappe.msgprint(message | options)
Shows modal message dialog.
frappe.msgprint(__('Operation completed'));
frappe.msgprint({
title: __('Success'),
message: __('Invoice created successfully'),
indicator: 'green'
});
// With primary action
frappe.msgprint({
title: __('Confirm'),
message: __('Proceed with operation?'),
primary_action: {
label: __('Proceed'),
server_action: 'myapp.api.do_action',
args: { name: frm.doc.name }
}
});frappe.throw(message)
Shows error modal AND throws exception. Halts execution.
if (frm.doc.amount < 0) {
frappe.throw(__('Amount cannot be negative'));
}frappe.show_alert(message | options, seconds)
Non-blocking notification toast. Default 7 seconds.
frappe.show_alert(__('Saved!'), 3);
frappe.show_alert({ message: __('Done'), indicator: 'green' }, 5);frappe.confirm(message, if_yes, if_no)
Modal confirmation dialog.
frappe.confirm(
__('Are you sure you want to delete?'),
() => { /* Yes */ },
() => { /* No */ }
);frappe.warn(title, message, proceed_action, primary_label, is_minimizable)
Warning modal with optional minimize.
frappe.warn(__('Warning'), __('Unsaved changes exist'),
() => { /* proceed */ }, __('Continue'), true);frappe.prompt(fields, callback, title, primary_label)
Quick input dialog.
// Single field
frappe.prompt('Reason', (values) => { console.log(values.value); });
// Multiple fields
frappe.prompt([
{ label: 'Name', fieldname: 'name', fieldtype: 'Data', reqd: 1 },
{ label: 'Date', fieldname: 'date', fieldtype: 'Date' }
], (values) => { console.log(values); }, __('Enter Details'));frappe.show_progress(title, count, total, description)
Progress bar indicator.
frappe.show_progress(__('Importing'), 45, 100, __('Please wait'));new frappe.ui.Dialog(options)
Full-featured dialog with custom fields.
let d = new frappe.ui.Dialog({
title: __('Enter Details'),
fields: [
{ label: 'Name', fieldname: 'name', fieldtype: 'Data', reqd: 1 },
{ label: 'Type', fieldname: 'type', fieldtype: 'Select',
options: 'Type A\nType B\nType C' }
],
size: 'small', // 'small', 'large', 'extra-large'
primary_action_label: __('Submit'),
primary_action(values) {
console.log(values);
d.hide();
}
});
d.show();Child Table Utilities
frappe.get_doc(cdt, cdn)
Gets child row data object. Use inside child table event handlers.
frappe.ui.form.on('Sales Invoice Item', {
qty(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
// row.qty, row.rate, row.amount, etc.
}
});frappe.model.set_value(cdt, cdn, fieldname, value)
Sets value in child row. Triggers change events. Returns Promise.
frappe.model.set_value(cdt, cdn, 'amount', row.qty * row.rate);
// Multiple values
frappe.model.set_value(cdt, cdn, {
amount: row.qty * row.rate,
net_amount: row.qty * row.rate * (1 - row.discount / 100)
});frappe.model.open_mapped_doc(options)
Opens a new document pre-filled from a source document.
frappe.model.open_mapped_doc({
method: 'erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice',
frm: frm
});Translation
__(message, replace, context)
Translates a string. ALWAYS wrap user-facing text.
__('Hello World')
__('Hello {0}', [user_name])
__('Total: {0}', [frm.doc.grand_total])
__('Row {0}: Quantity must be positive', [idx + 1])Date/Time Utilities
frappe.datetime.now_date() // 'YYYY-MM-DD'
frappe.datetime.now_datetime() // 'YYYY-MM-DD HH:mm:ss'
frappe.datetime.add_days('2024-01-01', 7) // '2024-01-08'
frappe.datetime.add_months('2024-01-01', 1) // '2024-02-01'
frappe.datetime.str_to_user('2024-01-15') // User date format
frappe.datetime.get_diff(date1, date2) // Difference in daysFormatting Utilities
frappe.format(value, { fieldtype: 'Currency' }) // Formatted currency
frappe.format('2024-09-08', { fieldtype: 'Date' }) // User date format
format_currency(1234.50, 'USD') // '$1,234.50'Routing
frappe.set_route('Form', 'Sales Order', 'SO-0001');
frappe.set_route('List', 'Sales Order');
frappe.new_doc('Sales Order');
frappe.new_doc('Task', { subject: 'New Task' }); // With defaults
let route = frappe.get_route(); // ['Form', 'Sales Order', ...]Asset Loading
frappe.require('/assets/myapp/js/library.js', () => {
// Library is now available
});