frappe-doctype-form-view
Frappe DocType Form Controller (.js)
See frappe-doctype-schema for the .json schema.
See frappe-doctype-controller for the .py server controller.
See frappe-doctype-list-view for the _list.js list view controller.
See frappe-doctype-tests for writing DocType controller Python tests.
See frappe-js-api for full option tables on frappe.call, frappe.db.*, dialogs, datetime, and all other globals.
Full per-hook details: REFERENCE.md.
Common use-case examples: EXAMPLES.md.
Quick Start
// myapp/mymodule/doctype/my_doc_type/my_doc_type.js
frappe.ui.form.on('My Doc Type', {
setup(frm) {
// runs ONCE per doctype load — wire set_query, one-time event listeners
frm.set_query('customer', () => ({
filters: { disabled: 0 },
}))
},
onload(frm) {
// runs when a new docname is loaded for the first time
// use for defaults that need server context (frm.doc.name exists here)
if (frm.is_new()) {
frm.set_value('posting_date', frappe.datetime.get_today())
}
},
refresh(frm) {
// runs on EVERY render — initial load, after save, after submit/cancel
// ALL conditional buttons and field visibility logic lives here
frm.add_custom_button(__('Run Report'), () => {
// called synchronously as user action
})
frm.toggle_display('tax_id', frm.doc.party_type === 'Company')
},
customer(frm) {
// field trigger: fires when frm.doc.customer changes
if (!frm.doc.customer) {
frm.set_value('customer_name', '')
}
},
})
Placement Rules
These rules prevent the most common JS form bugs. Follow them strictly.
The Golden Three
| Hook | Rule |
|---|---|
setup |
Wire-up only. Call frm.set_query() here (preferred — runs once). Register event listeners that should exist regardless of document state. Never read frm.doc values—the doc may not yet be loaded. |
refresh |
All conditional UI here. Add/remove custom buttons, show/hide fields, toggle reqd/enable. Runs every render—never accumulate state; always start from scratch. |
onload |
One-time initialization. Set default field values. Runs only on first load of a docname, before first refresh. |
Placement Decision Tree
Does it react to a field value changing?
YES → field trigger (fieldname(frm))
NO → Does it run every time the form renders?
YES → refresh(frm)
NO → Is it wiring search queries or one-time listeners?
YES → setup(frm)
NO → Is it setting default values on new docs?
YES → onload(frm) + guard with frm.is_new()
NO → on_submit / before_cancel / validate / etc.
Custom Buttons Are Always Safe in refresh
Framework calls frm.clear_custom_buttons() automatically inside refresh_header(), which runs before the refresh trigger fires in the serial chain:
refresh_header() ← clears all custom buttons
form-refresh (global)
refresh_fields()
script_manager.trigger('refresh') ← your refresh(frm) runs here
So just add your buttons — no manual clear needed:
refresh(frm) {
if (frm.doc.status === 'Pending') {
frm.add_custom_button(__('Approve'), () => approve(frm))
}
}
frm.clear_custom_buttons() manually is only needed if you want to wipe buttons from within a button's click handler or some other non-refresh context.
frm Object — Key Properties
frm.doc // live local document (plain JS object)
frm.doctype // "My Doc Type"
frm.docname // "MY-DOC-0001"
frm.fields_dict // { fieldname: field_control }
frm.is_new() // true when doc is unsaved (no server name)
frm.is_dirty() // true when doc has unsaved changes
frm.page // frappe.ui.Page — page-level buttons, title
frm.dashboard // frappe.ui.form.Dashboard — in-form progress bars, stats
frm.custom_buttons// { label: jQuery$btn } — track added custom buttons
Common APIs — Quick Reference
See REFERENCE.md for full signatures.
Field Visibility / State
frm.toggle_display(fnames, show) // show/hide one or more fields
frm.toggle_reqd(fnames, mandatory) // set required
frm.toggle_enable(fnames, enable) // set editable / read-only
frm.set_df_property(f, prop, value) // set any docfield property ('label', 'options', ...)
Setting Values
await frm.set_value('field', value)
await frm.set_value({ field1: val1, field2: val2 })
Triggering Events Programmatically
frm.trigger(event, cdt?, cdn?) fires a registered event handler as if the user had caused it. Use it to avoid duplicating logic that already lives in a field trigger or a named event.
// Fire a field trigger manually (e.g. after setting a value in code)
async customer(frm) {
await frm.set_value('territory', 'Peru')
// set_value does NOT fire the territory trigger — call it explicitly
frm.trigger('territory')
},
// Fire a named (non-field) event — useful for separating concerns
refresh(frm) {
if (frm.doc.status === 'Cancelled') {
frm.trigger('on_cancel_ui') // delegate display logic to a named handler
}
},
on_cancel_ui(frm) {
frm.toggle_display('cancellation_reason', true)
frm.toggle_reqd('cancellation_reason', true)
},
Rules:
frm.triggeris always synchronous dispatch — it calls the handler immediately but the handler may itself be async. If you need to await its side-effects (e.g. afrappe.callinside it), await the trigger:await frm.trigger('field').- Calling
frm.trigger('refresh')re-runs yourrefreshhook — rarely needed but valid. - For child-table events pass
cdtandcdn:frm.trigger('item_code', cdt, cdn). - Do not trigger
setuporonload— those hooks are lifecycle-managed by the framework.
Buttons
frm.add_custom_button(__('Label'), handler, group) // group=null for top-level
frm.remove_custom_button(__('Label'), group)
frm.clear_custom_buttons()
frm.change_custom_button_type(__('Label'), group, 'primary') // or 'danger', 'warning'
Link Search Filters
// Preferred: in setup(frm) — runs once, no repeated work
frm.set_query('fieldname', () => ({ filters: { active: 1 } }))
frm.set_query('child_fieldname', 'parent_table_field', () => ({ filters: {} }))
Calling Server Methods
Always use frappe.call. Never use frm.call — it hides the method path, making code harder to trace.
Pass callback as a parameter rather than chaining .then() — it keeps the response handler co-located with the call options and is the idiomatic Frappe style.
When calling a document method (a @frappe.whitelist() method on the controller), pass doc: frm.doc and a short method name. The doc carries the doctype, name, and all field values — no need to repeat them in args.
// ✅ GOOD — doc method, short path, callback param
frappe.call({
doc: frm.doc,
method: 'my_function', // short name — no full dotted path needed
freeze: true,
callback(r) { console.log(r.message) },
})
// ✅ GOOD — module-level function, full path required
frappe.call({
method: 'myapp.mymodule.some_module.my_function',
args: { name: frm.doc.name },
freeze: true,
callback(r) { console.log(r.message) },
})
// ❌ BAD — avoid frm.call
frm.call('my_function') // do not use
// ❌ BAD — avoid .then() for response handling
frappe.call({ doc: frm.doc, method: 'my_function' }).then(r => { ... })
Dialogs
// Simple prompt
frappe.prompt([{ label: 'Date', fieldname: 'date', fieldtype: 'Date', reqd: 1 }],
(values) => console.log(values.date), __('Title'), __('Confirm'))
// Full dialog
const d = new frappe.ui.Dialog({
title: __('My Dialog'),
fields: [...],
primary_action(values) { d.hide() },
primary_action_label: __('Submit'),
})
d.show()
Progress Bars
frappe.show_progress(__('Processing'), current, total, __('Description'))
frappe.hide_progress()
// In-form dashboard progress bar (inside the form, no modal dialog)
frm.dashboard.show_progress(__('Processing'), 45, __('Step 2 of 4')) // percent 0–100
frm.dashboard.hide_progress()
Dashboard Headline Alerts
Display a colored alert banner inside the form between the form header and the first field. Persistent until explicitly cleared.
// Primitive — arbitrary HTML, color is one of: 'yellow', 'blue', 'red', 'green', 'orange'
frm.dashboard.set_headline('<b>Pending</b> approval since ' + frm.doc.creation, 'yellow')
frm.dashboard.clear_headline() // remove the banner
// Plain-text shorthand (wraps text in <div> automatically)
frm.dashboard.set_headline_alert(__('On Hold'), 'orange')
frm.dashboard.set_headline_alert(null) // also clears
// Auto-dismissing (clears after 10 seconds unless permanent=true)
frm.dashboard.add_comment(__('Reminder: attach invoice before submitting'), 'yellow')
frm.dashboard.add_comment(__('Settings saved'), 'green', /*permanent=*/false)
Dashboard Indicator Pills
Add a small colored pill in the Stats section below the links area.
frm.dashboard.add_indicator(__('Overdue'), 'red')
frm.dashboard.add_indicator(frm.doc.status, 'blue')
See REFERENCE.md § Dashboard Alerts & Indicators for full signatures and color values.
Child Tables — Short Reference
// Grid reference
const grid = frm.get_field('items').grid
// Add button to grid toolbar
grid.add_custom_button(__('Fill Prices'), () => fillPrices(frm))
// Add button to each row (in refresh of child's frappe.ui.form.on block)
frappe.ui.form.on('My Child DocType', {
my_child_field_on_form_rendered(frm, cdt, cdn) {
// fires when a child row expander opens
}
})
// Accessing a child row doc
const row = frappe.get_doc(cdt, cdn)
frappe.model.set_value(cdt, cdn, 'rate', 100) // set value in child row
See REFERENCE.md § Child Tables for the full grid API.
Reusable Code — The frappe.ui.form.Controller Pattern
The problem
All code inside a .js file executes in the browser's global scope. Declaring helper functions at the top level puts them on window, polluting the global namespace and risking collisions across doctypes.
// ❌ BAD — pollutes window.canTransitionTo
function canTransitionTo(frm, status) { ... }
frappe.ui.form.on('My Doc', {
refresh(frm) {
if (canTransitionTo(frm, 'Active')) { ... } // global leak
},
})
The solution: extend frappe.ui.form.Controller
Frappe's built-in Controller pattern (used internally for DocType and Customize Form) is the canonical way to attach reusable methods to a form without polluting global scope.
// ✅ GOOD — safe namespace, no global pollution
frappe.provide('frappe.model')
frappe.model.MyDocTypeController = class MyDocTypeController extends frappe.ui.form.Controller {
// this.frm is always available (set by the base class constructor)
canTransitionTo(newStatus) {
const allowed = { Draft: ['Active'], Active: ['Archived', 'Draft'] }
return (allowed[this.frm.doc.status] || []).includes(newStatus)
}
isManager() {
return frappe.user_roles.includes('System Manager')
}
}
frappe.ui.form.on('My Doc Type', {
refresh(frm) {
if (!frm.cscript.isManager()) return
if (frm.cscript.canTransitionTo('Active')) {
frm.add_custom_button(__('Activate'), () => frm.trigger('activate'), __('Actions'))
}
},
})
// Must be the last line — merges controller methods into frm.cscript
extend_cscript(cur_frm.cscript, new frappe.model.MyDocTypeController({ frm: cur_frm }))
How it works
| Part | What it does |
|---|---|
frappe.ui.form.Controller |
Base class: constructor(opts) { $.extend(this, opts); } — copies { frm } as instance properties, making this.frm available on every method |
frappe.provide('frappe.model') |
Creates the namespace object safely — a no-op if it already exists, never clobbers |
extend_cscript(frm.cscript, instance) |
Merges controller's own properties and prototype chain into frm.cscript via $.extend + __proto__ assignment, so all methods are accessible via frm.cscript |
frm.cscript.myMethod() |
How event handlers call controller methods |
Namespace conventions
Follow Frappe's own convention — put the controller on a frappe.provide namespace, never on window.
frappe.provide('frappe.model') // Frappe's own doctypes (DocType, Customize Form)
frappe.provide('myapp.model') // ✅ recommended for app-specific doctypes
frappe.provide('myapp.controllers') // acceptable alternative
Naming: <Namespace>.<DocTypeNameInCamelCase>Controller
Example for docttype "Sales Commission Settlement" in app "soldamundo":
frappe.provide('frappe.model')
frappe.model.SalesCommissionSettlementController = class ... extends frappe.ui.form.Controller { ... }
Placement rules for Controller methods
| Put in Controller | Keep in frappe.ui.form.on |
|---|---|
| Permission / role checks | setup, refresh, field triggers |
| State transition guards | Any hook that needs (frm, cdt, cdn) args |
| Helpers called from ≥ 2 event handlers | One-shot logic used only once |
| Constants shared across handlers |
Controller does NOT replace event hooks
Event hooks (setup, refresh, fieldname) must stay in frappe.ui.form.on. The Controller is only for helper methods — not for declaring lifecycle hooks.
Why? If you define
refresh()on the Controller, Frappe calls it as an old-style handler with(doc, cdt, cdn)signature (not(frm, ...)). Mixing old-style and new-style handlers is confusing. Keep all event declarations infrappe.ui.form.on.
Full file structure
// 1. Namespace + Controller class (top of file)
frappe.provide('frappe.model')
frappe.model.MyDocTypeController = class MyDocTypeController extends frappe.ui.form.Controller {
ALLOWED_TRANSITIONS = {
Draft: ['Active'],
Active: ['Archived', 'Draft'],
}
canTransitionTo(newStatus) {
return (this.ALLOWED_TRANSITIONS[this.frm.doc.status] || []).includes(newStatus)
}
isManager() {
return frappe.user_roles.includes('System Manager')
}
}
// 2. Event hooks
frappe.ui.form.on('My Doc Type', {
refresh(frm) {
if (!frm.cscript.isManager()) return
if (frm.cscript.canTransitionTo('Active')) {
frm.add_custom_button(__('Activate'), () => frm.trigger('activate'), __('Actions'))
}
},
activate(frm) {
frappe.confirm(__('Activate this record?'), () => {
frappe.call({ method: 'activate', doc: frm.doc, callback() { frm.reload_doc() } })
})
},
})
// 3. extend_cscript — ALWAYS last line
extend_cscript(cur_frm.cscript, new frappe.model.MyDocTypeController({ frm: cur_frm }))
More from kehwar/skills
to-prd
Turn the current conversation context into a PRD and publish it to Beads. Use when user wants to create a PRD from the current context.
9setup-workflow-skills
Sets up an `## Agent skills` block in AGENTS.md/CLAUDE.md and `docs/agents/` so the engineering skills know this repo uses Beads for issue tracking and where to find domain docs. Run before first use of `to-issues`, `to-prd`, `tdd`, `improve-codebase-architecture`, or `zoom-out` — or if those skills appear to be missing context about the issue tracker or domain docs.
9tdd
Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. Tracks progress in Beads Issue Tracker.
8grill-me
Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
7to-tasks
Break a plan, spec, or PRD into independently-grabbable tasks/issues on Beads Issue Tracker using tracer-bullet vertical slices. Use when user wants to convert a plan into tasks, create implementation tickets, or break down work into tasks.
7improve-codebase-architecture
Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
6