$npx -y skills add Impertio-Studio/Frappe_Claude_Skill_Package --skill frappe-impl-clientscriptsUse when implementing client-side form features in Frappe/ERPNext: field visibility, cascading filters, calculated fields, custom buttons, server calls, form validation, child table logic, debugging. Covers step-by-step workflows from Setup > Client Script through migration to cu
| 1 | # Client Scripts — Implementation Workflows |
| 2 | |
| 3 | Step-by-step workflows for building client-side form features. For exact API syntax, see `frappe-syntax-clientscripts`. |
| 4 | |
| 5 | **Version**: v14/v15/v16 | **Note**: v13 renamed "Custom Script" to "Client Script" |
| 6 | |
| 7 | ## Quick Decision: Client or Server? |
| 8 | |
| 9 | ``` |
| 10 | MUST the logic ALWAYS execute (imports, API, Data Import)? |
| 11 | ├── YES → Server Script or Controller |
| 12 | └── NO → What is the goal? |
| 13 | ├── UI feedback / UX → Client Script |
| 14 | ├── Show/hide fields → Client Script |
| 15 | ├── Link filters → Client Script |
| 16 | ├── Data validation → BOTH (client for UX, server for integrity) |
| 17 | └── Calculations → Client for display, server for critical |
| 18 | ``` |
| 19 | |
| 20 | **Rule**: ALWAYS use Client Scripts for UX. ALWAYS back critical logic with server-side validation. |
| 21 | |
| 22 | ## Workflow 1: Create a Client Script via UI |
| 23 | |
| 24 | 1. Navigate to **Setup > Client Script** (or type "New Client Script" in awesomebar) |
| 25 | 2. Select the target **DocType** |
| 26 | 3. ALWAYS set **Enabled** checkbox |
| 27 | 4. Write script using the `frappe.ui.form.on` pattern |
| 28 | 5. Save — script is active immediately (no restart needed) |
| 29 | 6. Open target DocType form → test behavior |
| 30 | 7. Open browser DevTools Console (F12) for debugging |
| 31 | |
| 32 | **When to migrate to custom app**: ALWAYS migrate when the script exceeds 50 lines, needs version control, or must be deployed across environments. |
| 33 | |
| 34 | ## Workflow 2: Choose the Right Event |
| 35 | |
| 36 | ``` |
| 37 | WHAT DO YOU WANT? |
| 38 | ├── Set link filters → setup (once, earliest lifecycle) |
| 39 | ├── Add custom buttons → refresh (re-added after each render) |
| 40 | ├── Show/hide fields → refresh + {fieldname} (BOTH needed) |
| 41 | ├── Validate before save → validate (frappe.throw stops save) |
| 42 | ├── Action after save → after_save |
| 43 | ├── Calculate on change → {fieldname} handler |
| 44 | ├── Child row added → {tablename}_add |
| 45 | ├── Child row removed → {tablename}_remove |
| 46 | ├── Child field changed → Child DocType: {fieldname} |
| 47 | ├── One-time init → setup or onload |
| 48 | └── After full DOM render → onload_post_render |
| 49 | ``` |
| 50 | |
| 51 | > See [references/decision-tree.md](references/decision-tree.md) for complete event timing matrix. |
| 52 | |
| 53 | ## Workflow 3: Field Visibility Toggle |
| 54 | |
| 55 | **Goal**: Show "delivery_date" only when "requires_delivery" is checked. |
| 56 | |
| 57 | **Step 1**: Implement BOTH refresh and fieldname events: |
| 58 | |
| 59 | ```javascript |
| 60 | frappe.ui.form.on('Sales Order', { |
| 61 | refresh(frm) { |
| 62 | frm.trigger('requires_delivery'); // Set initial state |
| 63 | }, |
| 64 | requires_delivery(frm) { |
| 65 | frm.toggle_display('delivery_date', frm.doc.requires_delivery); |
| 66 | frm.toggle_reqd('delivery_date', frm.doc.requires_delivery); |
| 67 | } |
| 68 | }); |
| 69 | ``` |
| 70 | |
| 71 | **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. |
| 72 | |
| 73 | ## Workflow 4: Cascading Link Filters |
| 74 | |
| 75 | **Goal**: Filter "city" based on selected "country". |
| 76 | |
| 77 | ```javascript |
| 78 | frappe.ui.form.on('Customer', { |
| 79 | setup(frm) { |
| 80 | // ALWAYS set filters in setup — ensures consistency |
| 81 | frm.set_query('city', () => ({ |
| 82 | filters: { country: frm.doc.country || '' } |
| 83 | })); |
| 84 | }, |
| 85 | country(frm) { |
| 86 | frm.set_value('city', ''); // ALWAYS clear dependent field |
| 87 | } |
| 88 | }); |
| 89 | ``` |
| 90 | |
| 91 | **Rule**: ALWAYS put `set_query` in `setup`. ALWAYS clear child fields when parent changes. |
| 92 | |
| 93 | ## Workflow 5: Calculated Fields (Child Table) |
| 94 | |
| 95 | **Goal**: Calculate row amounts and document totals. |
| 96 | |
| 97 | ```javascript |
| 98 | frappe.ui.form.on('Invoice Item', { |
| 99 | qty(frm, cdt, cdn) { calculate_row(frm, cdt, cdn); }, |
| 100 | rate(frm, cdt, cdn) { calculate_row(frm, cdt, cdn); }, |
| 101 | amount(frm) { calculate_totals(frm); } |
| 102 | }); |
| 103 | |
| 104 | frappe.ui.form.on('Invoice', { |
| 105 | items_remove(frm) { calculate_totals(frm); } |
| 106 | }); |
| 107 | |
| 108 | function calculate_row(frm, cdt, cdn) { |
| 109 | let row = frappe.get_doc(cdt, cdn); |
| 110 | frappe.model.set_value(cdt, cdn, 'amount', |
| 111 | flt(row.qty) * flt(row.rate)); |
| 112 | } |
| 113 | |
| 114 | function calculate_totals(frm) { |
| 115 | let total = (frm.doc.items || []).reduce( |
| 116 | (sum, row) => sum + flt(row.amount), 0); |
| 117 | frm.set_value('grand_total', flt(total, 2)); |
| 118 | } |
| 119 | ``` |
| 120 | |
| 121 | **Rules**: |
| 122 | - ALWAYS use `flt()` for numeric operations (handles null/undefined) |
| 123 | - ALWAYS handle `items_remove` — totals must r |