
Frappe Impl Ui Components
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Provides workflows for building Frappe UI components including dialogs, List View customization, Page controllers, Kanban/Calendar views, and realtime updates.
About
An implementation skill for building custom Frappe UI components like dialogs, list views, pages, and realtime updates. A developer uses it to add custom client-side UI and socket-based live data to Frappe.
- Workflows for frappe.ui.Dialog, List View, and Page controllers
- Kanban/Calendar views and realtime updates via frappe.realtime and socket.io
Frappe Impl Ui Components by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,912 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-ui-componentsAdd 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 building Frappe UI components including dialogs, List View customization, Page controllers, Kanban/Calendar views, and realtime updates.
Files
Frappe UI Components & Realtime — Implementation Workflows
Step-by-step workflows for building client-side UI. For form scripting see frappe-impl-clientscripts. For server-side API see frappe-syntax-serverscripts.
Version: v14/v15/v16 | Note: v15+ uses Bootstrap 5; Dialog API is stable across all versions.
Quick Decision: Which UI Component?
WHAT do you need?
├── Prompt user for input → frappe.prompt (simple) or frappe.ui.Dialog (complex)
├── Show a message/alert → frappe.msgprint / frappe.show_alert / frappe.throw
├── Confirm an action → frappe.confirm
├── Multi-field data entry popup → frappe.ui.Dialog with fields
├── Select from a list of records → frappe.ui.form.MultiSelectDialog
├── Full custom page (not a form) → frappe.ui.Page
├── Customize list columns/colors → frappe.listview_settings
├── Visual board for workflow → Kanban Board (Select field based)
├── Date-based record view → Calendar View ({doctype}_calendar.js)
├── Hierarchical data display → Tree View (is_tree DocType)
├── Live updates without refresh → frappe.publish_realtime + frappe.realtime.on
├── Show background job progress → frappe.publish_progress
├── Scan barcode/QR code → frappe.ui.Scanner
└── Custom cell formatting → formatters in listview_settings or formSee references/decision-tree.md for the complete decision tree.
Workflow 1: Dialogs (frappe.ui.Dialog)
Simple Dialog
let d = new frappe.ui.Dialog({
title: "Enter Details",
fields: [
{ label: "Full Name", fieldname: "full_name", fieldtype: "Data", reqd: 1 },
{ label: "Email", fieldname: "email", fieldtype: "Data", options: "Email" },
{ label: "Role", fieldname: "role", fieldtype: "Select",
options: "Developer\nManager\nDesigner" },
],
size: "small", // "small", "large", or "extra-large"
primary_action_label: "Create",
primary_action(values) {
frappe.call({
method: "myapp.api.create_user",
args: values,
callback(r) {
if (!r.exc) {
frappe.show_alert({ message: "User created", indicator: "green" });
d.hide();
}
}
});
}
});
d.show();Rule: ALWAYS call d.hide() inside the callback, NEVER before the async call completes.
Dialog with Table Field
let d = new frappe.ui.Dialog({
title: "Add Items",
fields: [
{ label: "Customer", fieldname: "customer", fieldtype: "Link",
options: "Customer", reqd: 1 },
{ fieldtype: "Section Break" },
{ label: "Items", fieldname: "items", fieldtype: "Table",
in_place_edit: true, reqd: 1,
fields: [
{ fieldname: "item", label: "Item", fieldtype: "Link",
options: "Item", in_list_view: 1, reqd: 1 },
{ fieldname: "qty", label: "Qty", fieldtype: "Int",
in_list_view: 1, default: 1 },
{ fieldname: "rate", label: "Rate", fieldtype: "Currency",
in_list_view: 1 },
],
},
],
primary_action_label: "Submit",
primary_action(values) {
console.log(values); // { customer: "...", items: [{item, qty, rate}] }
d.hide();
}
});
d.show();Rule: ALWAYS set in_list_view: 1 on table child fields you want visible. Fields without it are hidden in the grid.
Multi-Step Dialog
let d = new frappe.ui.Dialog({
title: "Setup Wizard",
fields: [
// Page 1
{ fieldtype: "Section Break", label: "Step 1: Basic Info",
collapsible: 0 },
{ label: "Name", fieldname: "name", fieldtype: "Data", reqd: 1 },
// Page 2
{ fieldtype: "Section Break", label: "Step 2: Configuration",
collapsible: 0 },
{ label: "Option", fieldname: "option", fieldtype: "Select",
options: "A\nB\nC" },
],
primary_action_label: "Finish",
primary_action(values) {
d.hide();
}
});
d.show();Key Dialog Methods
| Method | Purpose |
|---|---|
d.show() | Display the dialog |
d.hide() | Close the dialog |
d.get_values() | Get all field values as object |
d.set_values({field: val}) | Set field values |
d.get_field("name") | Get a specific field control |
d.set_df_property("name", "hidden", 1) | Show/hide fields dynamically |
d.disable_primary_action() | Grey out submit button |
d.enable_primary_action() | Re-enable submit button |
Workflow 2: Messages & Alerts
frappe.msgprint: Modal Message
// Simple message
frappe.msgprint("Record saved successfully");
// With options
frappe.msgprint({
title: "Warning",
message: "This action cannot be undone",
indicator: "orange", // green, blue, orange, red
primary_action: {
label: "Proceed",
action() { do_something(); }
}
});
// List of messages
frappe.msgprint({
title: "Validation Errors",
message: "Please fix the following:",
as_list: true,
indicator: "red",
});frappe.throw: Error with Exception
// Client-side: shows msgprint and stops execution
frappe.throw("Amount cannot be negative");# Server-side: raises ValidationError, shown as red msgprint
frappe.throw("Amount cannot be negative")
frappe.throw("Not Permitted", frappe.PermissionError) # specific exceptionRule: ALWAYS use frappe.throw for validation errors. NEVER use frappe.msgprint for errors — it does not stop execution.
frappe.confirm: Yes/No Dialog
frappe.confirm(
"Are you sure you want to delete this record?",
() => { /* Yes callback */ delete_record(); },
() => { /* No callback (optional) */ }
);frappe.prompt: Quick Single-Field Input
frappe.prompt(
{ label: "Reason", fieldname: "reason", fieldtype: "Small Text", reqd: 1 },
(values) => {
console.log(values.reason);
},
"Enter Reason", // dialog title
"Submit" // primary action label
);
// Multiple fields
frappe.prompt([
{ label: "Reason", fieldname: "reason", fieldtype: "Small Text", reqd: 1 },
{ label: "Priority", fieldname: "priority", fieldtype: "Select",
options: "Low\nMedium\nHigh" },
], (values) => { console.log(values); }, "Details");frappe.show_alert: Toast Notification
// Simple
frappe.show_alert("Saved");
// With indicator and duration
frappe.show_alert({ message: "Email sent", indicator: "green" }, 5);
// Duration in seconds (default: 7)Rule: Use frappe.show_alert for non-blocking success messages. Use frappe.msgprint when the user MUST acknowledge.
Workflow 3: List View Customization
Create {doctype_name}_list.js in the DocType directory:
// myapp/doctype/task/task_list.js
frappe.listview_settings["Task"] = {
// Extra fields to fetch (beyond standard)
add_fields: ["priority", "status", "assigned_to"],
// Hide the name column
hide_name_column: true,
// Row indicator (colored dot)
get_indicator(doc) {
// MUST return [label, color, comma-separated-filter]
if (doc.status === "Completed") return ["Completed", "green", "status,=,Completed"];
if (doc.status === "Overdue") return ["Overdue", "red", "status,=,Overdue"];
return ["Open", "orange", "status,=,Open"];
},
// Custom column formatters
formatters: {
priority(val) {
const colors = { High: "red", Medium: "orange", Low: "green" };
return `<span class="indicator-pill ${colors[val] || ""}">${val}</span>`;
}
},
// Row action button
button: {
show(doc) { return doc.status === "Open"; },
get_label() { return __("Complete"); },
get_description(doc) { return __("Mark {0} as complete", [doc.name]); },
action(doc) {
frappe.xcall("myapp.api.complete_task", { task: doc.name })
.then(() => cur_list.refresh());
}
},
// Lifecycle hooks
onload(listview) {
listview.page.add_inner_button("Export", () => export_tasks());
},
refresh(listview) {
// Runs on every list refresh
},
// Default filters
filters: [["status", "!=", "Cancelled"]],
};Rule: ALWAYS return a 3-element array from get_indicator. The third element is the filter string for click-to-filter.
Workflow 4: Custom Page (frappe.ui.Page)
Step 1: Register in hooks.py
# hooks.py
page_js = { "my-custom-page": "public/js/my_custom_page.js" }Step 2: Create page definition
// myapp/myapp/my_custom_page/my_custom_page.js
frappe.pages["my-custom-page"].on_page_load = function(wrapper) {
let page = frappe.ui.make_app_page({
parent: wrapper,
title: "My Custom Page",
single_column: true,
});
// Primary action button
page.set_primary_action("Create", () => create_new(), "octicon octicon-plus");
// Secondary action
page.set_secondary_action("Refresh", () => refresh_data());
// Dropdown menu
page.add_menu_item("Export CSV", () => export_csv());
page.add_menu_item("Settings", () => frappe.set_route("Form", "My Settings"));
// Inner toolbar buttons
page.add_inner_button("Update All", () => update_all());
page.add_inner_button("New Post", () => new_post(), "Make"); // grouped
// Toolbar filter fields
let status_field = page.add_field({
label: "Status",
fieldtype: "Select",
fieldname: "status",
options: ["", "Open", "Closed", "Cancelled"],
change() { refresh_data(); }
});
// Status indicator
page.set_indicator("Active", "green");
// Content area
$(page.body).html(`<div class="my-page-content"></div>`);
// Load initial data
refresh_data();
};Key Page Methods
| Method | Purpose |
|---|---|
page.set_title(title) | Set page heading |
page.set_indicator(label, color) | Status badge (green/red/orange/blue) |
page.set_primary_action(label, fn, icon) | Main action button |
page.set_secondary_action(label, fn) | Secondary button |
page.add_menu_item(label, fn) | Dropdown menu entry |
page.add_inner_button(label, fn, group) | Toolbar button (optional group) |
page.add_field({...}) | Add filter/input to toolbar |
page.get_form_values() | Get all toolbar field values |
page.clear_fields() | Remove all toolbar fields |
page.clear_primary_action() | Remove primary button |
Workflow 5: Calendar View
Create {doctype}_calendar.js in the DocType directory:
// myapp/doctype/event/event_calendar.js
frappe.views.calendar["Event"] = {
field_map: {
start: "starts_on",
end: "ends_on",
id: "name",
title: "subject",
allDay: "all_day",
color: "color",
},
gantt: true, // Enable Gantt view toggle
get_events_method: "myapp.api.get_events", // Optional custom event source
filters: [
{ fieldtype: "Link", fieldname: "event_type", label: "Type",
options: "Event Type" }
],
};Rule: ALWAYS map start and end to actual Date or Datetime fields on the DocType. Missing mappings cause blank calendars.
Workflow 6: Kanban Board
Kanban boards work on any DocType with a Select field. No code needed:
1. Open List View → sidebar → Kanban → New Kanban Board 2. Select the Select field (e.g., status) — options become columns 3. Save — cards are draggable between columns
Rule: NEVER create Kanban boards for DocTypes without a Select field. See references/examples.md for programmatic configuration.
Workflow 7: Realtime Updates (Socket.IO)
Server: Publish Events
# Broadcast to all users
frappe.publish_realtime("task_updated", {"task": task.name, "status": "Done"})
# Send to specific user
frappe.publish_realtime("notification", {"msg": "Your report is ready"},
user="admin@example.com")
# Send to users viewing a specific document
frappe.publish_realtime("doc_updated", {"field": "status"},
doctype="Task", docname="TASK-001")
# ALWAYS use after_commit=True in document events
frappe.publish_realtime("order_created", message, after_commit=True)Client: Subscribe to Events
// Listen for events
frappe.realtime.on("task_updated", (data) => {
frappe.show_alert({ message: `Task ${data.task}: ${data.status}`, indicator: "green" });
cur_list && cur_list.refresh();
});
// Stop listening
frappe.realtime.off("task_updated");Progress Indicator
# Server: publish progress during long operations
def process_items(items):
total = len(items)
for i, item in enumerate(items):
process(item)
frappe.publish_progress(
percent=(i + 1) / total * 100,
title="Processing Items",
description=f"Processing {item.name}",
)Rule: ALWAYS use after_commit=True when publishing from document events. Without it, the event fires even if the transaction rolls back.
Realtime Rooms
| Room | Audience | Use Case |
|---|---|---|
| (default) | All System Users | Global notifications |
user:{email} | Single user | Personal alerts |
doctype:{dt} | Users viewing list | List refresh triggers |
doc:{dt}/{name} | Users viewing document | Document change alerts |
website | All users including guests | Public announcements |
Workflow 8: Scanner API (Barcode/QR)
// Single scan — closes after first scan
new frappe.ui.Scanner({
dialog: true, multiple: false,
on_scan(data) {
frappe.set_route("Form", "Item", data.decodedText);
}
});
// Continuous scanning — stays open for multiple scans
let scanner = new frappe.ui.Scanner({
dialog: true, multiple: true,
on_scan(data) { add_item_to_list(data.decodedText); }
});
// Stop: scanner.stop_scan() or close the dialogRule: ALWAYS set multiple: false for single-item lookups. See references/examples.md for a full barcode-in-Stock-Entry example.
Anti-Patterns Summary
| Anti-Pattern | Correct Approach |
|---|---|
frappe.msgprint for errors | Use frappe.throw — it stops execution |
| Hiding dialog before async completes | Hide in the callback: callback() { d.hide(); } |
| Synchronous API calls in dialogs | ALWAYS use frappe.call / frappe.xcall (async) |
Missing in_list_view on table fields | Set in_list_view: 1 on visible columns |
publish_realtime without after_commit | ALWAYS use after_commit=True in doc events |
| Kanban on DocType without Select field | Kanban requires a Select field for columns |
| Missing start/end in calendar field_map | ALWAYS map both start and end fields |
| 2-element array from get_indicator | ALWAYS return 3 elements: [label, color, filter] |
Reference Files
references/controls-api.md— Standalone controls viafrappe.ui.form.make_control(), full control type reference, control methods and eventsreferences/tree-view.md— Tree DocType configuration,frappe.views.TreeViewAPI,frappe.ui.Treelow-level API, tree node operationsreferences/workflows.md— Extended workflow walkthroughsreferences/examples.md— Complete code examplesreferences/decision-tree.md— Full UI component decision treereferences/anti-patterns.md— Expanded anti-patterns with code examples
See Also
frappe-impl-clientscripts— Form-level client scriptsfrappe-syntax-clientscripts— Client-side API syntax referencefrappe-impl-hooks— Hook registration for pages and routes
UI Components Anti-Patterns
AP-1: Using frappe.msgprint for Errors
Wrong:
if (!frm.doc.customer) {
frappe.msgprint("Customer is required");
// Execution continues! Form may still save.
}Correct: ALWAYS use frappe.throw for validation errors — it stops execution.
if (!frm.doc.customer) {
frappe.throw(__("Customer is required"));
// Execution stops here
}AP-2: Hiding Dialog Before Async Call Completes
Wrong:
primary_action(values) {
d.hide(); // Dialog closes immediately
frappe.call({ method: "myapp.api.create", args: values });
// User has no feedback if the call fails
}Correct: ALWAYS hide in the callback after success.
primary_action(values) {
d.disable_primary_action(); // Prevent double-click
frappe.call({
method: "myapp.api.create",
args: values,
callback(r) {
if (!r.exc) d.hide();
},
always() {
d.enable_primary_action(); // Re-enable on success or failure
}
});
}AP-3: Synchronous Calls in UI Components
Wrong:
primary_action(values) {
let result = frappe.call({ method: "myapp.api.check", args: values, async: false });
// Freezes the entire browser tab
}Correct: ALWAYS use async calls. Use frappe.xcall for promise-based flow.
primary_action(values) {
frappe.xcall("myapp.api.check", values).then(result => {
// Handle result
});
}AP-4: Missing in_list_view on Table Fields
Wrong:
fields: [
{ fieldname: "item", label: "Item", fieldtype: "Link", options: "Item" },
{ fieldname: "qty", label: "Qty", fieldtype: "Int" },
]
// Both fields are hidden in the grid — user sees empty rowsCorrect: ALWAYS set in_list_view: 1 on fields you want visible in the table grid.
fields: [
{ fieldname: "item", label: "Item", fieldtype: "Link", options: "Item",
in_list_view: 1 },
{ fieldname: "qty", label: "Qty", fieldtype: "Int", in_list_view: 1 },
]AP-5: publish_realtime Without after_commit
Wrong:
def on_update(self):
frappe.publish_realtime("order_updated", {"name": self.name})
# If the transaction rolls back, the event was already sent!Correct: ALWAYS use after_commit=True in document lifecycle events.
def on_update(self):
frappe.publish_realtime("order_updated", {"name": self.name}, after_commit=True)AP-6: Kanban Board on DocType Without Select Field
Wrong: Creating a Kanban Board for a DocType that has no Select field — the board has no columns.
Correct: ALWAYS ensure the target DocType has a Select field with the status options. The Select field's options become the Kanban columns.
AP-7: Missing Field Mapping in Calendar View
Wrong:
frappe.views.calendar["Event"] = {
field_map: {
start: "starts_on",
// Missing "end" — all events appear as zero-duration
}
};Correct: ALWAYS map both start and end fields.
frappe.views.calendar["Event"] = {
field_map: {
start: "starts_on",
end: "ends_on",
id: "name",
title: "subject",
}
};AP-8: Two-Element Array from get_indicator
Wrong:
get_indicator(doc) {
return ["Active", "green"]; // Missing filter — click does nothing
}Correct: ALWAYS return 3 elements. The third is the filter applied when clicking.
get_indicator(doc) {
return ["Active", "green", "status,=,Active"];
}AP-9: Not Cleaning Up Realtime Listeners
Wrong:
onload(frm) {
frappe.realtime.on("my_event", handler);
// Every form load adds another listener — memory leak, duplicate handling
}Correct: ALWAYS clean up listeners when leaving the form.
onload(frm) {
frappe.realtime.off("my_event"); // Remove previous listener first
frappe.realtime.on("my_event", handler);
}AP-10: Blocking UI During Long Operations
Wrong: Running a long server call without any progress feedback.
Correct: Use frappe.publish_progress on the server and optionally freeze: true with a message on the client.
frappe.call({
method: "myapp.api.long_operation",
args: { ... },
freeze: true,
freeze_message: __("Processing, please wait..."),
});Controls API Reference
Extended reference for standalone controls, control types, and advanced control patterns. Parent skill: frappe-impl-ui-components
frappe.ui.form.make_control() — Standalone Controls
Create Frappe controls outside of forms — in custom pages, dialogs, or arbitrary DOM containers.
Signature
frappe.ui.form.make_control({
parent: HTMLElement | jQuery, // Container element
df: { // Field definition object
fieldtype: "Data",
fieldname: "my_field",
label: "My Field",
// ... any standard field properties
},
render_input: true, // MUST be true to render the actual input element
});How It Works
The factory function maps fieldtype to a class name: "Control" + fieldtype.replace(/ /g, ""). For example, fieldtype: "Small Text" resolves to frappe.ui.form.ControlSmallText.
Rule: ALWAYS set render_input: true when creating standalone controls. Without it, only the wrapper is created — no input element is rendered.
Basic Example — Standalone Control on a Page
frappe.pages["my-page"].on_page_load = function(wrapper) {
let page = frappe.ui.make_app_page({
parent: wrapper,
title: "My Page",
single_column: true,
});
let $container = $('<div class="my-controls">').appendTo(page.body);
// Create a standalone Link control
let customer_control = frappe.ui.form.make_control({
parent: $container,
df: {
fieldtype: "Link",
fieldname: "customer",
label: "Customer",
options: "Customer",
change() {
let value = customer_control.get_value();
if (value) load_customer_data(value);
}
},
render_input: true,
});
// Create a standalone Date control
let date_control = frappe.ui.form.make_control({
parent: $container,
df: {
fieldtype: "Date",
fieldname: "from_date",
label: "From Date",
default: frappe.datetime.month_start(),
},
render_input: true,
});
// Set a value programmatically
customer_control.set_value("CUST-001");
};Control Methods (BaseControl / BaseInput)
All controls inherit from frappe.ui.form.Control (BaseControl):
| Method | Purpose |
|---|---|
control.get_value() | Get current value |
control.set_value(value) | Set value (returns Promise) |
control.refresh() | Re-render the control based on current state |
control.toggle(show) | Show/hide the control |
control.set_description(text) | Set help text below the control |
control.set_mandatory(value) | Set/unset required state |
Input controls (BaseInput subclasses) additionally have:
| Method | Purpose |
|---|---|
control.set_input(value) | Set the DOM input value directly |
control.get_input_value() | Read raw DOM input value |
control.validate(value) | Run validation on a value |
control.set_invalid() | Mark the control as invalid (red border) |
control.set_disp_area(value) | Set the read-only display value |
Control Events
Controls support these event hooks in the df (field definition):
let control = frappe.ui.form.make_control({
parent: $wrapper,
df: {
fieldtype: "Data",
fieldname: "email",
label: "Email",
// Event hooks:
change() {
// Fires when value changes (user input or set_value)
console.log("New value:", this.get_value());
},
onchange() {
// Alternative to change — same behavior
},
onchange_modified: true, // Only fire change if value actually differs
},
render_input: true,
});Rule: Use change for standalone controls. Use onchange in form field definitions. Both work, but change is the convention for standalone use.
before_render Event
The before_render hook is available on form-level controls via Client Script:
// In a Client Script for a DocType
frappe.ui.form.on("Sales Invoice", {
before_render(frm) {
// Runs before the form renders — configure controls here
// Useful for setting up dynamic field properties
frm.fields_dict.customer.df.read_only = 1;
}
});For standalone controls, use the constructor to configure before rendering, or call methods after make_control() but before appending to the visible DOM.
Complete Control Type Reference
Text Input Controls
| Fieldtype | Options | Notes |
|---|---|---|
Data | "Email", "Name", "Phone", "URL", "Barcode" | Options add validation |
Small Text | — | Textarea, 3-4 rows |
Text | — | Textarea, larger |
Long Text | — | Textarea, even larger |
Password | — | Masked input |
Read Only | — | Display-only text |
Number Controls
| Fieldtype | Options | Notes |
|---|---|---|
Int | — | Integer only |
Float | — | Decimal number |
Currency | "currency_field" or "USD" | Formatted with currency symbol |
Percent | — | 0-100 with % symbol |
Date/Time Controls
| Fieldtype | Options | Notes |
|---|---|---|
Date | — | Calendar picker |
Time | — | Time picker |
Datetime | — | Combined date + time |
Date Range | — | Returns [start, end] array |
Duration | "hide_days" | Duration in seconds |
Selection Controls
| Fieldtype | Options | Notes |
|---|---|---|
Select | "Option1\nOption2\nOption3" or ["A","B","C"] | Dropdown |
Link | "DocType Name" | Autocomplete linked record |
Dynamic Link | "field_holding_doctype" | Link with dynamic DocType |
Autocomplete | ["val1","val2"] | Free-text with suggestions |
MultiSelect | ["opt1","opt2"] | Multiple selection pills |
MultiCheck | [{label,value,checked}] | Checkbox grid, columns: N |
Table MultiSelect | "Child DocType" | Table-based multiselect |
Rich Content Controls
| Fieldtype | Options | Notes |
|---|---|---|
Text Editor | — | Quill WYSIWYG editor |
Markdown Editor | — | Markdown with preview |
HTML Editor | — | Raw HTML editing |
Code | "JavaScript", "Python", "HTML", "CSS", "JSON" | Syntax highlighting, wrap: true, max_lines: N |
Comment | — | Comment input with mentions |
Media Controls
| Fieldtype | Options | Notes |
|---|---|---|
Attach | — | File upload (any type) |
Attach Image | — | Image upload with preview |
Image | "image_field" | Display-only image |
Barcode | — | Barcode display/scan |
Signature | — | Draw signature |
Special Controls
| Fieldtype | Options | Notes |
|---|---|---|
Check | — | Checkbox (0/1) |
Color | — | Color picker |
Rating | — | Star rating (0-1 float) |
Geolocation | — | Map with coordinates |
Icon | — | Icon selector |
Button | — | Action button, btn_size: "xs"/"sm"/"lg" |
HTML | — | Raw HTML block |
Heading | — | Section heading |
JSON | — | JSON editor |
Phone | — | Phone input with country code |
Layout Controls (no data)
| Fieldtype | Notes |
|---|---|
Section Break | Start new section, collapsible: 1 |
Column Break | Start new column within section |
Tab Break | Start new tab (v14+) |
Custom Form Layouts with Standalone Controls
Pattern: Dashboard Widget with Controls
class MyDashboard {
constructor(parent) {
this.$wrapper = $('<div class="my-dashboard">').appendTo(parent);
this.make_filters();
this.make_chart_area();
}
make_filters() {
let $filters = $('<div class="filter-row d-flex gap-2">').appendTo(this.$wrapper);
this.company = frappe.ui.form.make_control({
parent: $('<div>').appendTo($filters),
df: {
fieldtype: "Link",
fieldname: "company",
label: "Company",
options: "Company",
default: frappe.defaults.get_default("company"),
change: () => this.refresh(),
},
render_input: true,
});
this.period = frappe.ui.form.make_control({
parent: $('<div>').appendTo($filters),
df: {
fieldtype: "Select",
fieldname: "period",
label: "Period",
options: ["Monthly", "Quarterly", "Yearly"],
default: "Monthly",
change: () => this.refresh(),
},
render_input: true,
});
}
refresh() {
let company = this.company.get_value();
let period = this.period.get_value();
if (company) {
this.load_data(company, period);
}
}
}Pattern: Standalone Dialog with Custom Control Layout
function show_custom_dialog() {
let d = new frappe.ui.Dialog({ title: "Advanced Search", size: "large" });
// Use make_control inside dialog body for custom layouts
let $row = $('<div class="row">').appendTo(d.body);
let $left = $('<div class="col-6">').appendTo($row);
let $right = $('<div class="col-6">').appendTo($row);
let search = frappe.ui.form.make_control({
parent: $left,
df: { fieldtype: "Data", fieldname: "search", label: "Search Term" },
render_input: true,
});
let doctype_filter = frappe.ui.form.make_control({
parent: $right,
df: {
fieldtype: "Link",
fieldname: "doctype",
label: "DocType",
options: "DocType",
},
render_input: true,
});
let $results = $('<div class="search-results mt-3">').appendTo(d.body);
d.set_primary_action("Search", () => {
let term = search.get_value();
let dt = doctype_filter.get_value();
run_search(term, dt, $results);
});
d.show();
}Anti-Patterns
| Anti-Pattern | Correct Approach |
|---|---|
make_control without render_input: true | ALWAYS pass render_input: true for standalone controls |
Reading value before set_value Promise resolves | ALWAYS await control.set_value(x) or use .then() |
Using control.value directly | Use control.get_value() — it applies formatting/parsing |
Forgetting options on Link fields | ALWAYS set options: "DocType" — without it, the Link control has no target |
| Creating controls on hidden elements | Create AFTER the parent is visible, or call control.refresh() after showing |
UI Components — Decision Tree
Which Component Do I Need?
START: What is the interaction?
│
├── USER NEEDS TO PROVIDE INPUT
│ ├── Single value (text, number, date)?
│ │ └── frappe.prompt (quick, lightweight)
│ ├── Multiple fields (form-like)?
│ │ └── frappe.ui.Dialog with fields array
│ ├── Select records from a DocType?
│ │ └── frappe.ui.form.MultiSelectDialog
│ ├── Tabular data entry (rows of items)?
│ │ └── frappe.ui.Dialog with Table fieldtype
│ └── Yes/No decision?
│ └── frappe.confirm
│
├── SYSTEM NEEDS TO SHOW INFORMATION
│ ├── User MUST acknowledge?
│ │ └── frappe.msgprint (modal, blocks interaction)
│ ├── Non-blocking notification?
│ │ └── frappe.show_alert (toast, auto-dismisses)
│ ├── Error that stops execution?
│ │ └── frappe.throw (client) / frappe.throw (server)
│ └── Background task progress?
│ └── frappe.publish_progress (server → client)
│
├── CUSTOMIZE AN EXISTING VIEW
│ ├── List View columns, indicators, buttons?
│ │ └── frappe.listview_settings in {doctype}_list.js
│ ├── Date-based record visualization?
│ │ └── Calendar View via {doctype}_calendar.js
│ ├── Workflow status board (drag-drop)?
│ │ └── Kanban Board (requires Select field on DocType)
│ ├── Hierarchical parent-child display?
│ │ └── Tree View (requires is_tree on DocType)
│ └── Custom cell/value formatting?
│ └── formatters in listview_settings or form
│
├── BUILD A COMPLETE NEW PAGE
│ ├── Dashboard or tool page?
│ │ └── frappe.ui.Page (full toolbar, sidebar, body)
│ ├── Report-style page?
│ │ └── Query Report or Script Report (see frappe-impl-reports)
│ └── Public-facing page?
│ └── Portal page in www/ (see frappe-impl-website)
│
├── LIVE UPDATES WITHOUT REFRESH
│ ├── Notify specific user?
│ │ └── frappe.publish_realtime(event, data, user=email)
│ ├── Update all viewers of a document?
│ │ └── frappe.publish_realtime(event, data, doctype=dt, docname=name)
│ ├── Broadcast to all users?
│ │ └── frappe.publish_realtime(event, data)
│ └── Show progress bar?
│ └── frappe.publish_progress(percent, title, description)
│
└── SCAN INPUT
├── Single barcode/QR lookup?
│ └── frappe.ui.Scanner({ dialog: true, multiple: false })
└── Continuous scanning (warehouse)?
└── frappe.ui.Scanner({ dialog: true, multiple: true })Message Type Selection
What kind of message?
├── Error (must stop) → frappe.throw("message")
├── Warning (must acknowledge) → frappe.msgprint({ indicator: "orange" })
├── Info (must acknowledge) → frappe.msgprint("message")
├── Success (non-blocking) → frappe.show_alert({ indicator: "green" })
├── Confirm before action → frappe.confirm("question", yes_fn, no_fn)
└── Quick input needed → frappe.prompt(fields, callback, title)Dialog Size Selection
How much content in the dialog?
├── 1-3 simple fields → size: "small"
├── 4-8 fields, no table → (default, no size needed)
├── Table field or many fields → size: "large"
└── Complex multi-section form → size: "extra-large"UI Components Examples — Complete Code
Example 1: Confirmation Dialog Before Dangerous Action
frappe.ui.form.on("Sales Order", {
custom_cancel_all_items(frm) {
frappe.confirm(
__("This will cancel all {0} items. Continue?", [frm.doc.items.length]),
() => {
frappe.xcall("myapp.api.cancel_all_items", { order: frm.doc.name })
.then(() => {
frm.reload_doc();
frappe.show_alert({ message: "All items cancelled",
indicator: "green" });
});
}
);
}
});Example 2: Progress Bar for Bulk Operation
# Server
@frappe.whitelist()
def process_invoices(invoices):
invoices = frappe.parse_json(invoices)
total = len(invoices)
for i, inv in enumerate(invoices):
submit_invoice(inv)
frappe.publish_progress(
percent=int((i + 1) / total * 100),
title="Submitting Invoices",
description=f"Processing {inv} ({i+1}/{total})",
)
return {"processed": total}// Client
frappe.xcall("myapp.api.process_invoices", {
invoices: selected_invoices
}).then(r => {
frappe.msgprint(__("{0} invoices processed", [r.processed]));
});
// Progress bar appears automatically via frappe.publish_progressExample 3: Dynamic Dialog — Fields Change Based on Selection
let d = new frappe.ui.Dialog({
title: "New Entry",
fields: [
{ label: "Type", fieldname: "type", fieldtype: "Select",
options: "Expense\nIncome\nTransfer", reqd: 1,
change() {
let type = d.get_value("type");
d.set_df_property("expense_account", "hidden", type !== "Expense");
d.set_df_property("income_account", "hidden", type !== "Income");
d.set_df_property("transfer_to", "hidden", type !== "Transfer");
}
},
{ label: "Amount", fieldname: "amount", fieldtype: "Currency", reqd: 1 },
{ label: "Expense Account", fieldname: "expense_account",
fieldtype: "Link", options: "Account", hidden: 1 },
{ label: "Income Account", fieldname: "income_account",
fieldtype: "Link", options: "Account", hidden: 1 },
{ label: "Transfer To", fieldname: "transfer_to",
fieldtype: "Link", options: "Account", hidden: 1 },
],
primary_action_label: "Save",
primary_action(values) {
frappe.xcall("myapp.api.create_entry", values).then(() => d.hide());
}
});
d.show();Example 4: Scanner in Stock Entry
frappe.ui.form.on("Stock Entry", {
custom_scan_items(frm) {
let scanner = new frappe.ui.Scanner({
dialog: true,
multiple: true,
on_scan(data) {
let barcode = data.decodedText;
// Prevent duplicate scans
let exists = frm.doc.items.find(d => d.barcode === barcode);
if (exists) {
exists.qty += 1;
frm.refresh_field("items");
frappe.show_alert({ message: `${barcode}: qty +1`,
indicator: "blue" });
return;
}
frappe.xcall("erpnext.stock.utils.get_item_by_barcode",
{ barcode }
).then(item => {
if (item) {
let row = frm.add_child("items");
frappe.model.set_value(row.doctype, row.name, {
item_code: item.item_code,
barcode: barcode,
qty: 1,
});
frm.refresh_field("items");
frappe.show_alert({ message: `Added: ${item.item_name}`,
indicator: "green" });
} else {
frappe.show_alert({ message: `Unknown barcode: ${barcode}`,
indicator: "red" });
}
});
}
});
}
});Example 5: Realtime Document Collaboration Indicator
# Server: track who is viewing a document
@frappe.whitelist()
def register_viewer(doctype, docname):
frappe.publish_realtime(
"viewer_joined",
{"user": frappe.session.user, "full_name": frappe.utils.get_fullname()},
doctype=doctype,
docname=docname,
after_commit=True,
)// Client: show active viewers
frappe.ui.form.on("Project", {
onload(frm) {
// Register self as viewer
frappe.xcall("myapp.api.register_viewer", {
doctype: frm.doctype, docname: frm.docname
});
// Listen for other viewers
frappe.realtime.on("viewer_joined", (data) => {
frappe.show_alert({
message: __("{0} is also viewing this document", [data.full_name]),
indicator: "blue"
}, 5);
});
},
before_unload(frm) {
frappe.realtime.off("viewer_joined");
}
});Example 6: Custom Formatter in List View
frappe.listview_settings["Payment Entry"] = {
add_fields: ["payment_type", "paid_amount", "status"],
formatters: {
paid_amount(val, df, doc) {
// Color code by amount threshold
let color = val > 10000 ? "red" : val > 1000 ? "orange" : "green";
return `<span style="color: var(--${color})">${format_currency(val)}</span>`;
},
payment_type(val) {
const icons = { Receive: "↓", Pay: "↑", "Internal Transfer": "↔" };
return `${icons[val] || ""} ${val}`;
}
},
get_indicator(doc) {
return {
Draft: ["Draft", "red", "docstatus,=,0"],
Submitted: ["Submitted", "blue", "docstatus,=,1"],
Cancelled: ["Cancelled", "darkgrey", "docstatus,=,2"],
}[doc.status] || ["", "grey", ""];
}
};Example 7: Tree View for Nested Categories
Tree View is automatic for DocTypes with is_tree = 1. Configuration:
# In DocType JSON or via code
# Required fields (auto-added for tree DocTypes):
# - parent_{doctype_name} (Link to self)
# - is_group (Check)
# - lft, rgt (Int — Nested Set Model, managed by Frappe)// Optional: customize tree behavior via {doctype}_tree.js
frappe.treeview_settings["Department"] = {
breadcrumb: "HR",
title: "Department Tree",
filters: [
{ fieldname: "company", fieldtype: "Link", options: "Company",
label: "Company", default: frappe.defaults.get_default("company") }
],
get_tree_root: false, // Show root nodes
root_label: "All Departments",
onload(treeview) {
treeview.page.add_inner_button("Expand All", () => {
treeview.tree.load_children(treeview.tree.root_node);
});
}
};Tree View Reference
Extended reference for Frappe tree views, tree DocType configuration, and tree node operations. Parent skill: frappe-impl-ui-components
Tree DocType Configuration
A Tree DocType uses the Nested Set Model (NSM) to store hierarchical data. Frappe automatically adds lft, rgt, old_parent, and is_group fields.
Step 1: Enable Tree on DocType
In the DocType definition, check Is Tree (is_tree = 1). This:
- Adds
lft(Int),rgt(Int) columns for nested set ordering - Adds
parent_{scrubbed_doctype}field (Link to self) as the parent pointer - Adds
old_parentfield (Data) for change detection - Adds
is_groupfield (Check) to distinguish branches from leaves - Enables the Tree View route:
/app/{doctype}/view/tree
Step 2: Parent Field Convention
The parent field follows the naming convention: parent_ + scrubbed DocType name.
| DocType | Parent Field |
|---|---|
Item Group | parent_item_group |
Territory | parent_territory |
Cost Center | parent_cost_center |
Department | parent_department |
To override, set nsm_parent_field on the controller:
class MyTreeDocType(NestedSet):
nsm_parent_field = "custom_parent" # Override default namingStep 3: Controller Setup
# myapp/doctype/my_category/my_category.py
from frappe.utils.nestedset import NestedSet
class MyCategory(NestedSet):
# nsm_parent_field = "parent_my_category" # auto-detected by default
def on_update(self):
super().on_update() # CRITICAL: calls update_nsm() for tree rebalancing
# Custom logic after tree update
def validate(self):
super().validate()
# Custom validationRule: ALWAYS inherit from frappe.utils.nestedset.NestedSet for tree DocTypes. ALWAYS call super().on_update() — without it, lft/rgt values are never updated and the tree breaks.
Key NestedSet Methods (Server-Side)
| Method | Purpose |
|---|---|
get_ancestors() | List of all parent nodes up to root |
get_parent() | Immediate parent document |
get_children() | Direct child documents |
is_ancestor_of(node) | Check if current node is ancestor of another |
is_group | Check field — 1 = can have children, 0 = leaf |
Server-Side Tree Queries
# Get all ancestors of a node
ancestors = frappe.get_all("Territory",
filters={"lft": ["<", node.lft], "rgt": [">", node.rgt]},
order_by="lft desc"
)
# Get all descendants of a node
descendants = frappe.get_all("Territory",
filters={"lft": [">", node.lft], "rgt": ["<", node.rgt]},
order_by="lft"
)
# Get direct children only
children = frappe.get_all("Territory",
filters={"parent_territory": node.name},
order_by="name"
)
# Rebuild tree if lft/rgt values get corrupted
from frappe.utils.nestedset import rebuild_tree
rebuild_tree("Territory") # Recalculates all lft/rgt valuesRule: NEVER manually edit lft or rgt fields. ALWAYS use rebuild_tree() if the nested set is corrupted.
frappe.views.TreeView — Client-Side Tree View
Automatic Tree View
Any DocType with is_tree = 1 and an is_group field automatically gets a tree view at /app/{doctype}/view/tree. No JavaScript configuration required.
Custom Tree View Configuration
Create {doctype}_tree.js in the DocType directory to customize behavior:
// myapp/doctype/my_category/my_category_tree.js
frappe.treeview_settings["My Category"] = {
// Breadcrumb module
breadcrumb: "Setup",
// Custom title
title: "Category Hierarchy",
// Root label (top of tree)
root_label: "All Categories",
// Show expand all / collapse all buttons
show_expand_all: true,
// Backend methods (override defaults)
get_tree_nodes: "myapp.api.get_category_nodes",
add_tree_node: "myapp.api.add_category_node",
// Toolbar filter fields
filters: [
{
fieldtype: "Select",
fieldname: "status",
label: __("Status"),
options: ["", "Active", "Archived"],
default: "Active",
// Fires on change — triggers tree reload
},
{
fieldtype: "Link",
fieldname: "company",
label: __("Company"),
options: "Company",
default: frappe.defaults.get_default("company"),
},
],
// Custom fields in "Add Child" dialog
fields: [
{ fieldtype: "Check", fieldname: "is_group", label: __("Is Group") },
{ fieldtype: "Data", fieldname: "category_name", label: __("Name"), reqd: 1 },
{ fieldtype: "Select", fieldname: "type", label: __("Type"),
options: "\nType A\nType B" },
],
// Fields to exclude from the Add Child dialog
ignore_fields: ["old_parent"],
// Custom label renderer
get_label(node) {
if (node.data && node.data.color) {
return `<span style="color:${node.data.color}">${node.label}</span>`;
}
return __(node.label);
},
// Lifecycle callbacks
onload(treeview) {
// Runs once when tree initializes
// treeview.page is available here
},
post_render(treeview) {
// Runs after tree is fully rendered
},
onrender(node) {
// Runs for each individual node after it renders
if (node.data && node.data.disabled) {
node.$tree_link.addClass("text-muted");
}
},
on_get_node(nodes) {
// Process node data before display
},
// Click handler
click(node) {
// Custom action when a node is clicked
},
// Custom view template (split view)
view_template: "my_category_node_detail",
// Custom toolbar buttons
toolbar: [
{
label: __("Move"),
condition(node) { return !node.is_root; },
click(node) {
move_category(node.label);
},
btnClass: "hidden-xs",
},
],
// Set to true to EXTEND default toolbar (Edit, Add Child, Rename, Delete)
// Set to false/omit to REPLACE default toolbar entirely
extend_toolbar: true,
// Custom menu items (added to page menu dropdown)
menu_items: [
{
label: __("Import Categories"),
action() { frappe.set_route("data-import", "My Category"); },
condition: "frappe.user.has_role('System Manager')",
},
],
};TreeView Properties and Methods
The frappe.views.TreeView instance (accessible via cur_tree.view_name === "Tree") exposes:
| Property/Method | Purpose |
|---|---|
treeview.tree | The underlying frappe.ui.Tree instance |
treeview.page | The frappe.ui.Page instance |
treeview.doctype | The DocType name |
treeview.body | jQuery wrapper for the tree container |
treeview.make_tree() | Rebuild the tree (full refresh) |
treeview.new_node() | Open the "Add Child" dialog |
treeview.rebuild_tree() | Calls frappe.utils.nestedset.rebuild_tree |
frappe.ui.Tree — Low-Level Tree API
The frappe.ui.Tree class handles rendering and node management:
// Constructor options
let tree = new frappe.ui.Tree({
parent: $container, // jQuery container
label: "Root", // Root node label
root_value: "Root", // Root node value
expandable: true, // Allow expand/collapse
with_skeleton: true, // Show loading skeleton
args: { doctype: "Territory" }, // Extra args for API calls
method: "frappe.desk.treeview.get_children", // Backend method
toolbar: [...], // Toolbar button definitions
icon_set: { // Custom icons (optional)
open: '<i class="fa fa-folder-open"></i>',
closed: '<i class="fa fa-folder"></i>',
leaf: '<i class="fa fa-file"></i>',
},
// Callbacks
get_label(node) { return node.label; },
on_render(node) { /* after node renders */ },
on_click(node) { /* node clicked */ },
on_get_node(data) { /* data received from server */ },
on_node_render(node, deep) { /* after load_children completes */ },
});| Method | Purpose |
|---|---|
tree.get_selected_node() | Get currently selected TreeNode |
tree.set_selected_node(node) | Set selection |
tree.load_children(node, deep) | Load children; deep=true loads all descendants |
tree.reload_node(node) | Reload a specific node's children |
tree.toggle() | Toggle selected node expand/collapse |
tree.refresh() | Refresh selected node's parent |
tree.add_node(parent_node, data) | Add a child node to the DOM |
tree.nodes | Object map: { label: TreeNode } |
tree.root_node | The root TreeNode instance |
TreeNode Properties
Each node in the tree is a TreeNode instance:
| Property | Type | Description |
|---|---|---|
node.label | String | Node identifier |
node.data | Object | Server data (value, expandable, etc.) |
node.parent_label | String | Parent's label |
node.parent_node | TreeNode | Parent TreeNode reference |
node.expandable | Boolean | Can have children |
node.is_root | Boolean | Is the root node |
node.loaded | Boolean | Children have been fetched |
node.expanded | Boolean | Currently expanded |
node.$tree_link | jQuery | The clickable link element |
node.$ul | jQuery | The children container |
node.$toolbar | jQuery | The toolbar buttons (if any) |
Tree Node Operations
Add a Child Node
// Via the built-in dialog (recommended)
cur_tree.view_name; // Access current tree view
// Click "New" button or use the "Add Child" toolbar button
// Programmatic: call the backend
frappe.call({
method: "frappe.desk.treeview.add_node",
args: {
doctype: "Territory",
parent: "India", // parent node label
is_group: 1,
territory_name: "North India",
},
callback(r) {
if (!r.exc) {
// Reload the parent node to show the new child
let parent_node = cur_tree.tree.nodes["India"];
cur_tree.tree.load_children(parent_node);
}
}
});Move a Node (Re-parent)
Moving a node means changing its parent. The nested set is automatically rebalanced on on_update:
# Server-side: move "North India" under "Asia"
doc = frappe.get_doc("Territory", "North India")
doc.parent_territory = "Asia"
doc.save() # on_update → update_nsm() rebalances lft/rgt// Client-side
frappe.call({
method: "frappe.client.set_value",
args: {
doctype: "Territory",
name: "North India",
fieldname: "parent_territory",
value: "Asia",
},
callback() {
// Refresh the tree view
cur_tree && cur_tree.make_tree();
}
});Rule: NEVER move a node to one of its own descendants — this creates a circular reference. The NestedSet class validates this and raises NestedSetRecursionError.
Delete a Node
// Via toolbar "Delete" button (built-in)
// Or programmatically:
frappe.model.delete_doc("Territory", "North India", function() {
// Refresh parent node
let parent_node = cur_tree.tree.nodes["India"];
if (parent_node) cur_tree.tree.load_children(parent_node);
});Rule: NEVER delete a group node that has children — Frappe raises NestedSetChildExistsError. ALWAYS delete or move children first.
Rebuild Corrupted Tree
If lft/rgt values become inconsistent (e.g., after direct SQL edits):
# Server-side
from frappe.utils.nestedset import rebuild_tree
rebuild_tree("Territory")
# Or via API
frappe.call({
method: "frappe.utils.nestedset.rebuild_tree",
args: { doctype: "Territory" }
});The "Rebuild Tree" option also appears in the tree view's menu dropdown for System Managers.
Common Tree Patterns
Pattern: Chart of Accounts Tree
ERPNext's Chart of Accounts is the canonical tree example:
// erpnext/accounts/doctype/account/account_tree.js
frappe.treeview_settings["Account"] = {
breadcrumb: "Accounts",
title: __("Chart of Accounts"),
get_tree_root: false, // Uses company-based root
root_label: "Accounts",
filters: [
{
fieldtype: "Link",
fieldname: "company",
label: __("Company"),
options: "Company",
default: frappe.defaults.get_default("company"),
},
],
fields: [
{ fieldtype: "Data", fieldname: "account_name", label: __("Account Name"), reqd: 1 },
{ fieldtype: "Check", fieldname: "is_group", label: __("Is Group") },
{ fieldtype: "Link", fieldname: "account_type", label: __("Account Type"),
options: "Account Type" },
],
get_label(node) {
// Show account number + name
if (node.data && node.data.account_number) {
return `${node.data.account_number} - ${node.label}`;
}
return node.label;
},
};Pattern: Territory / Region Tree
frappe.treeview_settings["Territory"] = {
breadcrumb: "Selling",
title: __("Territory"),
// Uses default toolbar (Edit, Add Child, Rename, Delete)
// No custom fields needed — territory_name is auto-detected as mandatory
};Pattern: Custom Tree with Split View
Display node details alongside the tree:
frappe.treeview_settings["My Category"] = {
// view_template renders in a side panel when a node is clicked
view_template: "my_category_detail",
// The template receives { data: node.data, doctype: "My Category" }
// Create: myapp/doctype/my_category/my_category_detail.html
};Template file (my_category_detail.html):
<div class="category-detail">
<h4>{{ data.value }}</h4>
<p>{{ data.description || "No description" }}</p>
<a href="/app/my-category/{{ data.value }}" class="btn btn-default btn-sm">
{{ __("Open") }}
</a>
</div>Anti-Patterns
| Anti-Pattern | Correct Approach |
|---|---|
Not calling super().on_update() in tree controller | ALWAYS call super().on_update() — it triggers nested set rebalancing |
Manually editing lft/rgt via SQL | Use rebuild_tree() after any direct database changes |
| Deleting a group with children | Move or delete children first, then delete the group |
| Moving a node under its own descendant | NestedSet validates this — but design your UI to prevent it |
Missing is_group field on DocType | Tree DocTypes MUST have is_group (Check) — without it, TreeFactory rejects the view |
Forgetting is_tree = 1 on DocType | Without it, no tree view route is generated |
Using parent as field name | parent is reserved by Frappe for child table links — tree uses parent_{doctype} |
UI Components Workflows — Extended
Complete Dialog with Validation and Async Submit
let d = new frappe.ui.Dialog({
title: "Create Invoice",
size: "large",
fields: [
{ label: "Customer", fieldname: "customer", fieldtype: "Link",
options: "Customer", reqd: 1,
change() {
// Dynamic filtering when customer changes
let customer = d.get_value("customer");
if (customer) {
d.fields_dict.items.grid.get_field("item").get_query = () => ({
filters: { "customer": customer }
});
}
}
},
{ fieldtype: "Section Break", label: "Items" },
{ label: "Items", fieldname: "items", fieldtype: "Table",
in_place_edit: true, reqd: 1,
fields: [
{ fieldname: "item", label: "Item", fieldtype: "Link",
options: "Item", in_list_view: 1, reqd: 1 },
{ fieldname: "qty", label: "Qty", fieldtype: "Int",
in_list_view: 1, default: 1, reqd: 1 },
{ fieldname: "rate", label: "Rate", fieldtype: "Currency",
in_list_view: 1 },
]
},
{ fieldtype: "Section Break" },
{ label: "Notes", fieldname: "notes", fieldtype: "Small Text" },
],
primary_action_label: "Create Invoice",
primary_action(values) {
// Disable button to prevent double-click
d.disable_primary_action();
frappe.xcall("myapp.api.create_invoice", {
customer: values.customer,
items: values.items,
notes: values.notes,
}).then((invoice_name) => {
d.hide();
frappe.show_alert({ message: __("Invoice {0} created", [invoice_name]),
indicator: "green" });
frappe.set_route("Form", "Sales Invoice", invoice_name);
}).catch(() => {
// Re-enable on error so user can retry
d.enable_primary_action();
});
}
});
d.show();MultiSelectDialog for Record Selection
new frappe.ui.form.MultiSelectDialog({
doctype: "Item",
target: cur_frm,
setters: {
item_group: null,
brand: null,
},
add_filters_group: 1,
primary_action_label: "Add Items",
columns: ["item_name", "item_group", "brand", "stock_uom"],
action(selections) {
// selections = array of selected document names
selections.forEach(item_name => {
let row = cur_frm.add_child("items");
frappe.model.set_value(row.doctype, row.name, "item_code", item_name);
});
cur_frm.refresh_field("items");
}
});Complete Custom Page with Data Table
frappe.pages["inventory-dashboard"].on_page_load = function(wrapper) {
let page = frappe.ui.make_app_page({
parent: wrapper,
title: "Inventory Dashboard",
single_column: true,
});
// Toolbar filters
let warehouse = page.add_field({
label: "Warehouse",
fieldtype: "Link",
fieldname: "warehouse",
options: "Warehouse",
change() { refresh(page); }
});
let item_group = page.add_field({
label: "Item Group",
fieldtype: "Link",
fieldname: "item_group",
options: "Item Group",
change() { refresh(page); }
});
// Action buttons
page.set_primary_action("Export", () => export_data(page));
page.add_menu_item("Print", () => window.print());
// Content area
$(page.body).html('<div id="inventory-table"></div>');
refresh(page);
};
function refresh(page) {
let filters = page.get_form_values();
page.set_indicator("Loading...", "orange");
frappe.xcall("myapp.api.get_inventory", filters).then(data => {
render_table(data);
page.set_indicator("Updated", "green");
page.set_title_sub(`${data.length} items`);
});
}
function render_table(data) {
let container = document.getElementById("inventory-table");
// Use frappe.DataTable or custom HTML
new frappe.DataTable(container, {
columns: [
{ name: "Item", width: 200 },
{ name: "Warehouse", width: 150 },
{ name: "Qty", width: 100 },
{ name: "Value", width: 120 },
],
data: data.map(d => [d.item_name, d.warehouse, d.qty, d.value]),
});
}List View with Multiple Buttons (Dropdown)
frappe.listview_settings["Sales Order"] = {
add_fields: ["status", "grand_total", "customer", "delivery_status"],
get_indicator(doc) {
const map = {
"Draft": ["Draft", "red", "status,=,Draft"],
"To Deliver and Bill": ["To Deliver", "orange",
"status,=,To Deliver and Bill"],
"Completed": ["Completed", "green", "status,=,Completed"],
"Cancelled": ["Cancelled", "darkgrey", "status,=,Cancelled"],
};
return map[doc.status] || ["Unknown", "grey", ""];
},
// Dropdown with multiple actions per row
dropdown_button: {
buttons: [
{
show(doc) { return doc.status === "Draft"; },
get_label() { return __("Submit"); },
get_description(doc) { return __("Submit {0}", [doc.name]); },
action(doc) {
frappe.xcall("frappe.client.submit", { doc: doc.name })
.then(() => cur_list.refresh());
}
},
{
show(doc) { return doc.docstatus === 1; },
get_label() { return __("Make Invoice"); },
get_description(doc) { return __("Create invoice for {0}", [doc.name]); },
action(doc) {
frappe.set_route("Form", "Sales Invoice", {
sales_order: doc.name
});
}
},
]
},
onload(listview) {
listview.page.add_inner_button("Bulk Update", () => {
frappe.prompt(
{ label: "Status", fieldname: "status", fieldtype: "Select",
options: "Open\nClosed", reqd: 1 },
(values) => {
let names = listview.get_checked_items().map(d => d.name);
frappe.xcall("myapp.api.bulk_update_status", {
orders: names, status: values.status
}).then(() => listview.refresh());
},
"Set Status"
);
});
}
};Realtime Chat-Style Updates
# Server: myapp/api.py
import frappe
@frappe.whitelist()
def send_message(room, message):
frappe.publish_realtime(
"new_message",
{"room": room, "message": message, "sender": frappe.session.user,
"timestamp": frappe.utils.now()},
after_commit=True
)
return "ok"// Client: listen and render
frappe.realtime.on("new_message", (data) => {
append_message(data.room, data.message, data.sender, data.timestamp);
frappe.show_alert({ message: `New message from ${data.sender}`,
indicator: "blue" }, 3);
});Calendar View with Custom Events
// myapp/doctype/appointment/appointment_calendar.js
frappe.views.calendar["Appointment"] = {
field_map: {
start: "scheduled_date",
end: "end_date",
id: "name",
title: "patient_name",
allDay: "all_day",
color: "color",
},
gantt: false,
filters: [
{ fieldtype: "Link", fieldname: "department", label: "Department",
options: "Medical Department" },
],
get_events_method: "myapp.api.get_appointments",
};# myapp/api.py
@frappe.whitelist()
def get_appointments(start, end, filters=None):
conditions = {"scheduled_date": ("between", [start, end])}
if filters:
import json
filters = json.loads(filters) if isinstance(filters, str) else filters
conditions.update(filters)
return frappe.get_all("Appointment",
filters=conditions,
fields=["name", "patient_name", "scheduled_date", "end_date",
"all_day", "color", "department"],
)