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

Builder Page Scripts

Add URL-driven filters to a Frappe Builder page with a client script, and feed the page filtered data from a Python data script.

Updated
Applies to
  • Frappe v15
  • Frappe Builder
Tags
  • frappe
  • builder
  • javascript
  • python
  • website
Reading time
5 min

Frappe Builder pages can run a client script in the browser and a Python data script on the server. This document pairs the two to build a filterable listing page: the client script renders filter controls that write to the URL query string, and the data script reads the query string and returns only the matching records. Use it when you want a public page with filters but don't want to build a full web form or Vue app.

How the Pieces Fit

  1. The visitor picks a filter. The client script sets it as a query parameter (?division=Senior) and reloads the page.

  2. Builder runs the page's data script on the server. The script reads the parameter from frappe.form_dict and puts the results on the data object.

  3. Blocks bound to keys on data (for example a repeater bound to data.days) render the filtered results.

Because the filter state lives in the URL, filtered views can be bookmarked and shared, and the page works without any client-side data fetching.

Client Script: Filter Controls

Add a block with the class filter-area where the filters should appear, then add a client script of type JavaScript to the page from the Builder editor's script panel (it's stored as a Builder Client Script and linked to the page's client_scripts).

window.addEventListener("DOMContentLoaded", () => {
    const filterArea = document.querySelector(".filter-area");
    if (!filterArea) return;

    filterArea.appendChild(
        createRadioFilter("division", "Division", ["All Divisions", "Junior", "Senior", "Open"])
    );
    // Optional free-text search box
    // filterArea.appendChild(createSearchInput("Search"));

    // Set a query parameter and reload so the data script re-runs.
    function setParam(name, value) {
        const params = new URLSearchParams(window.location.search);
        params.set(name, value);
        window.location.search = params.toString();
    }

    function createRadioFilter(name, label, options, showLegend = true) {
        const current = new URLSearchParams(window.location.search).get(name);
        const filter = document.createElement("div");
        filter.classList.add("filter");

        const fieldset = document.createElement("fieldset");
        fieldset.name = name;
        fieldset.id = name;
        fieldset.setAttribute("aria-label", `Select ${label}`);

        if (showLegend) {
            const legend = document.createElement("legend");
            legend.textContent = label;
            fieldset.appendChild(legend);
        }

        for (const option of options) {
            const optionLabel = document.createElement("label");
            const input = document.createElement("input");
            input.type = "radio";
            input.name = name;
            input.value = option;
            input.checked = current === option;
            input.addEventListener("change", () => setParam(name, option));
            optionLabel.append(input, ` ${option}`);
            fieldset.appendChild(optionLabel);
        }

        filter.appendChild(fieldset);
        return filter;
    }

    function createSearchInput(placeholder = "Search") {
        const input = document.createElement("input");
        input.type = "text";
        input.placeholder = placeholder;
        input.autofocus = true;
        input.value = new URLSearchParams(window.location.search).get("search") || "";
        input.addEventListener("keydown", (event) => {
            if (event.key === "Enter") setParam("search", input.value);
        });
        return input;
    }
});

Pass showLegend = false to createRadioFilter when the page already has a heading for the filter. The elements are built with DOM methods rather than an HTML string, so option values never get interpreted as markup.

Tip

Style the radios as pill buttons in a Builder client script of type CSS, for example by hiding the input and styling label:has(input:checked).

Data Script: Filtered Results

Builder runs the page's Data Script (stored in the page's page_data_script field) in Frappe's safe execution environment. Anything you set on data becomes available to the page's blocks, and frappe.form_dict holds the query parameters. frappe.db.sql is available for read-only SELECT queries, and json.loads is available for parsing.

This example returns upcoming schedule days, each with its matches nested inside, from a parent doctype Schedule Day and its child table Schedule Match. MariaDB builds the nested structure in one query with JSON_ARRAYAGG and JSON_OBJECT.

division = frappe.form_dict.get("division")
if division == "All Divisions":
    division = None

data.heading = "Game Schedule"
if division:
    data.heading = data.heading + " for " + division

query = """
SELECT JSON_ARRAYAGG(
    JSON_OBJECT(
        'date', DATE_FORMAT(d.date, '%%W, %%M %%e'),
        'type', d.type,
        'matches', COALESCE(m.matches, JSON_ARRAY())
    ) ORDER BY d.date ASC
)
FROM `tabSchedule Day` AS d
LEFT JOIN (
    SELECT
        parent,
        JSON_ARRAYAGG(
            JSON_OBJECT(
                'division', division,
                'field', CONCAT('@ field ', field),
                'teams', CONCAT(team_guest, ' vs ', team_home),
                'time', DATE_FORMAT(time, '%%h:%%i %%p')
            ) ORDER BY time ASC
        ) AS matches
    FROM `tabSchedule Match`
    WHERE (division = %s OR %s IS NULL)
    GROUP BY parent
) AS m ON d.name = m.parent
WHERE d.published = 1
  AND d.date > CURDATE()
"""

result = frappe.db.sql(query, (division, division))[0][0]
data.days = json.loads(result or "[]")

Notes on the query:

  • Literal % signs in DATE_FORMAT are doubled (%%) because the query has %s parameters.

  • The division is passed twice so %s IS NULL matches every row when no filter is set. Never format the parameter into the SQL string yourself.

  • JSON_ARRAYAGG returns NULL when no rows match, hence the or "[]" fallback.

  • The safe execution environment is built on RestrictedPython, which rejects augmented assignment to attributes, so write data.heading = data.heading + ... rather than data.heading += ....

In the page, bind a repeater block to days, and a nested repeater inside it to matches. Bind a heading block to heading.

Note

The data script runs on every page view. Keep the query indexed and narrow (here, published and date), and prefer the query builder or frappe.get_all when you don't need SQL-only features like JSON aggregation.

Sources

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