Who is this for?
- Part 1 — CRM customizers and admins. No build step required.
- Part 2 — Core contributors who need to understand the script evaluation pipeline.
Quick API reference: SPEC.md
formDialog full reference: form-dialog.md
Roadmap: PLAN.md
CRM Form Scripts let you attach JavaScript behaviour to any CRM document — without modifying source code. You write a plain ES class, define lifecycle hooks and field change handlers, and CRM wires it to the live form automatically.
- Open CRM Settings → Form Scripts (or navigate to the
CRM Form ScriptDocType). - Click New.
- Fill in:
- Name — a descriptive label (e.g.
Lead Qualification Rules) - DocType (dt) — which document this script applies to (e.g.
CRM Lead) - View —
Form(the only supported value currently) - Enabled — check this to activate
- Name — a descriptive label (e.g.
- Write your class in the Script field.
- Save.
Changes take effect on the next page load — no rebuild needed.
Your script must define at least one ES class. The class name must match the DocType name with all spaces removed:
| DocType | Class name |
|---|---|
CRM Lead |
CRMLead |
CRM Deal |
CRMDeal |
Contact |
Contact |
CRM Organization |
CRMOrganization |
class CRMLead {
// hooks go here
}The class does not need to extend anything. An empty class is valid (useful as a placeholder while you build out logic).
Inside any hook, this.doc is a live proxy to the document. Read and write fields directly — no .value, no reactivity boilerplate:
class CRMLead {
onLoad() {
console.log(this.doc.lead_name); // read a Text / Data field
console.log(this.doc.no_of_employees); // read an Int field
console.log(this.doc.is_deal_created); // read a Check field (true/false)
this.doc.status = "New"; // write — marks doc dirty, triggers re-render
this.doc.lead_owner = "admin"; // write a Link field (store the name/id)
}
}this.doc always reflects the current saved + unsaved state. There is no separate "form value" vs "doc value" — they are the same thing.
| Fieldtype | Value type in this.doc |
Notes |
|---|---|---|
| Data / Text | string |
|
| Int | number |
|
| Float / Currency / Percent | number |
|
| Check | true / false |
Not 0/1 on the client side |
| Select | string (the option value) |
|
| Link | string (the linked doc name) |
To display label, call frappe.client.get_value |
| Date | "YYYY-MM-DD" string |
|
| Datetime | "YYYY-MM-DD HH:MM:SS" string |
|
| Duration | number (seconds) |
|
| Rating | number (0–5) |
|
| Attach / Attach Image | string (file URL) |
Set to a public file URL |
| Table | array of row objects |
Mutate rows directly; see child table section |
| HTML | Not in this.doc — use setFieldHtml |
See HTML field section |
| Button | Not a data field — triggers a click hook | See button section |
| Geolocation | GeoJSON object |
{ type: "FeatureCollection", features: [...] } |
All hooks are optional. Define only what you need. Each hook supports a camelCase and a snake_case alias — whichever you define first wins.
Fires once when the document first loads from the server. Best for one-time setup: default values, actions, calculated fields.
class CRMLead {
onLoad() {
// Default the owner to the logged-in user if unset
if (!this.doc.lead_owner) {
this.doc.lead_owner = frappe.session?.user;
}
// Set a default rating
if (!this.doc.lead_quality) {
this.doc.lead_quality = 3;
}
// Register page header buttons
this.actions = [
{
label: "Send Intro Email",
onClick: async () => await this.doc.trigger("_sendIntroEmail")
}
];
}
}
onLoaddoes not re-fire when you navigate away and come back. UseonRenderfor that.
Fires every time the page renders — on first visit and on every re-visit. Use this for route-aware side-effects such as defaulting to a tab or refreshing a summary banner.
class CRMLead {
async onRender() {
// Always refresh the HTML summary banner
await this.doc.trigger("_renderSummary");
// Only jump to the Emails tab when arriving from a *different* page
const prevPath = router.previousRoute?.path;
const currPath = router.currentRoute.value.path;
if (prevPath === currPath) return; // same-page re-render — skip
router.replace({ ...router.currentRoute.value, hash: "#emails" });
}
}Compare
.pathnot.fullPath—fullPathincludes the hash, so#activity≠#emails. Userouter.replacenotrouter.push—replaceavoids adding a browser history entry.
Fires before every save. Throw a new Error (or call throwError) to block the save — the error message is shown as a toast automatically.
class CRMLead {
onValidate() {
// Require at least one contact method
if (!this.doc.email && !this.doc.mobile_no) {
throwError("Provide at least one contact method (email or mobile)");
}
// Validate a number field
if (this.doc.annual_revenue < 0) {
throwError("Annual revenue cannot be negative");
}
// Validate a Check field
if (!this.doc.is_gdpr_consent && this.doc.source === "Website") {
throwError("GDPR consent is required for website leads");
}
}
}If the hook returns without throwing, the save continues normally.
onValidatecan beasync— the engine awaits it before proceeding to save.
Fires after a successful save. Use this for post-save backend calls, notifications, or follow-up actions.
class CRMLead {
async onSave() {
// Only run for specific statuses
if (this.doc.status === "Qualified") {
await call("crm.api.lead.notify_manager", { lead: this.doc.name });
toast.success("Manager notified");
}
}
}Fires when a save fails (server returned an error). The doc is reverted automatically by the framework.
class CRMLead {
onError() {
// Custom message on top of the framework's error toast
toast.error("Could not save — please refresh and try again");
}
}Fires before a new document is created via a modal (e.g. the New Lead dialog). Use this to validate or pre-fill data before the document is written to the database.
class CRMLead {
onBeforeCreate() {
if (!this.doc.lead_name?.trim()) {
throwError("Lead name is required");
}
// Stamp the source automatically
if (!this.doc.source) {
this.doc.source = "Manual";
}
}
}Fires when a lead is being created through the CRM Lead creation flow.
class CRMLead {
onCreateLead(leadData) {
console.log("Creating lead with data:", leadData);
}
}Fires when a lead is converted to a deal.
class CRMLead {
convertToDeal(dealData) {
toast.info(`Converting ${this.doc.lead_name} to a deal`);
}
}Define a method named exactly the same as the fieldname to react when that field changes. This works for all fieldtypes except Button (which has its own section).
class CRMLead {
// Select field
status() {
if (this.doc.status === "Lost") {
toast.info("Please add a note explaining why this lead was lost");
}
}
// Link field
lead_owner() {
toast.info(`Owner changed to ${this.doc.lead_owner}`);
}
// Data / Text field
website() {
// Auto-prefix https:// if missing
if (this.doc.website && !this.doc.website.startsWith("http")) {
this.doc.website = "https://" + this.doc.website;
}
}
// Check field
is_deal_created() {
if (this.doc.is_deal_created) {
this.doc.status = "Qualified";
}
}
}Inside any field hook, two extra properties are available:
| Property | Description |
|---|---|
this.value |
The new value just set |
this.oldValue |
The previous value |
class CRMLead {
// Int / Float / Currency field
annual_revenue() {
if (this.oldValue && this.value > this.oldValue * 2) {
toast.info(
"Revenue more than doubled — double-check before saving"
);
}
}
// Rating field (0–5)
lead_quality() {
if (this.value >= 4) {
toast.success("High quality lead — consider fast-tracking");
}
}
// Date field
followup_date() {
const today = new Date().toISOString().split("T")[0];
if (this.value < today) {
toast.error("Follow-up date cannot be in the past");
this.doc.followup_date = today;
}
}
// Attach / Attach Image field
profile_photo() {
// this.value is the new file URL, this.oldValue is the previous URL
if (this.value) {
toast.success("Photo updated");
}
}
// Duration field (value is in seconds)
estimated_duration() {
const hours = Math.round(this.value / 3600);
if (hours > 40) {
toast.info(`That's ${hours} hours — is that right?`);
}
}
}To call a helper you've defined on your class, use this.doc.trigger('methodName'). Direct calls like this._helper() do not work — the engine invokes hooks through the this.doc proxy, and methods called outside that path lose their bound context.
class CRMLead {
async status() {
await this.doc.trigger("_renderSummary"); // calls _renderSummary on this controller
}
_renderSummary() {
this.setFieldHtml("lead_summary", `<b>${this.doc.status || "—"}</b>`);
}
}
this.doc.trigger()returns the method's return value. Forasynchelpers,awaitthe call and mark the calling hookasync.
Inside a child table class, this.doc.trigger() routes to the parent controller — not the child. To call methods defined on your child class, use a row proxy from this.getRow():
class CRMProducts {
async qty() {
const row = this.getRow("products", this.currentRowIdx);
if (row) await row.trigger("_recalc"); // calls _recalc on CRMProducts
}
async rate() {
const row = this.getRow("products", this.currentRowIdx);
if (row) await row.trigger("_recalc");
}
_recalc() {
const row = this.getRow("products", this.currentRowIdx);
if (row) row.amount = (row.qty || 0) * (row.rate || 0);
}
}For a field of type Button, the hook fires on click — without marking the doc dirty.
Define a method named after the fieldname:
class CRMLead {
// Button field with fieldname 'send_brochure'
send_brochure() {
if (!this.doc.email) {
throwError("No email address on this lead");
}
call("crm.api.lead.send_brochure", {
lead: this.doc.name,
email: this.doc.email
}).then(() => toast.success("Brochure sent to " + this.doc.email));
}
// Button field with fieldname 'make_phone_call'
make_phone_call() {
if (!this.doc.mobile_no) {
throwError("No mobile number on this lead");
}
crm.makePhoneCall(this.doc.mobile_no);
}
}Writing to
this.docinside a button hook will dirty the form — only do it intentionally.
For fields of type HTML, use this.setFieldHtml(fieldname, html) to inject reactive HTML. The field re-renders whenever you call it.
class CRMLead {
async onLoad() {
await this.doc.trigger("_renderBanner");
}
// Re-render the banner whenever relevant fields change
async status() {
await this.doc.trigger("_renderBanner");
}
async lead_owner() {
await this.doc.trigger("_renderBanner");
}
async annual_revenue() {
await this.doc.trigger("_renderBanner");
}
_renderBanner() {
const status = this.doc.status || "—";
const owner = this.doc.lead_owner || "Unassigned";
const revenue = this.doc.annual_revenue
? `$${Number(this.doc.annual_revenue).toLocaleString()}`
: "—";
const colorMap = {
Qualified: "green",
Lost: "red",
New: "blue",
Contacted: "yellow"
};
const color = colorMap[status] ?? "gray";
this.setFieldHtml(
"lead_summary", // fieldname of the HTML field in your DocType
`<div class="flex gap-4 text-sm text-${color}-700 font-medium py-2 px-1">
<span>Status: ${status}</span>
<span>·</span>
<span>Owner: ${owner}</span>
<span>·</span>
<span>Revenue: ${revenue}</span>
</div>`
);
}
}Static template alternative: If you don't call setFieldHtml, the HTML field renders the static Options text from the DocType field definition, with {{ fieldname }} tokens substituted from the current doc:
<!-- Entered in the field's Options: -->
<b>{{ lead_name }}</b> — {{ status }}
Called after a row is added. this.value is the new row object.
class CRMDeal {
// Table field with fieldname 'products'
products_add() {
const row = this.value;
row.qty = row.qty || 1;
row.discount = 0;
// Automatically calculate amount
if (row.rate) {
row.amount = row.qty * row.rate;
}
}
}Called after row(s) are deleted. this.selectedRows is an array of removed row names; this.rows is the remaining rows.
class CRMDeal {
products_remove() {
const total = this.rows.reduce((sum, r) => sum + (r.amount || 0), 0);
toast.info(
`${this.rows.length} product(s) remaining — new total: $${total.toLocaleString()}`
);
}
}Field change hooks fire for child table rows too. Use this.getRow(parentfield, idx) to access the specific row being edited:
class CRMDeal {
// Fires when 'qty' changes in any products row
qty() {
const row = this.getRow("products", this.currentRowIdx);
if (row) {
row.amount = (row.qty || 0) * (row.rate || 0);
}
}
rate() {
const row = this.getRow("products", this.currentRowIdx);
if (row) {
row.amount = (row.qty || 0) * (row.rate || 0);
}
}
}If your DocType has a child table, write a separate class for the child DocType in the same script file. The class name is the child DocType name (spaces removed).
Rule: define the parent class before the child class. If the child uses extends ParentClass, it will throw ReferenceError if the parent is defined later in the file.
// ── Parent DocType: CRM Deal ──────────────────────────────────────────────────
class CRMDeal {
async onLoad() {
await this.doc.trigger("_recalcTotal");
}
async onSave() {
await this.doc.trigger("_recalcTotal");
}
_recalcTotal() {
const products = this.doc.products || [];
const total = products.reduce((sum, r) => sum + (r.amount || 0), 0);
this.doc.total_value = total;
}
}
// ── Child DocType: CRM Products ───────────────────────────────────────────────
class CRMProducts {
qty() {
const row = this.getRow("products", this.currentRowIdx);
if (row) row.amount = (row.qty || 0) * (row.rate || 0);
}
rate() {
const row = this.getRow("products", this.currentRowIdx);
if (row) row.amount = (row.qty || 0) * (row.rate || 0);
}
}Use the injected call helper for any @frappe.whitelist() Python method:
class CRMLead {
async onSave() {
const result = await call("crm.api.lead.get_lead_score", {
lead: this.doc.name
});
this.setFieldHtml(
"score_display",
`<div class="text-lg font-bold">Lead Score: ${result.score}/100</div>`
);
}
async industry() {
// Look up the average deal size for this industry
const data = await call("frappe.client.get_value", {
doctype: "CRM Industry Benchmark",
fieldname: "avg_deal_size",
filters: { industry: this.doc.industry }
});
if (data?.avg_deal_size) {
toast.info(
`Industry avg deal size: $${data.avg_deal_size.toLocaleString()}`
);
}
}
}Set this.actions to an array of button definitions. Each button appears in the page header.
class CRMLead {
onLoad() {
this.actions = [
{
label: "Send Intro Email",
onClick: () => {
call("crm.api.lead.send_intro_email", {
lead: this.doc.name
}).then(() => toast.success("Intro email sent"));
}
},
{
label: "View on Map",
onClick: () => {
const addr = encodeURIComponent(
this.doc.city + ", " + this.doc.country
);
window.open(`https://maps.google.com/?q=${addr}`, "_blank");
}
}
];
}
}Set this.statuses to an array of status objects to replace the default status dropdown options.
class CRMLead {
onLoad() {
this.statuses = [
{ name: "New", color: "blue" },
{ name: "Contacted", color: "yellow" },
{ name: "Qualified", color: "green" },
{ name: "Nurturing", color: "purple" },
{ name: "Lost", color: "red" }
];
}
}Two helpers are available for dialogs:
Fire-and-forget. Good for confirmations and messages:
class CRMLead {
confirm_delete() {
createDialog({
title: "Delete Lead?",
message: "This action cannot be undone.",
actions: [
{
label: "Delete",
theme: "red",
onClick: async ({ close }) => {
await call("frappe.client.delete", {
doctype: "CRM Lead",
name: this.doc.name
});
close();
router.push("/leads");
}
},
{
label: "Cancel",
onClick: ({ close }) => close()
}
]
});
}
}Opens a dialog with a full FieldLayout. Supports three patterns: Promise (await), onSubmit callback, and custom actions. See form-dialog.md for full documentation.
// Promise style — blocks until dialog closes
class CRMLead {
async convert() {
const result = await formDialog({
title: "Convert",
fields: [
{ fieldname: "deal_name", fieldtype: "Data", label: "Deal Name", reqd: 1 }
]
});
if (!result) return;
await call("crm.api.convert", { name: this.doc.name, deal: result.deal_name });
}
}
// Callback style — fire-and-forget, code after runs immediately
class CRMLead {
mark_as_lost() {
formDialog({
title: "Mark as Lost",
fields: [
{ fieldname: "lost_reason", fieldtype: "Link", label: "Lost Reason", options: "CRM Lost Reason", reqd: 1 },
{ fieldname: "lost_notes", fieldtype: "Small Text", label: "Notes" }
],
onSubmit: (data) => {
this.doc.lost_reason = data.lost_reason;
this.doc.status = "Lost";
toast.success("Marked as lost");
}
});
}
}Dynamically change field visibility, editability, options, and more at runtime. Changes take effect immediately — no page reload needed.
class CRMLead {
onLoad() {
// Hide a field
this.setFieldProperty('lost_reason', 'hidden', true)
// Make a field read-only
this.setFieldProperty('annual_revenue', 'read_only', true)
// Make a field mandatory
this.setFieldProperty('email', 'reqd', true)
// Change Select options
this.setFieldProperty('status', 'options', 'New\nQualified\nLost')
// Change label
this.setFieldProperty('annual_revenue', 'label', 'Revenue (USD)')
// Set Link filters
this.setFieldProperty('lead_owner', 'link_filters', { enabled: 1 })
// Hide a section or tab
this.setFieldProperty('financial_section', 'hidden', true)
this.setFieldProperty('advanced_tab', 'hidden', true)
// Child table column (dot notation)
this.setFieldProperty('products.discount', 'hidden', true)
// Specific row in child table
this.setFieldProperty('products.rate', 'read_only', true, row.name)
}
}Batch set multiple properties:
this.setFieldProperties('annual_revenue', {
read_only: true,
label: 'Revenue (USD)',
description: 'Auto-calculated',
})Revert to original server value:
this.removeFieldProperty('annual_revenue', 'hidden')Get effective field definition (raw meta merged with overrides):
const field = this.getField('status')
console.log(field.options, field.read_only, field.hidden)class CRMLead {
onLoad() {
this._syncVisibility()
}
status() {
this._syncVisibility()
}
_syncVisibility() {
const isLost = this.doc.status === 'Lost'
this.setFieldProperty('lost_reason', 'hidden', !isLost)
this.setFieldProperty('lost_reason', 'reqd', isLost)
}
}Full reference: see SPEC.md — setFieldProperty API for all supported properties, override priority, child table dot notation, per-row overrides, and section/tab overrides.
Use the injected socket (Socket.io) to listen for or emit realtime events:
class CRMLead {
onLoad() {
// Listen for a server-side realtime event
socket.on("lead_score_updated", (data) => {
if (data.lead === this.doc.name) {
this.setFieldHtml(
"score_display",
`<b>Score updated: ${data.score}</b>`
);
}
});
}
}throwError(message) is a shorthand that both shows a red toast and throws, stopping execution immediately. Use it instead of a separate toast.error + throw combination:
class CRMLead {
onValidate() {
if (!this.doc.lead_name?.trim()) {
throwError("Lead name cannot be empty");
// execution stops here — everything below is not reached
}
if (this.doc.annual_revenue < 0) {
throwError("Annual revenue cannot be negative");
}
}
send_brochure() {
if (!this.doc.email) {
throwError("No email address — cannot send brochure");
}
// only reached if email exists
call("crm.api.lead.send_brochure", { lead: this.doc.name });
}
}All helpers are available as bare names everywhere in your script — no imports needed.
| Helper | Description |
|---|---|
router |
Vue Router — router.replace(), router.push(), router.currentRoute |
router.previousRoute |
The route navigated from — compare .path to detect cross-page navigation |
toast.success(msg) |
Green toast notification |
toast.error(msg) |
Red toast notification |
toast.info(msg) |
Info toast notification |
call(method, params) |
Frappe backend RPC — returns a Promise |
createDialog(options) |
Open a frappe-ui dialog modal (simple message/confirm) |
formDialog(options) |
Open a form dialog with FieldLayout. Supports Promise, onSubmit callback, and custom actions. See form-dialog.md |
socket |
Socket.io instance for realtime events |
throwError(message) |
toast.error + throw in one call — stops execution |
crm.makePhoneCall(number) |
Initiate a phone call via the CRM call integration |
crm.openSettings(page) |
Open the CRM settings panel to a specific page |
This script covers all major hook types, multiple fieldtypes, an HTML field, a Button field, dynamic actions, and validation:
class CRMLead {
// ─── Lifecycle ───────────────────────────────────────────────────────────────
async onLoad() {
// Default owner (Link field)
if (!this.doc.lead_owner) {
this.doc.lead_owner = frappe.session?.user;
}
// Default rating (Rating field)
if (!this.doc.lead_quality) {
this.doc.lead_quality = 3;
}
await this.doc.trigger("_renderSummary");
// Page header buttons
this.actions = [
{
label: "Send Intro Email",
onClick: async () => await this.doc.trigger("_sendIntroEmail")
}
];
// Custom status dropdown
this.statuses = [
{ name: "New", color: "blue" },
{ name: "Contacted", color: "yellow" },
{ name: "Qualified", color: "green" },
{ name: "Nurturing", color: "purple" },
{ name: "Lost", color: "red" }
];
}
async onRender() {
await this.doc.trigger("_renderSummary");
// Jump to Emails tab only when arriving from a different page
const prevPath = router.previousRoute?.path;
const currPath = router.currentRoute.value.path;
if (prevPath !== currPath) {
router.replace({ ...router.currentRoute.value, hash: "#emails" });
}
}
onValidate() {
if (!this.doc.lead_name?.trim()) {
throwError("Lead name is required");
}
if (!this.doc.email && !this.doc.mobile_no) {
throwError("Provide at least one contact method (email or mobile)");
}
if (this.doc.annual_revenue < 0) {
throwError("Annual revenue cannot be negative");
}
}
async onSave() {
if (this.doc.status === "Qualified") {
await call("crm.api.lead.notify_manager", { lead: this.doc.name });
toast.success("Manager notified");
}
}
onError() {
toast.error("Save failed — please check your input and try again");
}
// ─── Field change hooks ──────────────────────────────────────────────────────
// Select field
async status() {
await this.doc.trigger("_renderSummary");
if (this.doc.status === "Lost") {
toast.info("Please add a note explaining why this lead was lost");
}
}
// Link field
async lead_owner() {
await this.doc.trigger("_renderSummary");
}
// Currency / Float field
async annual_revenue() {
await this.doc.trigger("_renderSummary");
if (this.value > 0 && this.value < 1000) {
toast.info("Annual revenue looks low — did you mean thousands?");
}
}
// Rating field (0–5)
lead_quality() {
if (this.value >= 4) {
toast.success("High quality lead!");
}
}
// Check field
is_gdpr_consent() {
if (!this.value && this.doc.source === "Website") {
toast.error("GDPR consent is required for website leads");
}
}
// Date field
followup_date() {
const today = new Date().toISOString().split("T")[0];
if (this.value < today) {
toast.error("Follow-up date cannot be in the past");
this.doc.followup_date = today;
}
}
// ─── Button field hooks ───────────────────────────────────────────────────────
// Button field: fieldname 'make_phone_call'
make_phone_call() {
if (!this.doc.mobile_no) {
throwError("No mobile number on this lead");
}
crm.makePhoneCall(this.doc.mobile_no);
}
// Button field: fieldname 'send_brochure'
send_brochure() {
if (!this.doc.email) {
throwError("No email address on this lead");
}
createDialog({
title: "Send Brochure",
message: `Send brochure to ${this.doc.email}?`,
actions: [
{
label: "Send",
theme: "blue",
onClick: ({ close }) => {
call("crm.api.lead.send_brochure", {
lead: this.doc.name
}).then(() => {
close();
toast.success("Brochure sent");
});
}
}
]
});
}
// ─── Private helpers ──────────────────────────────────────────────────────────
_renderSummary() {
const status = this.doc.status || "—";
const owner = this.doc.lead_owner || "Unassigned";
const revenue = this.doc.annual_revenue
? `$${Number(this.doc.annual_revenue).toLocaleString()}`
: "—";
const quality =
"★".repeat(this.doc.lead_quality || 0) +
"☆".repeat(5 - (this.doc.lead_quality || 0));
const colorMap = {
Qualified: "green",
Lost: "red",
New: "blue",
Contacted: "yellow",
Nurturing: "purple"
};
const color = colorMap[status] ?? "gray";
this.setFieldHtml(
"lead_summary",
`<div class="flex flex-wrap gap-4 text-sm text-${color}-700 font-medium py-2 px-1">
<span>Status: ${status}</span>
<span>·</span>
<span>Owner: ${owner}</span>
<span>·</span>
<span>Revenue: ${revenue}</span>
<span>·</span>
<span>Quality: ${quality}</span>
</div>`
);
}
async _sendIntroEmail() {
await call("crm.api.lead.send_intro_email", { lead: this.doc.name });
toast.success("Intro email sent");
}
}| Mistake | Fix |
|---|---|
| Class name doesn't match DocType | CRM Lead → CRMLead (remove spaces, keep exact casing) |
| Child class defined before parent | Parent class must appear first in the script |
this.doc.fieldname.value |
this.doc is a plain proxy — no .value needed |
this._myHelper() inside a hook |
Use this.doc.trigger('_myHelper') — in child classes use row.trigger('_myHelper') on a row from this.getRow() |
Throwing a string in onValidate — throw 'msg' |
Use throw new Error('msg') or throwError('msg') |
router.push to change the active tab |
Use router.replace — push adds a browser history entry |
Writing to this.doc in a button hook by accident |
Only write to this.doc if you want to dirty the form |
Comparing router.previousRoute?.fullPath to current |
Compare .path only — fullPath includes the hash |
async field hook without await on call |
call returns a Promise — add await or use .then() |
This section is for core contributors who maintain the CRM frontend. It describes the script evaluation pipeline,
createDocProxy, thesave.submitpatch, and thetriggerOnChangeflow. Customizers do not need to read this.
When a CRM Lead/Deal/Contact/Organization page mounts, setupFormScript() in document.js runs:
setupFormScript()
→ scripts resource: fetch CRM Form Script records (dt = X, view = 'Form', enabled = 1)
→ for each script record: evaluateFormClass(script, helpers)
→ setupHelperMethods(FormClass)
→ setupFormController(FormClass, document)
→ store instance in controllersCache[doctype][docname]
controllersCache is keyed by [doctype][docname], so setupFormScript() is a no-op on re-visits. triggerOnRender still fires on every visit because it is called directly by the page component's onMounted hook.
Each form script string is wrapped in a new Function call with all helpers injected as named parameters:
// evaluateFormClass() in script.js
const wrappedScript = `
${script}
return ${className};
`;
const FormClass = new Function(...helperKeys, wrappedScript)(...helperValues);getClassNames(script)extracts class names via regex/class\s+([A-Za-z0-9_]+)/g(skips comments)- Each class is extracted and instantiated separately
- Helpers (
router,toast,call,createDialog,socket,throwError,crm) are closed over as local variables — never leaked ontowindow
Controllers access this.doc which is a Proxy created by createDocProxy(source, instance). The proxy intercepts both field access and the trigger() method:
// Simplified from script.js
function createDocProxy(source, instance) {
const getData = () => (typeof source === "function" ? source() : source);
return new Proxy(
{},
{
get(_, prop) {
if (prop === "trigger") {
// Calls the named method on the controller instance
return (methodName, ...args) => {
const method = instance[methodName];
if (typeof method === "function")
return method.apply(instance, args);
};
}
return getData()[prop]; // field value read
},
set(_, prop, value) {
getData()[prop] = value; // field value write
return true;
}
}
);
}this.doc.fieldname— reads the live field value from the reactive datathis.doc.fieldname = x— writes directly on the reactive object, triggering Vue reactivitythis.doc.trigger('method', ...args)— callsinstance[method].apply(instance, args)— the correct way to call methods defined on your class- Child controllers:
this.docis created withinstance = parentController, sothis.doc.trigger()calls parent methods. Use a row proxy fromthis.getRow()to call methods on the child class - No
.valueneeded because the data object is a plain reactive object (not aref)
After createDocumentResource, document.js patches save.submit to run pre-save hooks:
document.save.submit(...args)
→ await triggerOnValidate() // controller.onValidate?.() — throw to block
→ checkMandatory(doc) // client-side mandatory field check
→ _originalSubmit(...args) // only reached if both pass
Why not use frappe-ui's setValue.validate config?
frappe-ui's documentResource.js calls the validate option as:
validate(data) { options.setValue?.validate?.call(vm, data) }
// no `return`, no `await` — any returned value is silently discardedAny value returned from setValue.validate is discarded and never blocks the save. Patching save.submit directly is the only reliable approach for async validation.
triggerOnChange(fieldname, value, row?)
→ useAttachments.trackOldFile(oldValue, newValue) // flag old file URL for cleanup
→ document.doc[fieldname] = value // reactive write
→ controller[fieldname]?.() // fire per-field hook if defined
The hook receives this.value and this.oldValue because document.js sets them on the controller instance before calling controller[fieldname]():
controller.value = value;
controller.oldValue = document.doc[fieldname]; // read before write
controller.currentRowIdx = row?.idx;App.vue uses :key="$route.fullPath" on <router-view> — every navigation creates a new component instance. But setupFormScript is guarded by controllersCache and only runs once per doctype+docname.
| Visit | What happens |
|---|---|
| First visit | setupFormScript runs → triggerOnLoad() then triggerOnRender() |
| Re-visit | setupFormScript is a no-op (cached). Page onMounted runs if (document.doc) await triggerOnRender() |
The if (document.doc) guard prevents a double-fire on the first visit: on first visit, document.doc is null until the resource loads. Once loaded, triggerOnRender was already called from within setupFormScript.
All 8 page components wire this: Lead, Deal, MobileLead, MobileDeal, Contact, Organization, MobileContact, MobileOrganization.
setFieldHtml(fieldname, html) {
document.fieldHtmlMap[fieldname] = html
}fieldHtmlMap is a plain {} added directly to the reactive() cache entry. Vue's reactivity system tracks property writes on a reactive() object automatically, so Field.vue and SidePanelLayout.vue re-render when it changes.
triggerOnChange and setValue never touch fieldHtmlMap — so writing HTML never marks the doc dirty or triggers a save.
document.jssource —documentsCache,useDocument,save.submitpatchscript.jssource —setupScript,evaluateFormClass,createDocProxy,setupHelperMethods- SPEC.md — stable API contracts
- ARCHIVE.md — implementation history and rendering flow