
Frappe Syntax Clientscripts
- 24 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-syntax-clientscripts is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-syntax-clientscripts
- AI & Agent Building
- AI-coding skill
Frappe Syntax Clientscripts by the numbers
- 24 all-time installs (skills.sh)
- Ranked #9,912 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-syntax-clientscriptsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
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
});