Merge pull request #38567 from frappe/38159-allow-bulk-edit-in-child-tables

feat: allow Bulk Edit in Child Table
This commit is contained in:
Ejaaz Khan 2026-04-27 14:50:55 +05:30 committed by GitHub
commit e6daaa6c48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 256 additions and 0 deletions

View file

@ -111,4 +111,76 @@ context("Grid", () => {
cy.get("@table-form").find(".grid-footer-toolbar").click();
});
});
it("shows edit button only when child table allow_bulk_edit is enabled", () => {
cy.visit("/desk/contact/Test Contact");
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
cy.window()
.its("cur_frm")
.then((frm) => {
const grid = frm.get_field("phone_nos").grid;
grid.meta.allow_bulk_edit = false;
grid.refresh_edit_rows_button();
});
cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true });
cy.get("@table").find(".grid-edit-rows").should("have.class", "hidden");
cy.window()
.its("cur_frm")
.then((frm) => {
const grid = frm.get_field("phone_nos").grid;
grid.meta.allow_bulk_edit = true;
grid.refresh_edit_rows_button();
});
cy.get("@table").find(".grid-edit-rows").should("not.have.class", "hidden");
});
it("bulk edit updates only selected child rows", () => {
const updated_phone = `99999${Date.now().toString().slice(-5)}`;
cy.visit("/desk/contact/Test Contact");
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
cy.window()
.its("cur_frm")
.then((frm) => {
const grid = frm.get_field("phone_nos").grid;
grid.meta.allow_bulk_edit = true;
grid.refresh_edit_rows_button();
expect(frm.doc.phone_nos.length).to.be.greaterThan(1);
const phone_df = grid.docfields.find((df) => df.fieldname === "phone");
expect(phone_df).to.exist;
cy.wrap(phone_df.label).as("phoneFieldLabel");
cy.wrap(frm.doc.phone_nos[1].phone || "").as("secondRowPhoneBefore");
});
cy.get("@table").find('.grid-row[data-idx="1"] .grid-row-check').click({ force: true });
cy.get("@table").find(".grid-edit-rows").click({ force: true });
cy.window()
.its("cur_dialog")
.then((dialog) => {
cy.get("@phoneFieldLabel").then((phoneFieldLabel) => {
return dialog
.set_value("field", phoneFieldLabel)
.then(() => dialog.set_value("value", updated_phone))
.then(() => {
dialog.get_primary_btn().click();
});
});
});
cy.window().its("cur_frm.doc.phone_nos.0.phone").should("eq", updated_phone);
cy.window()
.its("cur_frm")
.then((frm) => {
cy.get("@secondRowPhoneBefore").then((secondRowPhoneBefore) => {
expect(frm.doc.phone_nos[1].phone || "").to.equal(secondRowPhoneBefore);
});
});
});
});

View file

@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"autoname": "Prompt",
"creation": "2013-02-18 13:36:19",
@ -34,6 +35,7 @@
"quick_entry",
"grid_page_length",
"rows_threshold_for_grid_search",
"allow_bulk_edit",
"cb01",
"track_changes",
"track_seen",
@ -715,6 +717,14 @@
"fieldname": "recipient_account_field",
"fieldtype": "Data",
"label": "Recipient Account Field"
},
{
"default": "1",
"depends_on": "istable",
"description": "Enable bulk update of this field across child table rows.",
"fieldname": "allow_bulk_edit",
"fieldtype": "Check",
"label": "Allow Bulk Edit"
}
],
"grid_page_length": 50,

View file

@ -101,6 +101,7 @@ class DocType(Document):
actions: DF.Table[DocTypeAction]
allow_auto_repeat: DF.Check
allow_bulk_edit: DF.Check
allow_copy: DF.Check
allow_events_in_timeline: DF.Check
allow_guest_to_view: DF.Check

View file

@ -24,6 +24,7 @@
"track_views",
"allow_auto_repeat",
"allow_import",
"allow_bulk_edit",
"queue_in_background",
"naming_section",
"naming_rule",
@ -222,6 +223,14 @@
"fieldtype": "Check",
"label": "Allow Import (via Data Import Tool)"
},
{
"default": "1",
"depends_on": "istable",
"description": "Enable bulk edit for child table fields in Form view.",
"fieldname": "allow_bulk_edit",
"fieldtype": "Check",
"label": "Allow Bulk Edit"
},
{
"depends_on": "email_append_to",
"fieldname": "subject_field",

View file

@ -42,6 +42,7 @@ class CustomizeForm(Document):
actions: DF.Table[DocTypeAction]
allow_auto_repeat: DF.Check
allow_bulk_edit: DF.Check
allow_copy: DF.Check
allow_import: DF.Check
autoname: DF.Data | None
@ -744,6 +745,7 @@ doctype_properties = {
"track_views": "Check",
"allow_auto_repeat": "Check",
"allow_import": "Check",
"allow_bulk_edit": "Check",
"show_name_in_global_search": "Check",
"show_preview_popup": "Check",
"default_email_template": "Data",

View file

@ -87,6 +87,10 @@ export default class Grid {
data-action="delete_rows">
${__("Delete")}
</button>
<button type="button" class="btn btn-xs btn-secondary grid-edit-rows hidden"
data-action="bulk_edit_rows">
${__("Edit")}
</button>
<button type="button" class="btn btn-xs btn-danger grid-remove-all-rows hidden"
data-action="delete_all_rows">
${__("Delete all")}
@ -148,6 +152,7 @@ export default class Grid {
this.grid_buttons = this.wrapper.find(".grid-buttons");
this.grid_custom_buttons = this.wrapper.find(".grid-custom-buttons");
this.remove_rows_button = this.grid_buttons.find(".grid-remove-rows");
this.edit_rows_button = this.grid_buttons.find(".grid-edit-rows");
this.duplicate_rows_button = this.grid_buttons.find(".grid-duplicate-rows");
this.remove_all_rows_button = this.grid_buttons.find(".grid-remove-all-rows");
@ -253,13 +258,16 @@ export default class Grid {
// update "Delete" and "Duplicate" button labels
if (num_selected_rows == 1) {
this.remove_rows_button.text(__("Delete row"));
this.edit_rows_button.text(__("Edit row"));
this.duplicate_rows_button.text(__("Duplicate row"));
} else {
this.remove_rows_button.text(__("Delete {0} rows", [num_selected_rows]));
this.edit_rows_button.text(__("Edit {0} rows", [num_selected_rows]));
this.duplicate_rows_button.text(__("Duplicate {0} rows", [num_selected_rows]));
}
this.refresh_remove_rows_button();
this.refresh_edit_rows_button();
this.refresh_duplicate_rows_button();
});
}
@ -386,6 +394,18 @@ export default class Grid {
}
}
refresh_edit_rows_button() {
if (!this.meta?.allow_bulk_edit) {
this.edit_rows_button.toggleClass("hidden", true);
return;
}
const show_button = this.wrapper.find(".grid-body .grid-row-check:checked:first").length
? true
: false;
this.edit_rows_button.toggleClass("hidden", !show_button);
}
debounced_refresh_remove_rows_button = frappe.utils.debounce(
this.refresh_remove_rows_button,
100
@ -547,6 +567,7 @@ export default class Grid {
this.form_grid.toggleClass("error", !!(this.df.reqd && !(this.data && this.data.length)));
this.refresh_remove_rows_button();
this.refresh_edit_rows_button();
this.refresh_duplicate_rows_button();
this.wrapper.trigger("change");
@ -1049,6 +1070,145 @@ export default class Grid {
return d;
}
bulk_edit_rows() {
if (!this.meta?.allow_bulk_edit) return;
const selected_children = this.get_selected_children();
if (!selected_children.length) {
frappe.show_alert({ message: __("No rows selected"), indicator: "orange" });
return;
}
const is_field_editable = (field_doc) => {
const parent_docstatus = this.frm?.doc?.docstatus;
const is_submitted_or_cancelled = [1, 2].includes(parent_docstatus);
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 &&
(!is_submitted_or_cancelled || field_doc.allow_on_submit)
);
};
const editable_fields = (this.docfields || []).filter((field_doc) =>
is_field_editable(field_doc)
);
if (!editable_fields.length) {
frappe.msgprint(__("No editable fields available for bulk edit."));
return;
}
const field_mappings = {};
editable_fields.forEach((field_doc) => {
const field_key = `${field_doc.label}`;
field_mappings[field_key] = Object.assign({}, field_doc);
});
const 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)) ||
field_options.find((value) => field_mappings[value]?.fieldtype === "Select");
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 } = selected_field;
dialog.disable_primary_action();
const update_value = value || null;
const tasks = selected_children.map((doc) =>
frappe.model.set_value(doc.doctype, doc.name, fieldname, update_value)
);
Promise.all(tasks).then(() => {
this.frm && this.frm.dirty();
this.refresh();
dialog.hide();
const row_label = selected_children.length === 1 ? __("row") : __("rows");
frappe.show_alert(
__("Updated {0} selected {1}. Save the form to keep changes.", [
selected_children.length,
row_label,
])
);
});
},
primary_action_label: __("Update {0} rows", [selected_children.length]),
});
if (default_field) set_value_field(dialog);
show_help_text();
function set_value_field(dialogObj) {
const field_value = dialogObj.get_value("field");
if (!field_value || !field_mappings[field_value]) return;
const new_df = Object.assign({}, field_mappings[field_value]);
if (
new_df.label?.match(status_regex) &&
new_df.fieldtype === "Select" &&
!new_df.default
) {
let options = [];
if (typeof new_df.options === "string") {
options = 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;
dialogObj.replace_field("value", new_df);
show_help_text();
}
function show_help_text() {
if (dialog.get_primary_btn().is(":focus, :active")) return;
let value = dialog.get_value("value");
if (value == null || value === "") {
dialog.set_df_property(
"value",
"description",
__("You have not entered a value. The field will be set to empty.")
);
} else {
dialog.set_df_property("value", "description", "");
}
}
dialog.refresh();
dialog.show();
}
set_focus_on_row(idx) {
if (!idx && idx !== 0) {
idx = this.grid_rows.length - 1;

View file

@ -420,6 +420,8 @@ export default class BulkOperations {
}
function show_help_text() {
if (dialog.get_primary_btn().is(":focus, :active")) return;
let value = dialog.get_value("value");
if (value == null || value === "") {
dialog.set_df_property(