Bulk Editing Child Table Fields in Frappe v15 List View

Frappe v15 lets you bulk edit records from the List View, but only parent-level fields. If you need to update a field inside a child table — like a rate, date, or status inside Items — the option simp

 · 7 min read

Frappe v15 ships with a bulk edit feature in the List View. You select multiple records, click Actions > Edit, pick a field, set a value, and it updates all selected documents. It is a useful feature, but it has a hard limitation: it only exposes fields from the parent doctype. If you want to update a field inside a child table — say, a status field inside Items or Taxes — you are out of luck in v15.

This limitation was addressed in Frappe v16, where child table fields were added to the bulk edit dialog. But many production deployments are still on v15 and upgrading is not always an option. This post walks through how to backport that capability into a custom Frappe app without touching the Frappe core.


Before and After

Before — Frappe v15 default: only parent fields

After — With the override: parent and child table fields

The field names in the patched version follow the format Field Label (Table Label) so you can clearly tell which doctype each field belongs to.

What We Are Overriding and Why

The bulk edit flow in Frappe's List View involves two parts:

list_view.jsget_actions_menu_items()

This method builds the Actions dropdown. Inside it, a bulk_edit() closure constructs field_mappings — a dictionary of editable fields passed to bulk_operations.edit(). In v15, this only iterates parent doctype fields. In v16, it also iterates Table fieldtype children.

bulk_operations.jsedit()

This method receives field_mappings and renders the dialog. It also calls frappe.call to push the update to the server via submit_cancel_or_update_docs.

The server-side method submit_cancel_or_update_docs in v15 only does doc.update(data) followed by doc.save(). It has no concept of child table updates. So we need to override that too.


The Plan

  1. Override ListView.prototype.get_actions_menu_items in a custom app JS file to include child table fields in field_mappings.
  2. Replace the edit action with a custom _patched_edit function that handles the is_child_field flag and sends a structured child_table_updates payload to the server.
  3. Write a custom Python whitelisted method that handles both parent field updates and child table row updates.
  4. Register everything in hooks.py.

We do not touch Frappe core. Everything lives in the custom app.


Step 1: Understanding the Field Mappings Structure

In the original v15 code, field_mappings looks like this:

{
  "Status": { fieldname: "status", fieldtype: "Select", ... },
  "Description": { fieldname: "description", fieldtype: "Data", ... }
}

We extend this to include child table fields by adding metadata about which child doctype and parent table fieldname they belong to:

{
  "Status (Sales Order)": { fieldname: "status", is_child_field: false, ... },
  "Delivery Date (Items)": {
    fieldname: "delivery_date",
    is_child_field: true,
    child_doctype: "Sales Order Item",
    parent_table_field: "items",
    ...
  }
}

The key format Field Label (Parent or Child Table Label) avoids collisions when parent and child have fields with the same label.


Step 2: Frontend Override — bulk_operations_override.js

Create this file in your custom app:

your_app/public/js/bulk_operations_override.js

Part A: Override get_actions_menu_items

We call the original method first to get all existing items, then find the Edit item and replace only its action. This is the minimal change — we are not rewriting the entire method.

frappe.after_ajax(() => {
    if (!frappe.views?.ListView) return;

    const _orig_get_actions = frappe.views.ListView.prototype.get_actions_menu_items;

    frappe.views.ListView.prototype.get_actions_menu_items = function () {
        const items = _orig_get_actions.call(this);

        const edit_item = items.find((item) => item.label === __("Edit"));
        if (!edit_item) return items;

        const doctype = this.doctype;

        const is_field_editable = (field_doc) => {
            return (
                field_doc.fieldname &&
                frappe.model.is_value_type(field_doc) &&
                field_doc.fieldtype !== "Read Only" &&
                !field_doc.hidden &&
                !field_doc.read_only &&
                !field_doc.is_virtual
            );
        };

        edit_item.action = () => {
            let field_mappings = {};

            frappe.meta.get_docfields(doctype).forEach((field_doc) => {
                // Parent doctype fields
                if (is_field_editable(field_doc)) {
                    const field_key = `${field_doc.label} (${doctype})`;
                    field_mappings[field_key] = Object.assign({}, field_doc, {
                        is_child_field: false,
                    });
                }

                // Child table fields
                if (field_doc.fieldtype === "Table" && field_doc.options) {
                    const child_doctype = field_doc.options;
                    frappe.meta.get_docfields(child_doctype).forEach((child_field) => {
                        if (is_field_editable(child_field)) {
                            const field_key = `${child_field.label} (${field_doc.label})`;
                            field_mappings[field_key] = Object.assign({}, child_field, {
                                is_child_field: true,
                                child_doctype: child_doctype,
                                parent_table_field: field_doc.fieldname,
                            });
                        }
                    });
                }
            });

            this.disable_list_update = true;
            _patched_edit.call(
                { doctype },
                this.get_checked_items(true),
                field_mappings,
                () => {
                    this.disable_list_update = false;
                    this.refresh();
                }
            );
        };

        return items;
    };
});

Two things worth noting here:

  • _orig_get_actions.call(this) preserves all other actions — Export, Assign, Print, Delete, Submit, Cancel. We only patch the Edit action.
  • _patched_edit.call({ doctype }, ...) passes doctype as the this context. The function uses this.doctype internally, so we pass a plain object instead of the full listview instance.

Part B: The Patched Edit Dialog

This function renders the Bulk Edit dialog and handles the server call. The key differences from the v15 original:

  • It reads is_child_field, child_doctype from the selected field mapping.
  • For child fields, it wraps the update in a child_table_updates structure that the custom Python method understands.
  • It calls our custom Python method instead of the default one.
function _patched_edit(docnames, field_mappings, done) {
    let field_options = Object.keys(field_mappings).sort((a, b) =>
        __(cstr(field_mappings[a].label)).localeCompare(cstr(__(field_mappings[b].label)))
    );

    const status_regex = /status/i;
    const default_field = field_options.find((value) => status_regex.test(value));

    const dialog = new frappe.ui.Dialog({
        title: __("Bulk Edit"),
        fields: [
            {
                fieldtype: "Select",
                options: field_options,
                default: default_field,
                label: __("Field"),
                fieldname: "field",
                reqd: 1,
                onchange: () => set_value_field(dialog),
            },
            {
                fieldtype: "Data",
                label: __("Value"),
                fieldname: "value",
                onchange() {
                    show_help_text();
                },
            },
        ],
        primary_action: ({ value }) => {
            const selected_field = field_mappings[dialog.get_value("field")];
            const { fieldname, is_child_field, child_doctype } = selected_field;

            dialog.disable_primary_action();

            let update_data = {};
            if (is_child_field) {
                update_data = {
                    child_table_updates: {
                        [child_doctype]: {
                            [fieldname]: value || null,
                        },
                    },
                };
            } else {
                update_data[fieldname] = value || null;
            }

            frappe
                .call({
                    method: "your_app.api.bulk_update.custom_submit_cancel_or_update_docs",
                    args: {
                        doctype: this.doctype,
                        freeze: true,
                        docnames: docnames,
                        action: "update",
                        data: update_data,
                    },
                })
                .then((r) => {
                    let failed = r.message || [];
                    if (failed.length && !r._server_messages) {
                        dialog.enable_primary_action();
                        frappe.throw(
                            __("Cannot update {0}", [
                                failed.map((f) => (f.bold ? f.bold() : f)).join(", "),
                            ])
                        );
                    }
                    done();
                    dialog.hide();
                    frappe.show_alert(__("Updated successfully"));
                });
        },
        primary_action_label: __("Update {0} records", [docnames.length]),
    });

    if (default_field) set_value_field(dialog);
    show_help_text();

    function set_value_field(dialogObj) {
        const new_df = Object.assign({}, field_mappings[dialogObj.get_value("field")]);
        if (
            new_df.label.match(status_regex) &&
            new_df.fieldtype === "Select" &&
            !new_df.default
        ) {
            let options = typeof new_df.options === "string" ? new_df.options.split("\n") : [];
            new_df.default = options[0] || options[1];
        }
        new_df.label = __("Value");
        new_df.onchange = show_help_text;
        delete new_df.depends_on;
        delete new_df.is_child_field;
        delete new_df.child_doctype;
        dialogObj.replace_field("value", new_df);
        show_help_text();
    }

    function show_help_text() {
        const value = dialog.get_value("value");
        dialog.set_df_property(
            "value",
            "description",
            value == null || value === ""
                ? __("You have not entered a value. The field will be set to empty.")
                : ""
        );
    }

    dialog.refresh();
    dialog.show();
}

Step 3: Backend Override — bulk_update.py

Create this file in your custom app:

your_app/api/bulk_update.py

The original submit_cancel_or_update_docs in v15 does doc.update(data) and doc.save(). It knows nothing about child_table_updates. Our version intercepts that key from the data payload, applies updates row-by-row to the child table, and then saves.

import frappe
from frappe.utils.scheduler import is_scheduler_inactive
from frappe import _
from frappe.core.doctype.submission_queue.submission_queue import queue_submission


@frappe.whitelist()
def custom_submit_cancel_or_update_docs(doctype, docnames, action="submit", data=None, task_id=None):
    if isinstance(docnames, str):
        docnames = frappe.parse_json(docnames)

    if len(docnames) < 20:
        return custom_bulk_action(doctype, docnames, action, data, task_id)
    elif len(docnames) <= 500:
        frappe.msgprint(_("Bulk operation is enqueued in background."), alert=True)
        frappe.enqueue(
            custom_bulk_action,
            doctype=doctype,
            docnames=docnames,
            action=action,
            data=data,
            task_id=task_id,
            queue="short",
            timeout=1000,
        )
    else:
        frappe.throw(
            _("Bulk operations only support up to 500 documents."),
            title=_("Too Many Documents")
        )


def custom_bulk_action(doctype, docnames, action, data, task_id=None):
    if data:
        data = frappe.parse_json(data)

    child_table_updates = data.get("child_table_updates") if data else None
    failed = []
    num_documents = len(docnames)

    for idx, docname in enumerate(docnames, 1):
        doc = frappe.get_doc(doctype, docname)
        try:
            message = ""
            if action == "submit" and doc.docstatus.is_draft():
                if doc.meta.queue_in_background and not is_scheduler_inactive():
                    queue_submission(doc, action)
                    message = _("Queuing {0} for Submission").format(doctype)
                else:
                    doc.submit()
                    message = _("Submitting {0}").format(doctype)

            elif action == "cancel" and doc.docstatus.is_submitted():
                doc.cancel()
                message = _("Cancelling {0}").format(doctype)

            elif action == "update" and not doc.docstatus.is_cancelled():
                # Apply child table updates
                if child_table_updates:
                    table_fields = doc.meta.get_table_fields()
                    for child_doctype, field_updates in child_table_updates.items():
                        table_fieldname = next(
                            (
                                field.fieldname
                                for field in table_fields
                                if field.options == child_doctype
                            ),
                            None,
                        )
                        if table_fieldname and hasattr(doc, table_fieldname):
                            child_meta = frappe.get_meta(child_doctype)
                            child_docs = getattr(doc, table_fieldname)
                            for child_doc in child_docs:
                                for fieldname, value in field_updates.items():
                                    if child_meta.has_field(fieldname):
                                        setattr(child_doc, fieldname, value)

                # Apply parent field updates (exclude child_table_updates key)
                if data:
                    parent_data = {
                        k: v for k, v in data.items() if k != "child_table_updates"
                    }
                    if parent_data:
                        doc.update(parent_data)

                doc.save()
                message = _("Updating {0}").format(doctype)

            else:
                failed.append(docname)

            frappe.db.commit()
            frappe.publish_progress(
                percent=idx / num_documents * 100,
                title=message,
                description=docname,
                task_id=task_id,
            )

        except Exception:
            failed.append(docname)
            frappe.db.rollback()

    return failed

A few things to call out in the Python:

  • We check child_table_updates separately from the rest of data. The original code called doc.update(data) on everything, which would have tried to set child_table_updates as a field on the parent doc and silently failed or errored.
  • We use child_meta.has_field(fieldname) as a safety check before calling setattr. This prevents bad data from crashing the update loop for every document.
  • The threshold logic (20 inline, 20-500 enqueued, 500+ rejected) mirrors the original method so behavior stays consistent.

Step 4: Register Everything in hooks.py

# hooks.py

app_include_js = [
    "/assets/your_app/js/bulk_operations_override.js"
]

Then build and clear cache:

bench build --app your_app
bench clear-cache

Hard refresh the browser after the build completes.


How It Works End to End

  1. User opens any List View, selects multiple records, and clicks Actions > Edit.
  2. Our overridden get_actions_menu_items runs. It calls the original method first, then replaces the Edit action.
  3. The new action iterates parent fields and also iterates through every Table fieldtype field, fetching child doctype fields using frappe.meta.get_docfields.
  4. The _patched_edit dialog opens with the full field list, now including entries like Delivery Date (Items) or Rate (Taxes and Charges).
  5. User picks a child field. The dialog dynamically replaces the Value field with the correct fieldtype (Date, Currency, Select, etc.) using replace_field.
  6. On submit, if is_child_field is true, the update payload becomes { child_table_updates: { "Sales Order Item": { delivery_date: "2025-01-01" } } }.
  7. The custom Python method receives this, iterates every row in the child table of each selected document, and applies the value using setattr.
  8. doc.save() is called once per document after all updates are applied.

Limitations and Things to Know

All child rows are updated. When you bulk edit a child table field, every row in that child table gets the new value. There is no row-level filtering. This is intentional and matches how v16 implements it.

Validation still runs. Since we call doc.save(), all doctype-level validate hooks run. If a value is invalid for a specific document, that document ends up in the failed list and the rest continue.

Linked and Dynamic Link fields in child tables will appear in the dropdown but may need the target doctype populated for the Value field to behave correctly. frappe.meta.get_docfields returns the full field meta, so options should be set correctly for Link fields.

Upgrade note. When you move to Frappe v16, this override becomes redundant. The get_actions_menu_items prototype patch calls the original method first, so on v16 the original already returns the correct behavior. You would just want to remove the override to avoid double-listing child fields. Keep a note in your app changelog.

The frappe.after_ajax wrapper is important. It ensures the frappe.views.ListView class is fully loaded before we attempt to patch its prototype. Without it, the prototype patch may run before the class is defined and silently do nothing.


File Structure Summary

your_app/
    public/
        js/
            bulk_operations_override.js   # JS override
    api/
        bulk_update.py                    # Python override
    hooks.py                              # app_include_js registration

Conclusion

This is a clean, non-invasive way to backport a v16 feature into a v15 deployment. No core files are modified. The override is additive — it calls the original method and extends only what needs to change. The Python side is a direct replacement method registered under a custom path, so it does not interfere with other doctypes or Frappe internals.

The same pattern — call original, find item, replace action — can be used to customize other bulk operations in the List View without forking Frappe.


No comments yet.

Add a comment
Ctrl+Enter to add comment