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.

Important

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

  1. Awesome Bar (Cmd/Ctrl + K) → client scriptClient Script List+ Add Client Script.
  2. Fill in: - DocType — the form it applies to, e.g. Production Order. - Apply ToForm (most common), List, or Report. - Enabled — tick it.
  3. Write the code in the Script box.
  4. 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.doc is 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");
    },
});
  • cdt is the child DocType name, cdn the row's id.
  • locals[cdt][cdn] is the row.
  • Two extra events exist: operation_plan_add and operation_plan_remove, named after the parent's field, for rows being added and removed.

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_display shows/hides.
  • toggle_reqd makes mandatory/optional.
  • toggle_enable makes 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 2026 beats // 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.

WhatsApp