Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Web Forms

Build public web forms with server-side context loading, client-side validation, kiosk-style auto-submit, and payments.

Updated
Tags
  • frappe
  • web-forms
  • javascript
  • python
  • website
Reading time
7 min

Public-facing data entry forms with custom server-side context loading, client-side validation, field binding, and payment integration.

File Structure

mymodule/web_form/my_form/
├── my_form.json           # Web form definition (fields, payment config, routes)
├── my_form.py             # Server-side get_context() hook
├── my_form.js             # Client-side logic
└── block_form.json        # Custom Builder layout (optional)

Web Form JSON Configuration

Key configuration fields in the JSON definition:

{
    "doctype": "Web Form",
    "name": "my-form",
    "doc_type": "Player Application",
    "route": "my-form",
    "module": "My Module",
    "is_standard": 1,
    "published": 1,

    "login_required": 0,
    "allow_edit": 0,
    "allow_delete": 0,
    "allow_multiple": 0,

    "button_label": "Submit & Pay",
    "success_message": "Thank you for your submission!",

    "breadcrumbs": "[{\"label\": \"Home\", \"route\":\"/\"},{\"label\": \"Apply\", \"route\":\"apply\"}]",

    "accept_payment": 1,
    "payment_gateway": "Stripe-Credit Card",
    "currency": "USD",
    "amount_based_on_field": 1,
    "amount_field": "fee",
    "payer_email_based_on_field": 1,
    "payer_email_field": "email",
    "payer_name_based_on_field": 1,
    "payer_name_field": "name_full"
}
PropertyPurpose
login_required0 for public/guest access, 1 for authenticated only
accept_paymentEnable payment gateway integration
amount_based_on_fieldPayment amount comes from a form field value
amount_fieldWhich field contains the payment amount
payer_email_field / payer_name_fieldFields for payment gateway payer info
allow_edit / allow_deletePost-submission capabilities
is_standardPart of the app (version controlled)
show_listShow list view of submitted records
apply_document_permissionsRespect doctype-level permissions

Server-Side Context Loading

The get_context(context) function runs before rendering and receives a mutable context dict:

import frappe
from frappe.query_builder import DocType
from frappe.utils import format_date

def get_context(context):
    # 1. Custom template (optional)
    if not frappe.form_dict.is_list:
        context.template = "templates/web_form/my_form.html"

    # 2. Business logic gating
    current_season = get_current_season()
    force = frappe.local.request.args.get("force")

    if not current_season.is_open() and not force:
        context.enrollment_closed = True
        context.message = "<strong>Enrollment is closed.</strong>"
        return

    # 3. Dynamic introduction text
    close_date = format_date(current_season.enrollment_close_date)
    fee = frappe.format_value(current_season.fee, {"fieldtype": "Currency"})
    context.introduction_text = f"Entries due by {close_date}. Fee: {fee}."

    # 4. Pre-fill form fields
    context.reference_doc["fee"] = current_season.get_fee()
    context.reference_doc["season"] = current_season.name

    # 5. Sidebar for logged-in users
    if frappe.session.user != "Guest":
        context.show_sidebar = True
        context.sidebar_items = frappe.get_all(
            "Website Sidebar Item",
            filters={"parent": "My Portal"},
            fields=["title", "route", "group"],
            order_by="idx",
        )

    # 6. Pre-fill from logged-in user's contact
    if frappe.session.user == "Guest":
        return

    contact_name = frappe.db.exists("Contact", {"user": frappe.session.user})
    if not contact_name:
        return

    contact = frappe.get_doc("Contact", contact_name)
    context.reference_doc.update({
        "name_first": contact.first_name,
        "name_last": contact.last_name,
        "email": contact.email_id,
        "phone": contact.mobile_no,
    })

    # 7. URL-parameter-driven pre-fill
    player_name = frappe.local.request.args.get("player")
    if not player_name:
        return

    # Verify authorization via QueryBuilder join
    Contact = DocType("Contact")
    Guardian = DocType("Family Guardian")
    Player = DocType("Player")

    is_authorized = (
        frappe.qb.from_(Contact)
        .join(Guardian).on(Guardian.guardian == Contact.name)
        .join(Player).on(Player.family == Guardian.parent)
        .select(Player.name)
        .where(Contact.email_id == contact.email_id)
        .where(Player.name == player_name)
        .run()
    )

    if is_authorized:
        player = frappe.get_doc("Player", player_name)
        context.reference_doc.update({
            "player_first": player.first_name,
            "player_last": player.last_name,
        })

Key Context Properties

PropertyPurpose
context.reference_docDict of initial form field values
context.introduction_textHTML shown above the form
context.templateOverride the Jinja template
context.show_sidebarShow/hide sidebar
context.sidebar_itemsSidebar navigation items
context.enrollment_closedCustom flag for template conditionals

Client-Side Logic

Field Change Handlers

frappe.ready(function () {
    // Auto-compute combined field from parts
    frappe.web_form.on("first_name", (_field, value) => {
        let first = value.trim();
        let last = frappe.web_form.get_value("last_name");
        if (first && last) {
            frappe.web_form.set_value("full_name", `${first} ${last}`);
        }
    });

    // Trim whitespace
    frappe.web_form.on("first_name", (field, value) => {
        let trimmed = value.trim();
        if (value !== trimmed) {
            frappe.web_form.set_value("first_name", trimmed);
        }
    });
});

Immediate Validation Feedback

frappe.web_form.on("birthdate", (field, value) => {
    let age = frappe.datetime.get_diff(
        frappe.datetime.get_today(), value
    ) / 365;

    if (age > 15) {
        frappe.msgprint(__("Sorry, players must be under 16."));
    }
    if (age < 6) {
        frappe.msgprint(__("Sorry, players must be over 5."));
    }
});

Submit Validation

frappe.web_form.events.on("after_load", () => {
    frappe.web_form.validate = () => {
        let values = frappe.web_form.get_values();

        if (!values.liability_release) {
            frappe.msgprint("Please accept the liability release.");
            return false;
        }

        if (values.birthdate) {
            let age = frappe.datetime.get_diff(
                frappe.datetime.get_today(), values.birthdate
            ) / 365;
            if (age > 15 || age < 6) return false;
        }

        return true;
    };
});

Field Property Manipulation

frappe.ready(function () {
    // Hide a field
    frappe.web_form.set_df_property("computed_field", "hidden", 1);

    // Custom page title
    const title = document.querySelector(".web-form-title h1");
    if (title) title.textContent = "Custom Form Title";
});

Web Form API Reference

MethodPurpose
frappe.web_form.on(fieldname, callback)Field change handler
frappe.web_form.get_value(fieldname)Get field value
frappe.web_form.set_value(fieldname, value)Set field value
frappe.web_form.get_values()Get all field values
frappe.web_form.set_df_property(fieldname, prop, value)Set field property
frappe.web_form.validateCustom validation function (return true/false)
frappe.web_form.events.on("after_load", fn)Hook into form lifecycle

Kiosk Forms with Auto-Submit

A web form can act as a single-purpose kiosk, such as a shared time clock where staff scan a badge and the form records an Employee Checkin with no further clicks. The pattern combines four client-side pieces: focus the first field on load, react to that field's change, save programmatically, and send the browser straight back to a fresh form.

Lifecycle Hooks

Besides frappe.web_form.on() and validate, the web form object calls these optional functions if you assign them:

HookWhen it runs
frappe.web_form.after_loadAfter the form is rendered and the client script has run
frappe.web_form.validateAt the start of save(); return false to block the save
frappe.web_form.after_saveAfter the server accepts the document

Each also fires as an event, so frappe.web_form.events.on("after_save", fn) works too.

Example: Time Clock Form

This script assumes a web form on Employee Checkin with the fields employee, log_type (Select: IN / OUT), time, and optional manual_override, manual_override_date, and manual_override_time fields for supervisors.

frappe.web_form.after_load = () => {
    // Put the cursor in the badge field so a scanner can type straight into it
    $("input[data-fieldname='employee']").trigger("focus");
    render_log_type_buttons();
};

frappe.web_form.on("employee", (field, value) => {
    if (!value) return;

    // Optional: show the employee's last punch from a whitelisted method in your app
    frappe
        .xcall("<APP_NAME>.api.get_last_checkin", { employee: value })
        .then((last) => last && frappe.show_alert(__("Last punch: {0}", [last])));

    if (frappe.web_form.get_value("manual_override")) {
        frappe.web_form.set_value(
            "time",
            `${frappe.web_form.get_value("manual_override_date")} ${frappe.web_form.get_value("manual_override_time")}`
        );
    } else {
        frappe.web_form.set_value("time", frappe.datetime.now_datetime());
    }

    frappe.web_form.save();
});

frappe.web_form.after_save = () => {
    // Skip the success page and go straight back to a blank form
    window.location.href = "/<WEB_FORM_ROUTE>/new";
};

// Replace the log type dropdown with large toggle buttons
function render_log_type_buttons() {
    const $select = $("select[data-fieldname='log_type']");
    const $group = $('<div class="log-type-buttons"></div>').insertAfter($select);
    $select.hide();

    $select.find("option").each(function (i) {
        const value = $(this).val();
        if (!value) return;

        const id = `log-type-${value}`;
        $("<input type='radio' name='log_type_choice' class='d-none'>")
            .attr({ id, value })
            .prop("checked", i === 0)
            .on("change", () => frappe.web_form.set_value("log_type", value))
            .appendTo($group);
        $("<label class='btn btn-light btn-lg mr-4'>")
            .attr("for", id)
            .text(__("Clock {0}", [this.textContent.toLowerCase()]))
            .appendTo($group);
    });
}

Notes:

  • Read values with get_value() one field at a time. get_values() validates mandatory fields and shows a "Missing Values Required" message if any are empty, which is noisy on a kiosk.

  • Set the Select through frappe.web_form.set_value() rather than jQuery's .val(), so the form's model and dependent fields stay in sync.

  • The employee selects the log type before scanning, because the scan itself triggers the save.

  • Web forms in current Frappe open a blank form at /<route>/new. Older notes that redirect to ?new=1 predate this.

  • The Success URL setting on the Web Form also redirects after saving, but only after a five-second countdown. Use after_save when the redirect has to be immediate.

Warning

A kiosk form that creates Employee Checkins for any employee ID will accept anyone who can reach the URL. Keep login_required on and sign the kiosk in as a dedicated low-privilege user, or restrict the route at the proxy to the kiosk's network.

Hidden Fields for Server-Side Use

Fields can be hidden from the user but populated by get_context():

{
    "fieldname": "fee",
    "fieldtype": "Currency",
    "hidden": 1,
    "default": "150"
}

The server sets the actual value dynamically:

context.reference_doc["fee"] = current_season.get_fee()

Payment Flow

When accept_payment: 1, form submission redirects to the payment gateway:

  1. User fills form and clicks "Submit & Pay"

  2. Frappe creates the document (e.g., Player Application)

  3. Frappe generates a payment URL using the configured gateway

  4. User is redirected to complete payment

  5. On success, the payment status is updated on the document

The payment amount, payer email, and payer name are extracted from form fields configured in the JSON definition.

Sources

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.