
Frappe Impl Clientscripts
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Provides workflows for implementing Frappe client scripts including field visibility, cascading filters, calculated fields, custom buttons, and form validation.
About
An implementation skill with step-by-step workflows for building Frappe form client scripts. A developer uses it to add dynamic UI, custom buttons, validation, and child-table logic to Frappe forms.
- Workflows for field visibility, cascading filters, and calculated fields
- Custom buttons, server calls, form validation, and migration to custom app JS
Frappe Impl Clientscripts by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,914 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-impl-clientscriptsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Provides workflows for implementing Frappe client scripts including field visibility, cascading filters, calculated fields, custom buttons, and form validation.
Files
Client Scripts — Implementation Workflows
Step-by-step workflows for building client-side form features. For exact API syntax, see frappe-syntax-clientscripts.
Version: v14/v15/v16 | Note: v13 renamed "Custom Script" to "Client Script"
Quick Decision: Client or Server?
MUST the logic ALWAYS execute (imports, API, Data Import)?
├── YES → Server Script or Controller
└── NO → What is the goal?
├── UI feedback / UX → Client Script
├── Show/hide fields → Client Script
├── Link filters → Client Script
├── Data validation → BOTH (client for UX, server for integrity)
└── Calculations → Client for display, server for criticalRule: ALWAYS use Client Scripts for UX. ALWAYS back critical logic with server-side validation.
Workflow 1: Create a Client Script via UI
1. Navigate to Setup > Client Script (or type "New Client Script" in awesomebar) 2. Select the target DocType 3. ALWAYS set Enabled checkbox 4. Write script using the frappe.ui.form.on pattern 5. Save — script is active immediately (no restart needed) 6. Open target DocType form → test behavior 7. Open browser DevTools Console (F12) for debugging
When to migrate to custom app: ALWAYS migrate when the script exceeds 50 lines, needs version control, or must be deployed across environments.
Workflow 2: Choose the Right Event
WHAT DO YOU WANT?
├── Set link filters → setup (once, earliest lifecycle)
├── Add custom buttons → refresh (re-added after each render)
├── Show/hide fields → refresh + {fieldname} (BOTH needed)
├── Validate before save → validate (frappe.throw stops save)
├── Action after save → after_save
├── Calculate on change → {fieldname} handler
├── Child row added → {tablename}_add
├── Child row removed → {tablename}_remove
├── Child field changed → Child DocType: {fieldname}
├── One-time init → setup or onload
└── After full DOM render → onload_post_renderSee references/decision-tree.md for complete event timing matrix.
Workflow 3: Field Visibility Toggle
Goal: Show "delivery_date" only when "requires_delivery" is checked.
Step 1: Implement BOTH refresh and fieldname events:
frappe.ui.form.on('Sales Order', {
refresh(frm) {
frm.trigger('requires_delivery'); // Set initial state
},
requires_delivery(frm) {
frm.toggle_display('delivery_date', frm.doc.requires_delivery);
frm.toggle_reqd('delivery_date', frm.doc.requires_delivery);
}
});Why both? refresh sets state on form load. {fieldname} responds to user interaction. NEVER use only one — the form will show wrong state on load or on change.
Workflow 4: Cascading Link Filters
Goal: Filter "city" based on selected "country".
frappe.ui.form.on('Customer', {
setup(frm) {
// ALWAYS set filters in setup — ensures consistency
frm.set_query('city', () => ({
filters: { country: frm.doc.country || '' }
}));
},
country(frm) {
frm.set_value('city', ''); // ALWAYS clear dependent field
}
});Rule: ALWAYS put set_query in setup. ALWAYS clear child fields when parent changes.
Workflow 5: Calculated Fields (Child Table)
Goal: Calculate row amounts and document totals.
frappe.ui.form.on('Invoice Item', {
qty(frm, cdt, cdn) { calculate_row(frm, cdt, cdn); },
rate(frm, cdt, cdn) { calculate_row(frm, cdt, cdn); },
amount(frm) { calculate_totals(frm); }
});
frappe.ui.form.on('Invoice', {
items_remove(frm) { calculate_totals(frm); }
});
function calculate_row(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, 'amount',
flt(row.qty) * flt(row.rate));
}
function calculate_totals(frm) {
let total = (frm.doc.items || []).reduce(
(sum, row) => sum + flt(row.amount), 0);
frm.set_value('grand_total', flt(total, 2));
}Rules:
- ALWAYS use
flt()for numeric operations (handles null/undefined) - ALWAYS handle
items_remove— totals must recalculate on row deletion - NEVER call
refresh_fieldafterset_value— it triggers automatically
Workflow 6: Server Calls: Which Method to Use
NEED TO CALL THE SERVER?
├── Fetch a single value?
│ └── frappe.db.get_value(doctype, name, fields)
│ Returns: Promise — lightweight, no whitelist needed
│
├── Call a document's controller method?
│ └── frm.call(method, args)
│ Requires: @frappe.whitelist() on controller method
│ Auto-includes: doctype, docname, doc context
│
├── Call a standalone whitelisted function?
│ └── frappe.call({method: 'dotted.path', args: {}})
│ Requires: @frappe.whitelist() decorator
│ Returns: Promise with r.message
│
└── Need Promise-only (no callback)?
└── frappe.xcall('dotted.path', args)
Same as frappe.call but returns clean PromiseExample — frm.call:
frm.call('calculate_taxes').then(r => {
frm.reload_doc(); // Refresh after server-side changes
});Example — frappe.xcall:
let result = await frappe.xcall(
'myapp.api.check_credit', { customer: frm.doc.customer });Workflow 7: Custom Button Implementation
frappe.ui.form.on('Sales Order', {
refresh(frm) {
// ALWAYS check conditions before adding buttons
if (!frm.is_new() && frm.doc.docstatus === 1) {
frm.add_custom_button(__('Create Invoice'), () => {
create_invoice(frm);
}, __('Create')); // Group label
}
}
});Rules:
- ALWAYS add buttons in
refresh— they are cleared on each render - ALWAYS check
frm.is_new()— buttons on unsaved docs cause errors - ALWAYS wrap button labels in
__()for translation - NEVER add buttons in
setuporonload— UI not ready
Workflow 8: Async Validation with Server Check
frappe.ui.form.on('Sales Order', {
async validate(frm) {
if (!frm.doc.customer || !frm.doc.grand_total) return;
let r = await frappe.call({
method: 'myapp.api.check_credit',
args: {
customer: frm.doc.customer,
amount: frm.doc.grand_total
}
});
if (r.message && !r.message.allowed) {
frappe.throw(__('Credit limit exceeded. Available: {0}',
[r.message.available]));
}
}
});Rules:
- ALWAYS use
async/awaitfor server calls invalidate - ALWAYS use
frappe.throw()to stop save —msgprintdoes NOT stop it - NEVER put slow server calls in
validatewithout user expectation
Workflow 9: Debugging in Browser
1. Open F12 DevTools > Console 2. Add console.log(frm.doc) in your event handler 3. Use cur_frm in Console to inspect current form state 4. Check Network tab for failed frappe.call requests 5. Use frappe.ui.form.handlers to see registered event handlers
Debug pattern:
frappe.ui.form.on('MyDocType', {
my_field(frm) {
console.log('Field changed:', frm.doc.my_field);
// ... actual logic
}
});Workflow 10: Migrate Client Script to Custom App
1. Create JS file: myapp/myapp/public/js/sales_order.js 2. Move script content to the file (keep frappe.ui.form.on wrapper) 3. Register in hooks.py:
doctype_js = {
"Sales Order": "public/js/sales_order.js"
}4. Run bench build (or bench watch for development) 5. Delete the Client Script document from Setup 6. Test on the form — behavior must be identical
ALWAYS migrate when: version control needed, multi-environment deployment, script > 50 lines, team collaboration required.
Performance Rules
| Rule | Why |
|---|---|
set_query in setup only | Prevents re-registration on every refresh |
Batch set_value calls | frm.set_value({a: 1, b: 2}) — one update, not two |
| Cache server responses | Store in frm._cache_key to avoid repeat calls |
| NEVER query in loops | Fetch all data once, build lookup map |
Use frappe.db.get_value | Lighter than frappe.call for simple lookups |
Related Skills
frappe-syntax-clientscripts— Exact API syntax and method signaturesfrappe-errors-clientscripts— Error handling and common pitfallsfrappe-syntax-whitelisted— Server methods callable from clientfrappe-core-database—frappe.db.*client-side APIfrappe-impl-serverscripts— When to move logic server-side
See references/decision-tree.md for event selection.
See references/workflows.md for extended patterns.
See references/examples.md for 10+ complete examples.
Decision Tree: Client Script Events
Level 1: Client or Server?
WHERE SHOULD THE LOGIC RUN?
│
├── Only when user opens/edits the form in browser?
│ └── CLIENT SCRIPT
│
├── Also on API calls, imports, Data Import Tool?
│ └── SERVER SCRIPT or CONTROLLER
│
├── Critical business rule that must NEVER be skipped?
│ └── SERVER (controller validate / before_save)
│
└── UX improvement (speed, visual feedback)?
└── CLIENT SCRIPT (+ optional server backup)Level 2: Which Client Event?
WHAT IS THE GOAL?
INITIALIZATION
├── One-time setup (link filters)?
│ └── setup — ALWAYS use for set_query
├── UI initialization on form load?
│ └── onload — fires once per form load
└── Actions after complete DOM render?
└── onload_post_render — DOM fully available
UI MANIPULATION
├── Add custom buttons?
│ └── refresh — re-added after each render cycle
├── Show/hide fields?
│ └── refresh + {fieldname} — ALWAYS use both
├── Set indicator/intro text?
│ └── refresh
└── Adjust field properties (read_only, label)?
└── refresh
DATA VALIDATION
├── Sync validation (data already available)?
│ └── validate — frappe.throw() stops save
├── Async validation (server check needed)?
│ └── validate with async/await
└── Pre-submit check?
└── before_submit
POST-SAVE ACTIONS
├── UI update after save?
│ └── after_save
├── Redirect to another document?
│ └── after_save
└── Create follow-up document?
└── after_save or on_submit
FIELD CHANGES
├── Respond to field change?
│ └── {fieldname}
├── Cascading changes (A → B → C)?
│ └── {fieldname} for each link in chain
└── Trigger calculation?
└── {fieldname} for all input fields
CHILD TABLE
├── Row added?
│ └── {tablename}_add
├── Row removed?
│ └── {tablename}_remove
├── Before row removed (v15+)?
│ └── before_{tablename}_remove
├── Field in row changed?
│ └── ChildDocType: {fieldname}(frm, cdt, cdn)
└── Row reordered?
└── {tablename}_moveEvent Timing Matrix
| Event | When | Can Stop Save? | Access To |
|---|---|---|---|
setup | Once on first form creation | No | frm (doc may be empty) |
before_load | Before data loads from server | No | frm |
onload | After data loaded, before render | No | frm, doc |
refresh | After each render cycle | No | frm, doc, full UI |
onload_post_render | After complete DOM render | No | frm, doc, DOM |
validate | Before save request | YES (frappe.throw) | frm, doc |
before_save | Just before save | YES (frappe.throw) | frm, doc |
after_save | After successful save | No | frm, doc (saved) |
before_submit | Before submit | YES (frappe.throw) | frm, doc (docstatus=0) |
on_submit | After submit | No | frm, doc (docstatus=1) |
before_cancel | Before cancel | YES (frappe.throw) | frm, doc |
after_cancel | After cancel | No | frm, doc (docstatus=2) |
{fieldname} | On field value change | No | frm, doc |
{table}_add | Row added to child table | No | frm, cdt, cdn |
{table}_remove | Row removed from child table | No | frm |
{table}_move | Row reordered in child table | No | frm |
Event Combination Patterns
Pattern: Visibility Toggle
ALWAYS use both refresh and {fieldname}:
refresh(frm) → frm.trigger('controlling_field')
controlling_field(frm) → frm.toggle_display(...)Pattern: Cascading Filters
ALWAYS use setup + {parent_field}:
setup(frm) → frm.set_query('child_field', ...)
parent_field(frm) → frm.set_value('child_field', '')Pattern: Calculated Fields
ALWAYS use all input field handlers:
field_a(frm) → calculate(frm)
field_b(frm) → calculate(frm)Pattern: Child Table Totals
ALWAYS handle both change and remove:
ChildDocType.qty → calculate_row → calculate_totals
ChildDocType.rate → calculate_row → calculate_totals
ParentDocType.items_remove → calculate_totalsQuick Reference
| I want to... | Event(s) |
|---|---|
| Filter link field | setup |
| Add button | refresh |
| Hide field on condition | refresh + {fieldname} |
| Calculate value | {input_fields} |
| Validate before save | validate |
| Server check before save | validate (async) |
| Redirect after save | after_save |
| Calculate child table total | Child {fieldname} + Parent {table}_remove |
| Set default for new doc | onload (check frm.is_new()) |
| Custom keyboard shortcut | onload |
Client Script Complete Examples
Example 1: Project Tracking Form
Requirements: Show completion_date on Completed status, calculate days, validate dates.
frappe.ui.form.on('Project Task', {
setup(frm) {
frm.set_query('assigned_to', () => ({ filters: { enabled: 1 } }));
},
refresh(frm) {
frm.trigger('status');
if (frm.doc.start_date && !frm.is_new()) {
let days = frappe.datetime.get_day_diff(
frappe.datetime.now_date(),
frappe.datetime.str_to_obj(frm.doc.start_date));
frm.set_value('days_elapsed', days);
}
},
status(frm) {
let done = frm.doc.status === 'Completed';
frm.toggle_display('completion_date', done);
frm.toggle_reqd('completion_date', done);
if (done && !frm.doc.completion_date)
frm.set_value('completion_date', frappe.datetime.now_date());
},
validate(frm) {
if (frm.doc.completion_date && frm.doc.start_date
&& frm.doc.completion_date < frm.doc.start_date)
frappe.throw(__('Completion date cannot be before start date'));
}
});Example 2: Invoice with Tax Calculation
frappe.ui.form.on('Custom Invoice', {
tax_rate(frm) {
(frm.doc.items || []).forEach(row =>
calculate_row_tax(frm, row.doctype, row.name));
},
items_remove(frm) { calculate_totals(frm); }
});
frappe.ui.form.on('Custom Invoice Item', {
qty(frm, cdt, cdn) { calculate_row(frm, cdt, cdn); },
rate(frm, cdt, cdn) { calculate_row(frm, cdt, cdn); },
amount(frm, cdt, cdn) { calculate_row_tax(frm, cdt, cdn); },
tax_amount(frm) { calculate_totals(frm); }
});
function calculate_row(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, 'amount', flt(flt(row.qty) * flt(row.rate), 2));
}
function calculate_row_tax(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let rate = flt(frm.doc.tax_rate) || 21;
frappe.model.set_value(cdt, cdn, 'tax_amount', flt(flt(row.amount) * rate / 100, 2));
}
function calculate_totals(frm) {
let subtotal = 0, total_tax = 0;
(frm.doc.items || []).forEach(row => {
subtotal += flt(row.amount);
total_tax += flt(row.tax_amount);
});
frm.set_value({
subtotal: flt(subtotal, 2),
total_tax: flt(total_tax, 2),
grand_total: flt(subtotal + total_tax, 2)
});
}Example 3: Wizard-Style Multi-Step Form
frappe.ui.form.on('Onboarding Form', {
refresh(frm) {
update_step_visibility(frm);
let current = frm.doc.current_step || 'step_1';
if (current !== 'step_1')
frm.add_custom_button(__('Previous'), () => navigate_step(frm, -1));
if (current !== 'step_3')
frm.add_custom_button(__('Next'), () => navigate_step(frm, 1), null, 'primary');
}
});
const STEPS = ['step_1', 'step_2', 'step_3'];
function update_step_visibility(frm) {
let current = frm.doc.current_step || 'step_1';
STEPS.forEach(s => frm.toggle_display(`${s}_section`, s === current));
frm.set_intro(__('Step {0} of 3', [STEPS.indexOf(current) + 1]), 'blue');
}
async function navigate_step(frm, direction) {
let idx = STEPS.indexOf(frm.doc.current_step || 'step_1');
if (direction > 0 && !await validate_step(frm)) return;
let next = idx + direction;
if (next >= 0 && next < STEPS.length) {
await frm.set_value('current_step', STEPS[next]);
frm.refresh();
}
}
async function validate_step(frm) {
let step = frm.doc.current_step || 'step_1';
if (step === 'step_1' && (!frm.doc.full_name || !frm.doc.email)) {
frappe.msgprint(__('Fill Name and Email'));
return false;
}
if (step === 'step_2' && !frm.doc.department) {
frappe.msgprint(__('Select a Department'));
return false;
}
return true;
}Example 4: Cascading Filters with Custom Server Query
frappe.ui.form.on('Customer Location', {
setup(frm) {
frm.set_query('city', () => {
if (!frm.doc.country) return { filters: { name: '' } };
return { filters: { country: frm.doc.country } };
});
frm.set_query('address', () => ({
query: 'myapp.queries.get_addresses_for_city',
filters: { city: frm.doc.city, address_type: 'Office' }
}));
},
country(frm) {
frm.set_value('city', '');
frm.set_value('address', '');
},
city(frm) {
frm.set_value('address', '');
}
});Server-side query (myapp/queries.py):
@frappe.whitelist()
def get_addresses_for_city(doctype, txt, searchfield, start, page_len, filters):
return frappe.db.sql("""
SELECT name, address_line1, city FROM `tabAddress`
WHERE city = %(city)s AND address_type = %(address_type)s
AND (name LIKE %(txt)s OR address_line1 LIKE %(txt)s)
LIMIT %(start)s, %(page_len)s
""", {"city": filters.get("city"), "address_type": filters.get("address_type"),
"txt": f"%{txt}%", "start": start, "page_len": page_len})Example 5: File Upload with Custom Button
frappe.ui.form.on('Product', {
refresh(frm) {
if (frm.doc.product_image) show_preview(frm);
if (!frm.is_new()) {
frm.add_custom_button(__('Upload Image'), () => {
new frappe.ui.FileUploader({
doctype: frm.doc.doctype,
docname: frm.doc.name,
folder: 'Home/Products',
restrictions: {
allowed_file_types: ['image/*'],
max_file_size: 2 * 1024 * 1024
},
on_success: (file_doc) => {
frm.set_value('product_image', file_doc.file_url);
frm.save();
}
});
});
}
},
product_image(frm) { show_preview(frm); }
});
function show_preview(frm) {
if (!frm.doc.product_image) return;
frm.$wrapper.find('.product-preview').remove();
frm.get_field('product_image').$wrapper.append(`
<div class="product-preview" style="margin:10px 0">
<img src="${frm.doc.product_image}"
style="max-width:200px;max-height:200px;border:1px solid #d1d8dd">
</div>`);
}Example 6: Keyboard Shortcuts
frappe.ui.form.on('Quick Entry', {
onload(frm) {
frappe.ui.keys.add_shortcut({
shortcut: 'ctrl+d',
action: () => {
if (frm.is_new()) { frappe.msgprint(__('Save first')); return; }
let new_doc = frappe.model.copy_doc(frm.doc);
new_doc.docstatus = 0;
frappe.set_route('Form', frm.doc.doctype, new_doc.name);
},
description: __('Duplicate Document'),
page: frm.page
});
}
});Example 7: Print and Export Actions
frappe.ui.form.on('Report Document', {
refresh(frm) {
if (!frm.is_new() && frm.doc.docstatus === 1) {
frm.add_custom_button(__('Print Report'), () => {
frm.print_doc('Custom Report Format');
}, __('Actions'));
frm.add_custom_button(__('Export to Excel'), () => {
let data = [['Item', 'Quantity', 'Rate', 'Amount']];
(frm.doc.items || []).forEach(row =>
data.push([row.item_code, row.qty, row.rate, row.amount]));
data.push(['', '', 'Total:', frm.doc.grand_total]);
frappe.tools.downloadify(data, null, frm.doc.name);
}, __('Actions'));
}
}
});Example 8: Realtime Collaboration Indicator
frappe.ui.form.on('Project', {
onload(frm) {
if (frm.is_new()) return;
frappe.realtime.emit('viewing_document', {
doctype: frm.doc.doctype, name: frm.doc.name, user: frappe.session.user
});
frappe.realtime.on('viewing_document', (data) => {
if (data.doctype === frm.doc.doctype && data.name === frm.doc.name
&& data.user !== frappe.session.user) {
frappe.show_alert({
message: __('User {0} is also viewing', [data.user]),
indicator: 'yellow'
}, 10);
}
});
}
});Client Script Implementation Workflows
Workflow: Master-Detail Form (Auto-populate)
Use case: Customer selection auto-fills name, territory, credit limit.
frappe.ui.form.on('Sales Order', {
setup(frm) {
frm.set_df_property('customer_name', 'read_only', 1);
frm.set_df_property('territory', 'read_only', 1);
},
refresh(frm) {
if (frm.doc.credit_limit) {
frm.dashboard.add_indicator(
__('Credit: {0}', [format_currency(frm.doc.credit_limit)]),
'blue');
}
},
async customer(frm) {
if (!frm.doc.customer) {
frm.set_value({ customer_name: '', territory: '', credit_limit: 0 });
return;
}
try {
let r = await frappe.db.get_value('Customer', frm.doc.customer,
['customer_name', 'territory', 'credit_limit']);
if (r.message) frm.set_value(r.message);
} catch (e) {
frappe.show_alert({ message: __('Could not fetch customer details'), indicator: 'red' });
}
}
});Workflow: Conditional Form Sections
Use case: Show different sections based on document type.
frappe.ui.form.on('Sales Order', {
refresh(frm) { frm.trigger('order_type'); },
order_type(frm) {
const type = frm.doc.order_type;
// Hide all type-specific sections
['standard_section', 'blanket_section', 'maintenance_section']
.forEach(s => frm.toggle_display(s, false));
// Show relevant section and set required fields
if (type === 'Standard') {
frm.toggle_display('standard_section', true);
} else if (type === 'Blanket') {
frm.toggle_display('blanket_section', true);
frm.toggle_reqd('blanket_order', true);
} else if (type === 'Maintenance') {
frm.toggle_display('maintenance_section', true);
frm.toggle_reqd(['maintenance_schedule', 'service_level'], true);
}
}
});Workflow: Tiered Price Calculation
Use case: Apply quantity-based discount tiers on child table.
frappe.ui.form.on('Quotation Item', {
async item_code(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
if (!row.item_code) return;
let r = await frappe.db.get_value('Item Price',
{ item_code: row.item_code, selling: 1 }, 'price_list_rate');
if (r.message) frappe.model.set_value(cdt, cdn, 'rate', r.message.price_list_rate);
},
qty(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let discount = row.qty >= 50 ? 10 : row.qty >= 10 ? 5 : 0;
frappe.model.set_value(cdt, cdn, 'discount_percentage', discount);
calculate_amount(frm, cdt, cdn);
},
rate(frm, cdt, cdn) { calculate_amount(frm, cdt, cdn); },
discount_percentage(frm, cdt, cdn) { calculate_amount(frm, cdt, cdn); }
});
function calculate_amount(frm, cdt, cdn) {
let row = frappe.get_doc(cdt, cdn);
let factor = 1 - (flt(row.discount_percentage) / 100);
frappe.model.set_value(cdt, cdn, 'amount', flt(row.qty) * flt(row.rate) * factor);
}Workflow: Document Creation Button
Use case: Sales Order → Sales Invoice with one click.
frappe.ui.form.on('Sales Order', {
refresh(frm) {
if (frm.doc.docstatus === 1 && frm.doc.per_billed < 100) {
frm.add_custom_button(__('Sales Invoice'), () => {
create_invoice(frm);
}, __('Create'));
}
}
});
async function create_invoice(frm) {
let confirmed = await new Promise(resolve =>
frappe.confirm(__('Create Sales Invoice?'), () => resolve(true), () => resolve(false)));
if (!confirmed) return;
try {
let r = await frappe.call({
method: 'erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice',
args: { source_name: frm.doc.name },
freeze: true,
freeze_message: __('Creating Invoice...')
});
if (r.message) {
frappe.model.sync(r.message);
frappe.set_route('Form', 'Sales Invoice', r.message.name);
}
} catch (e) {
frappe.msgprint({ title: __('Error'), message: e.message, indicator: 'red' });
}
}Workflow: Multi-Step Validation
Use case: Complex validation with local + server checks.
frappe.ui.form.on('Sales Order', {
async validate(frm) {
// Step 1: Local checks (fast)
if (!frm.doc.items || frm.doc.items.length === 0)
frappe.throw(__('At least one item required'));
if (frm.doc.grand_total <= 0)
frappe.throw(__('Total must be greater than zero'));
// Step 2: Server validation (async)
let r = await frappe.call({
method: 'myapp.validations.validate_sales_order',
args: { customer: frm.doc.customer, amount: frm.doc.grand_total }
});
if (r.message) {
if (r.message.customer_disabled)
frappe.throw(__('Customer {0} is disabled', [frm.doc.customer]));
if (r.message.credit_exceeded)
frappe.throw(__('Credit limit exceeded by {0}',
[format_currency(r.message.exceeded_by)]));
}
}
});Workflow: Auto-Populate Child Table from Template
frappe.ui.form.on('Sales Order', {
async order_template(frm) {
if (!frm.doc.order_template) return;
if (frm.doc.items && frm.doc.items.length > 0) {
let ok = await new Promise(resolve =>
frappe.confirm(__('Replace existing items?'),
() => resolve(true), () => resolve(false)));
if (!ok) { frm.set_value('order_template', ''); return; }
}
let template = await frappe.db.get_doc('Order Template', frm.doc.order_template);
frm.clear_table('items');
for (let item of template.items) {
frm.add_child('items', {
item_code: item.item_code, qty: item.qty, rate: item.rate
});
}
frm.refresh_field('items');
frappe.show_alert({
message: __('Added {0} items', [template.items.length]),
indicator: 'green'
});
}
});Workflow: Bulk Actions on Child Table
frappe.ui.form.on('Purchase Order', {
refresh(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__('Mark Selected as Received'), () => {
let selected = frm.get_selected();
if (!selected.items || selected.items.length === 0) {
frappe.msgprint(__('Select items first'));
return;
}
frappe.confirm(__('Mark {0} items as received?', [selected.items.length]), () => {
selected.items.forEach(cdn => {
let row = frappe.get_doc('Purchase Order Item', cdn);
frappe.model.set_value('Purchase Order Item', cdn, {
received_qty: row.qty,
received_date: frappe.datetime.now_date()
});
});
frm.refresh_field('items');
frappe.show_alert({ message: __('Updated'), indicator: 'green' });
});
});
}
}
});Workflow: Role-Based Tab Visibility
frappe.ui.form.on('Employee Record', {
refresh(frm) {
if (!frappe.user_roles.includes('HR Manager')) {
frm.toggle_display('salary_tab', false);
frm.toggle_display('documents_tab', false);
}
}
});Workflow: Real-time Dashboard in Form
frappe.ui.form.on('Customer', {
async refresh(frm) {
if (frm.is_new()) return;
let stats = await frappe.call({
method: 'myapp.api.get_customer_stats',
args: { customer: frm.doc.name }
});
if (stats.message) {
frm.dashboard.clear_headline();
frm.dashboard.add_indicator(
__('Orders: {0}', [stats.message.total_orders]),
stats.message.total_orders > 0 ? 'blue' : 'gray');
frm.dashboard.add_indicator(
__('Revenue: {0}', [format_currency(stats.message.total_revenue)]),
'green');
}
}
});