Client Scripts
A Client Script is JavaScript that runs in the browser, on a form or list, for one DocType. It is how you make a form react as it is filled in: set a value, show a warning, add a button, hide a section.
You need a little JavaScript. Everything below is copy-and-adapt, and each example is explained line by line.
A client script runs in the browser, so it can be bypassed — by the API, by an import, by a floor app. Never rely on one to enforce a rule. Use it to make the form pleasant; use a server script to make a rule true.
Creating one
- Awesome Bar (Cmd/Ctrl + K) →
client script→ Client Script List → + Add Client Script. - Fill in:
- DocType — the form it applies to, e.g.
Production Order. - Apply To —Form(most common),List, orReport. - Enabled — tick it. - Write the code in the Script box.
- Save, then reload the page (Cmd/Ctrl + R) — scripts load when the form loads.
If nothing happens, that reload is the first thing to check.
The shape of a form script
Every form script has the same skeleton:
frappe.ui.form.on("Production Order", {
refresh(frm) {
// runs every time the form is displayed
},
});
"Production Order"— the DocType. Must match exactly.refresh— the event. There is one function per event you care about.frm— the form object.frm.docis the record;frm.set_value()changes it.
The events you will use
| Event | Fires when |
|---|---|
onload |
The form is first loaded, before it is drawn |
refresh |
Every time the form is drawn — after load, after save, after reload |
validate |
Just before saving, in the browser |
before_save / after_save |
Around the save |
on_submit / before_submit |
Around submitting |
<fieldname> |
That field's value changed |
A function named after a field runs when the field changes:
frappe.ui.form.on("Production Order", {
total_planned_qty(frm) {
// runs whenever total_planned_qty changes
},
});
Reading and writing
frm.doc.status // read a field
frm.doc.items // read a child table (an array of rows)
frm.set_value("priority", "High"); // write a field — always use set_value
frm.refresh_field("items"); // redraw a field after changing it directly
frm.is_new() // true if the record has never been saved
frm.doc.docstatus // 0 = draft, 1 = submitted, 2 = cancelled
Use frm.set_value() rather than assigning to frm.doc.field — it marks the form dirty, redraws the field, and fires dependent logic. Direct assignment does none of that.
Worked examples
Set a default that Frappe's Default can't express
Default the delivery date to 30 days out, but only on a new record:
frappe.ui.form.on("Production Order", {
onload(frm) {
if (frm.is_new() && !frm.doc.end_date) {
frm.set_value("end_date", frappe.datetime.add_days(frappe.datetime.get_today(), 30));
}
},
});
frm.is_new() stops it overwriting the date on existing records. The !frm.doc.end_date check stops it overwriting a value the user already typed.
Warn without blocking
frappe.ui.form.on("Production Order", {
total_planned_qty(frm) {
if (frm.doc.total_planned_qty > 5000) {
frappe.show_alert({
message: __("That's a large order — check the unit capacity."),
indicator: "orange",
}, 7);
}
},
});
frappe.show_alert is a toast that fades. 7 is the seconds it stays. For something the user must acknowledge, use frappe.msgprint().
__() marks the text for translation — always wrap user-visible strings in it.
Add a button
frappe.ui.form.on("Production Order", {
refresh(frm) {
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__("Open Cut Plan"), () => {
frappe.set_route("Form", "Production Order", frm.doc.name);
}, __("Tools"));
}
},
});
Buttons are added in refresh because the toolbar is rebuilt each time. The third argument groups the button under a dropdown; omit it for a top-level button.
The docstatus === 1 check puts the button on submitted orders only.
Fill a field from another record
When Fetch From isn't enough — because you need a value two links away, or with logic:
frappe.ui.form.on("Production Order", {
tech_pack(frm) {
if (!frm.doc.tech_pack) return;
frappe.db.get_value("Tech Pack", frm.doc.tech_pack, ["model_name", "brand"])
.then((r) => {
if (r.message) {
frm.set_value("custom_style_name", r.message.model_name);
frm.set_value("custom_brand", r.message.brand);
}
});
},
});
frappe.db.get_value fetches from the server, so the result arrives later — which is why the work happens inside .then().
For a single value one link away, use Fetch From instead. This is for the cases it can't cover.
React to a child table
Child tables have their own handler, named after the child DocType:
frappe.ui.form.on("Production Order Operation", {
operation(frm, cdt, cdn) {
const row = locals[cdt][cdn];
console.log("Operation set to", row.operation);
frm.refresh_field("operation_plan");
},
});
cdtis the child DocType name,cdnthe row's id.locals[cdt][cdn]is the row.- Two extra events exist:
operation_plan_addandoperation_plan_remove, named after the parent's field, for rows being added and removed.
Filter a Link field's options
Show only internal production units in a link:
frappe.ui.form.on("Production Order", {
setup(frm) {
frm.set_query("custom_finishing_unit", () => {
return { filters: { unit_type: "Internal" } };
});
},
});
set_query goes in setup — it configures the field once rather than on every redraw.
Hide something conditionally
For simple cases use Depends On. For anything involving more than the current record:
frappe.ui.form.on("Production Order", {
refresh(frm) {
frm.toggle_display("custom_hold_reason", frm.doc.status === "On Hold");
frm.toggle_reqd("custom_hold_reason", frm.doc.status === "On Hold");
frm.toggle_enable("custom_reference", frm.doc.docstatus === 0);
},
});
toggle_displayshows/hides.toggle_reqdmakes mandatory/optional.toggle_enablemakes editable/read-only.
Block a save from the browser
frappe.ui.form.on("Production Order", {
validate(frm) {
if (frm.doc.status === "On Hold" && !frm.doc.custom_hold_reason) {
frappe.throw(__("Enter a hold reason before saving."));
}
},
});
frappe.throw stops the save and shows the message. Useful, but remember the warning at the top — this only protects the form, not the API.
List scripts
Set Apply To = List to customize a list view:
frappe.listview_settings["Production Order"] = {
add_fields: ["status", "end_date"],
get_indicator(doc) {
if (doc.status === "On Hold") return [__("On Hold"), "red", "status,=,On Hold"];
if (doc.status === "Completed") return [__("Completed"), "green", "status,=,Completed"];
return [__(doc.status), "blue", "status,=," + doc.status];
},
};
get_indicator returns [label, colour, filter] — the coloured pill on each row, clickable to filter. add_fields makes sure the fields you read are fetched.
Debugging
Open the browser console — F12, or Cmd+Option+I on a Mac — and go to the Console tab. console.log() output and errors appear there.
Useful things to type into the console while a form is open:
cur_frm.doc // the whole current record
cur_frm.doc.items // a child table
Common causes when a script "doesn't work":
| Symptom | Usual cause |
|---|---|
| Nothing happens at all | Page not reloaded after saving the script, or Enabled not ticked |
| Nothing happens, still | DocType name misspelled — it is case- and space-sensitive |
| Works for you, not for others | Their browser cached the old form. Have them hard-refresh |
| Value set but not saved | Assigned to frm.doc.field instead of using frm.set_value() |
| Button appears twice | Added outside refresh, or added again without a guard |
If a change genuinely won't appear, ask an administrator to run a cache clear on the site, then hard-refresh.
Keeping scripts maintainable
- One script per concern, not one giant script per DocType. They are easier to disable individually.
- Name them clearly — the Client Script's name is all you will have in a year.
- Comment why, not what.
// finance asked for this in Oct 2026beats// set the field. - Wrap user-visible text in
__()so it translates. - Guard everything. Check a value exists before reading into it; a script that throws stops the rest of the form from loading.
What to do next
For rules that must hold no matter how the data arrives, continue to Server scripts.