Repository: frappe/frappe
Pull request: List view bulk edit option
Fixes https://github.com/frappe/erpnext/issues/3191
![bulk edit1](https://user-images.githubusercontent.com/13928957/36834925-7c94ec14-1d5a-11e8-8add-0b67828479d1.gif)
![bulk edit2](https://user-images.githubusercontent.com/13928957/36837166-95b64466-1d61-11e8-9855-93ee69161b4e.gif)


Problem statement:
Multiple Cancellation/Assignment/Submit Required
I have one suggestion that there should be a way by which user can cancel/assign/submit multiple documents in one go.

Like for e.g., I have 200 Material Request and I want to cancel all therefore as per current feature I have to open each and everyone and press cancel button.

My opinion is system should raise error for the request which have some problem to cancel and leave them but user should be able to cancel the all in one go.

Same should be applied to
Assigned To
Submit

Hints from issue discussion:
It's common practice in business environments to review documents (Expense Claims, Journal Vouchers, Orders etc) in batches for approval. It would greatly help to have a Bulk Submit feature for scenarios like this. There is already a feature to Delete documents in bulk from the List View. This should be extended to Submit and Cancel as well

Kind regards,
Hi, ist this feature planned to be added?
Thanks!
+1 can't believe this is not in. Not only delete, also multiedit.
Hi,

Any updates on this? It's quite needed and long overdue

Kind regards,
+1 , bulk cancellation  plus bulk amend- **(best scenario to include in bulk update tool)** to make those documents in draft so that they can be updated from bulk update tool.
@ruchin78 Please Add **Close** (Reopen if closed) to the request.
+1 for this idea, being able to submit multiple documents at once is critical to our workflow.

Commit message: Refactor

commonified redundant method
get_json -> parse_json

Patch to review:
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 7778068fc095..e03650aa93c0 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -1468,4 +1468,8 @@ def get_version(doctype, name, limit = None, head = False, raise_err = True):
 
 @whitelist(allow_guest = True)
 def ping():
-	return "pong"
\ No newline at end of file
+	return "pong"
+
+def parse_json(val):
+	from frappe.utils import parse_json
+	return parse_json(val)
diff --git a/frappe/async.py b/frappe/async.py
index 15ba8a4af691..6af91d930162 100644
--- a/frappe/async.py
+++ b/frappe/async.py
@@ -47,8 +47,8 @@ def full_path(_file):
 def is_file_old(file_path):
 	return ((time.time() - os.stat(file_path).st_mtime) > TASK_LOG_MAX_AGE)
 
-def publish_progress(percent, title=None, doctype=None, docname=None):
-	publish_realtime('progress', {'percent': percent, 'title': title},
+def publish_progress(percent, title=None, doctype=None, docname=None, description=None):
+	publish_realtime('progress', {'percent': percent, 'title': title, 'description': description},
 		user=frappe.session.user, doctype=doctype, docname=docname)
 
 def publish_realtime(event=None, message=None, room=None,
@@ -200,6 +200,6 @@ def get_task_progress_room(task_id):
 # frappe.chat
 def get_chat_room(room):
 	room = ''.join([frappe.local.site, ":room:", room])
-	
+
 	return room
 # end frappe.chat room
\ No newline at end of file
diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js
index 976266395798..02b5adc54e22 100644
--- a/frappe/core/doctype/data_import/data_import.js
+++ b/frappe/core/doctype/data_import/data_import.js
@@ -33,10 +33,13 @@ frappe.ui.form.on('Data Import', {
 		frm.disable_save();
 		frm.dashboard.clear_headline();
 		if (frm.doc.reference_doctype && !frm.doc.import_file) {
-			frm.dashboard.add_comment(__('Please attach a file to import'));
+			frm.page.set_indicator(__('Please attach a file to import'), 'orange');
 		} else {
 			if (frm.doc.import_status) {
-				frm.dashboard.add_comment(frm.doc.import_status);
+				const listview_settings = frappe.listview_settings['Data Import'];
+				const indicator = listview_settings.get_indicator(frm.doc);
+				
+				frm.page.set_indicator(indicator[0], indicator[1]);
 
 				if (frm.doc.import_status==="In Progress") {
 					frm.dashboard.add_progress("Data Import Progress", "0");
diff --git a/frappe/desk/doctype/bulk_update/bulk_update.js b/frappe/desk/doctype/bulk_update/bulk_update.js
index cfee158ac75b..0526fc0f5dcd 100644
--- a/frappe/desk/doctype/bulk_update/bulk_update.js
+++ b/frappe/desk/doctype/bulk_update/bulk_update.js
@@ -4,10 +4,9 @@
 frappe.ui.form.on('Bulk Update', {
 	refresh: function(frm) {
 		frm.page.set_primary_action(__('Update'), function() {
-			if(!frm.doc.update_value){
+			if (!frm.doc.update_value) {
 				frappe.throw(__('Field "value" is mandatory. Please specify value to be updated'));
-			}
-			else{
+			} else {
 				frappe.call({
 					method: 'frappe.desk.doctype.bulk_update.bulk_update.update',
 					args: {
@@ -17,13 +16,19 @@ frappe.ui.form.on('Bulk Update', {
 						condition: frm.doc.condition,
 						limit: frm.doc.limit
 					},
-					callback: function() {
-						frappe.hide_progress();
+				}).then(r => {
+					let failed = r.message;
+					if (!failed) failed = [];
+
+					if (failed.length && !r._server_messages) {
+						frappe.throw(__('Cannot update {0}', [failed.map(f => f.bold ? f.bold(): f).join(', ')]));
 					}
+					frappe.hide_progress();
 				});
 			}
 		});
 	},
+
 	document_type: function(frm) {
 		// set field options
 		if(!frm.doc.document_type) return;
diff --git a/frappe/desk/doctype/bulk_update/bulk_update.py b/frappe/desk/doctype/bulk_update/bulk_update.py
index ae6c73d8e08e..c92d892d8dc5 100644
--- a/frappe/desk/doctype/bulk_update/bulk_update.py
+++ b/frappe/desk/doctype/bulk_update/bulk_update.py
@@ -22,23 +22,47 @@ def update(doctype, field, value, condition='', limit=500):
 	if ';' in condition:
 		frappe.throw(_('; not allowed in condition'))
 
-	items = frappe.db.sql_list('''select name from `tab{0}`{1} limit 0, {2}'''.format(doctype,
-		condition, limit), debug=1)
-	n = len(items)
+	docnames = frappe.db.sql_list(
+		'''select name from `tab{0}`{1} limit 0, {2}'''.format(doctype, condition, limit)
+	)
+	data = {}
+	data[field] = value
+	return submit_cancel_or_update_docs(doctype, docnames, 'update', data)
 
-	for i, d in enumerate(items):
-		doc = frappe.get_doc(doctype, d)
-		doc.set(field, value)
+@frappe.whitelist()
+def submit_cancel_or_update_docs(doctype, docnames, action='submit', data=None):
+	docnames = frappe.parse_json(docnames)
+
+	if data:
+		data = frappe.parse_json(data)
 
+	failed = []
+
+	for i, d in enumerate(docnames, 1):
+		doc = frappe.get_doc(doctype, d)
 		try:
-			doc.save()
-		except Exception as e:
-			frappe.msgprint(_("Validation failed for {0}").format(frappe.bold(doc.name)))
-			raise e
+			if action == 'submit':
+				doc.submit()
+				message = _('Submiting {0}').format(doctype)
+			elif action == 'cancel':
+				doc.cancel()
+				message = _('Cancelling {0}').format(doctype)
+			elif action == 'update':
+				doc.update(data)
+				doc.save()
+				message = _('Updating {0}').format(doctype)
+
+			show_progress(docnames, message, i, d)
 
-		frappe.publish_progress(float(i)*100/n,
-			title = _('Updating Records'), doctype='Bulk Update', docname='Bulk Update')
+		except Exception:
+			failed.append(d)
+	return failed
 
-	# clear messages
-	frappe.local.message_log = []
-	frappe.msgprint(_('{0} records updated').format(n), title=_('Success'), indicator='green')
\ No newline at end of file
+def show_progress(docnames, message, i, description):
+	n = len(docnames)
+	if n >= 10:
+		frappe.publish_progress(
+			float(i) * 100 / n,
+			title = message,
+			description = description
+		)
diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py
index 313610760894..e9e994f0bba9 100644
--- a/frappe/desk/reportview.py
+++ b/frappe/desk/reportview.py
@@ -224,8 +224,8 @@ def delete_items():
 			frappe.delete_doc(doctype, d)
 			if len(il) >= 5:
 				frappe.publish_realtime("progress",
-					dict(progress=[i+1, len(il)], title=_('Deleting {0}').format(doctype)),
-					user=frappe.session.user)
+					dict(progress=[i+1, len(il)], title=_('Deleting {0}').format(doctype), description=d),
+						user=frappe.session.user)
 		except Exception:
 			failed.append(d)
 
diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js
index 9b559a644b9f..85bb1f0e61d7 100644
--- a/frappe/public/js/frappe/form/layout.js
+++ b/frappe/public/js/frappe/form/layout.js
@@ -119,19 +119,27 @@ frappe.ui.form.Layout = Class.extend({
 		});
 	},
 
-	make_field: function(df, colspan, render = false) {
+	change_df: function(fieldname, df, render) {
+		df.fieldname = fieldname; // change of fieldname is avoided
+		if (this.fields_dict[fieldname] && this.fields_dict[fieldname].df) {
+			const fieldobj = this.init_field(df, render);
+			this.fields_dict[fieldname].$wrapper.remove();
+			this.fields_list.splice(this.fields_dict[fieldname], 1, fieldobj);
+			this.fields_dict[fieldname] = fieldobj;
+			if (this.frm) {
+				fieldobj.perm = this.frm.perm;
+			}
+			this.section.fields_list.splice(this.section.fields_dict[fieldname], 1, fieldobj);
+			this.section.fields_dict[fieldname] = fieldobj;
+			this.refresh();
+		}
+	},
+
+	make_field: function(df, colspan, render) {
 		!this.section && this.make_section();
 		!this.column && this.make_column();
 
-		var fieldobj = frappe.ui.form.make_control({
-			df: df,
-			doctype: this.doctype,
-			parent: this.column.wrapper.get(0),
-			frm: this.frm,
-			render_input: render
-		});
-
-		fieldobj.layout = this;
+		const fieldobj = this.init_field(df, render);
 		this.fields_list.push(fieldobj);
 		this.fields_dict[df.fieldname] = fieldobj;
 		if(this.frm) {
@@ -141,6 +149,20 @@ frappe.ui.form.Layout = Class.extend({
 		this.section.fields_list.push(fieldobj);
 		this.section.fields_dict[df.fieldname] = fieldobj;
 	},
+
+	init_field: function(df, render = false) {
+		const fieldobj = frappe.ui.form.make_control({
+			df: df,
+			doctype: this.doctype,
+			parent: this.column.wrapper.get(0),
+			frm: this.frm,
+			render_input: render
+		});
+
+		fieldobj.layout = this;
+		return fieldobj;
+	},
+
 	make_page: function(df) {
 		var me = this,
 			head = $('<div class="form-clickable-section text-center">\
@@ -234,7 +256,7 @@ frappe.ui.form.Layout = Class.extend({
 				if(cnt % 2) {
 					$this.addClass("shaded-section");
 				}
-				cnt ++;
+				cnt++;
 			}
 		});
 	},
@@ -414,11 +436,9 @@ frappe.ui.form.Layout = Class.extend({
 			} else {
 				field.grid.grid_rows[0].toggle_view(true);
 			}
-		}
-		else if(field.editor) {
+		} else if(field.editor) {
 			field.editor.set_focus();
-		}
-		else if(field.$input) {
+		} else if(field.$input) {
 			field.$input.focus();
 		}
 	},
@@ -632,11 +652,13 @@ frappe.ui.form.Column = Class.extend({
 			</form>\
 		</div>').appendTo(this.section.body)
 			.find("form")
-			.on("submit", function() { return false; });
+			.on("submit", function() {
+				return false;
+			});
 
-		if(this.df.label) {
-			$('<label class="control-label">'+ __(this.df.label)
-				+'</label>').appendTo(this.wrapper);
+		if (this.df.label) {
+			$('<label class="control-label">' + __(this.df.label)
+				+ '</label>').appendTo(this.wrapper);
 		}
 	},
 	resize_all_columns: function() {
diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js
index ebce0101c5ee..9abcb076d895 100644
--- a/frappe/public/js/frappe/list/base_list.js
+++ b/frappe/public/js/frappe/list/base_list.js
@@ -41,7 +41,6 @@ frappe.views.BaseList = class BaseList {
 		this.method = 'frappe.desk.reportview.get';
 
 		this.can_create = frappe.model.can_create(this.doctype);
-		this.can_delete = frappe.model.can_delete(this.doctype);
 		this.can_write = frappe.model.can_write(this.doctype);
 
 		this.fields = [];
@@ -146,34 +145,9 @@ frappe.views.BaseList = class BaseList {
 
 	setup_page_head() {
 		this.page.set_title(this.page_title);
-		this.set_menu_items();
 		this.set_breadcrumbs();
 	}
 
-	set_menu_items() {
-		this.page.clear_menu();
-		const $secondary_action = this.page.set_secondary_action(
-			this.secondary_action.label,
-			this.secondary_action.action,
-			this.secondary_action.icon
-		);
-		if (!this.secondary_action.icon) {
-			$secondary_action.addClass('hidden-xs');
-		} else {
-			$secondary_action.addClass('visible-xs');
-		}
-
-		this.menu_items.map(item => {
-			if (item.condition && item.condition() === false) {
-				return;
-			}
-			const $item = this.page.add_menu_item(item.label, item.action, item.standard);
-			if (item.class) {
-				$item && $item.addClass(item.class);
-			}
-		});
-	}
-
 	set_breadcrumbs() {
 		frappe.breadcrumbs.add(this.meta.module, this.doctype);
 	}
diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js
new file mode 100644
index 000000000000..719874c8d837
--- /dev/null
+++ b/frappe/public/js/frappe/list/bulk_operations.js
@@ -0,0 +1,187 @@
+export default class BulkOperations {
+	constructor({ doctype }) {
+		if (!doctype) frappe.throw(__('Doctype required'));
+		this.doctype = doctype;
+	}
+
+	print(docs) {
+		const print_settings = frappe.model.get_doc(':Print Settings', 'Print Settings');
+		const allow_print_for_draft = cint(print_settings.allow_print_for_draft);
+		const is_submittable = frappe.model.is_submittable(this.doctype);
+		const allow_print_for_cancelled = cint(print_settings.allow_print_for_cancelled);
+
+		const valid_docs = docs.filter(doc => {
+			return !is_submittable || doc.docstatus === 1 ||
+				(allow_print_for_cancelled && doc.docstatus == 2) ||
+				(allow_print_for_draft && doc.docstatus == 0) ||
+				frappe.user.has_role('Administrator');
+		}).map(doc => doc.name);
+
+		const invalid_docs = docs.filter(doc => !valid_docs.includes(doc.name));
+
+		if (invalid_docs.length > 0) {
+			frappe.msgprint(__('You selected Draft or Cancelled documents'));
+			return;
+		}
+
+		if (valid_docs.length > 0) {
+			const dialog = new frappe.ui.Dialog({
+				title: __('Print Documents'),
+				fields: [{
+					'fieldtype': 'Check',
+					'label': __('With Letterhead'),
+					'fieldname': 'with_letterhead'
+				},
+				{
+					'fieldtype': 'Select',
+					'label': __('Print Format'),
+					'fieldname': 'print_sel',
+					options: frappe.meta.get_print_formats(this.doctype)
+				}]
+			});
+
+			dialog.set_primary_action(__('Print'), args => {
+				if (!args) return;
+				const default_print_format = frappe.get_meta(this.doctype).default_print_format;
+				const with_letterhead = args.with_letterhead ? 1 : 0;
+				const print_format = args.print_sel ? args.print_sel : default_print_format;
+				const json_string = JSON.stringify(valid_docs);
+
+				const w = window.open('/api/method/frappe.utils.print_format.download_multi_pdf?' +
+					'doctype=' + encodeURIComponent(this.doctype) +
+					'&name=' + encodeURIComponent(json_string) +
+					'&format=' + encodeURIComponent(print_format) +
+					'&no_letterhead=' + (with_letterhead ? '0' : '1'));
+				if (!w) {
+					frappe.msgprint(__('Please enable pop-ups'));
+					return;
+				}
+			});
+
+			dialog.show();
+		} else {
+			frappe.msgprint(__('Select atleast 1 record for printing'));
+		}
+	}
+
+	delete(docnames, done = null) {
+		frappe
+			.call({
+				method: 'frappe.desk.reportview.delete_items',
+				freeze: true,
+				args: {
+					items: docnames,
+					doctype: this.doctype
+				}
+			})
+			.then((r) => {
+				let failed = r.message;
+				if (!failed) failed = [];
+
+				if (failed.length && !r._server_messages) {
+					frappe.throw(__('Cannot delete {0}', [failed.map(f => f.bold()).join(', ')]));
+				}
+				if (failed.length < docnames.length) {
+					frappe.utils.play_sound('delete');
+					if (done) done();
+				}
+			});
+	}
+
+	assign(docnames, done) {
+		if (docnames.length > 0) {
+			const dialog = new frappe.ui.form.AssignToDialog({
+				obj: this,
+				method: 'frappe.desk.form.assign_to.add_multiple',
+				doctype: this.doctype,
+				docname: docnames,
+				bulk_assign: true,
+				re_assign: true,
+				callback: done
+			});
+			dialog.clear();
+			dialog.show();
+		} else {
+			frappe.msgprint(__('Select records for assignment'));
+		}
+	}
+
+	submit_or_cancel(docnames, action='submit', done=null) {
+		action = action.toLowerCase();
+		frappe
+			.call({
+				method: 'frappe.desk.doctype.bulk_update.bulk_update.submit_cancel_or_update_docs',
+				args: {
+					doctype: this.doctype,
+					action: action,
+					docnames: docnames
+				},
+			})
+			.then((r) => {
+				let failed = r.message;
+				if (!failed) failed = [];
+
+				if (failed.length && !r._server_messages) {
+					frappe.throw(__('Cannot {0} {1}', [action, failed.map(f => f.bold()).join(', ')]));
+				}
+				if (failed.length < docnames.length) {
+					frappe.utils.play_sound(action);
+					if (done) done();
+				}
+			});
+	}
+
+	edit(docnames, field_mappings, done) {
+		let field_options = Object.keys(field_mappings).sort();
+		const dialog = new frappe.ui.Dialog({
+			title: __('Edit'),
+			fields: [
+				{
+					'fieldtype': 'Select', 'options': field_options,
+					'label': __('Field'), 'fieldname': 'field', 'reqd': 1,
+					'onchange': () => {
+						const new_df = Object.assign({},
+							field_mappings[dialog.get_value('field')]);
+						new_df.label = __('Value');
+						new_df.reqd = 1;
+						delete new_df.depends_on;
+						dialog.change_df('value', new_df);
+					}
+				},
+				{
+					'fieldtype': 'Data',
+					'label': __('Value'),
+					'fieldname': 'value',
+					'reqd': 1
+				}
+			],
+			primary_action: ({ value }) => {
+				const fieldname = field_mappings[dialog.get_value('field')].fieldname;
+
+				frappe.call({
+					method: 'frappe.desk.doctype.bulk_update.bulk_update.submit_cancel_or_update_docs',
+					args: {
+						doctype: this.doctype,
+						docnames: docnames,
+						action: 'update',
+						data: {
+							[fieldname]: value
+						}
+					}
+				}).then(r => {
+					let failed = r.message || [];
+
+					if (failed.length && !r._server_messages) {
+						frappe.throw(__('Cannot update {0}', [failed.map(f => f.bold ? f.bold() : f).join(', ')]));
+					}
+					done();
+					dialog.hide();
+				});
+			},
+			primary_action_label: __('Update')
+		});
+
+		dialog.refresh();
+		dialog.show();
+	}
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js
index fc2ea761df3f..147c368106e1 100644
--- a/frappe/public/js/frappe/list/list_view.js
+++ b/frappe/public/js/frappe/list/list_view.js
@@ -1,3 +1,5 @@
+import BulkOperations from "./bulk_operations";
+
 frappe.provide('frappe.views');
 
 frappe.views.ListView = class ListView extends frappe.views.BaseList {
@@ -55,10 +57,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 		}
 		// initialize with saved order by
 		this.order_by = this.view_user_settings.order_by || 'modified desc';
-		// buld menu items)
+
+		// build menu items
 		this.menu_items = this.menu_items.concat(this.get_menu_items());
 
 		this.freeze_on_refresh = true;
+		this.actions_menu_items = this.get_actions_menu_items();
 
 		this.patch_refresh_and_load_lib();
 	}
@@ -68,6 +72,45 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 		super.setup_page();
 	}
 
+	setup_page_head() {
+		super.setup_page_head();
+		this.set_primary_action();
+		this.set_menu_items();
+		this.set_actions_menu_items();
+	}
+
+	set_menu_items() {
+		const $secondary_action = this.page.set_secondary_action(
+			this.secondary_action.label,
+			this.secondary_action.action,
+			this.secondary_action.icon
+		);
+		if (!this.secondary_action.icon) {
+			$secondary_action.addClass('hidden-xs');
+		} else {
+			$secondary_action.addClass('visible-xs');
+		}
+
+		this.menu_items.map(item => {
+			if (item.condition && item.condition() === false) {
+				return;
+			}
+			const $item = this.page.add_menu_item(item.label, item.action, item.standard);
+			if (item.class) {
+				$item && $item.addClass(item.class);
+			}
+		});
+	}
+
+	set_actions_menu_items() {
+		this.actions_menu_items.map(item => {
+			const $item = this.page.add_actions_menu_item(item.label, item.action, item.standard);
+			if (item.class) {
+				$item.addClass(item.class);
+			}
+		});
+	}
+
 	set_fields() {
 		let fields = [].concat(
 			frappe.model.std_fields_list,
@@ -103,10 +146,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 		}, interval);
 	}
 
-	setup_page_head() {
-		super.setup_page_head();
-		this.set_primary_action();
-	}
 
 	set_primary_action() {
 		if (this.can_create) {
@@ -220,43 +259,23 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 		});
 	}
 
-	// freeze(toggle) {
-	// 	if (this.view_name !== 'List') return;
-
-	// 	this.$freeze.toggle(toggle);
-	// 	this.$result.toggle(!toggle);
-
-	// 	const columns = this.columns;
-	// 	if (toggle) {
-	// 		if (this.$freeze.find('.freeze-row').length > 0) return;
-
-	// 		const html = `
-	// 			${this.get_header_html()}
-	// 			${Array.from(new Array(10)).map(loading_row).join('')}
-	// 		`;
-	// 		this.$freeze.html(html);
-	// 	}
-
-	// 	function loading_row() {
-	// 		return `
-	// 			<div class="list-row freeze-row level">
-	// 				<div class="level-left">
-	// 					<div class="list-row-col list-subject"></div>
-	// 					${columns.slice(1).map(c => `<div class="list-row-col"></div>`).join('')}
-	// 				</div>
-	// 				<div class="level-right"></div>
-	// 			</div>
-	// 		`;
-	// 	}
-	// }
-
 	toggle_result_area() {
 		super.toggle_result_area();
-		this.toggle_delete_button(
+		this.toggle_actions_menu_button(
 			this.$result.find('.list-row-check:checked').length > 0
 		);
 	}
 
+	toggle_actions_menu_button(toggle) {
+		if (toggle) {
+			this.page.show_actions_menu();
+			this.page.clear_primary_action();
+		} else {
+			this.page.hide_actions_menu();
+			this.set_primary_action();
+		}
+	}
+
 	before_render() {
 		this.settings.before_render && this.settings.before_render();
 		frappe.model.user_settings.save(this.doctype, 'last_view', this.view_name);
@@ -734,56 +753,13 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 			this.$checkbox_actions.show();
 			this.$list_head_subject.hide();
 		}
-
-		if (this.can_delete) {
-			this.toggle_delete_button(this.$checks.length > 0);
-		}
-	}
-
-	toggle_delete_button(toggle) {
-		if (toggle) {
-			this.page.set_primary_action(__('Delete'),
-				() => this.delete_items(),
-				'octicon octicon-trashcan'
-			).addClass('btn-danger');
-		} else {
-			this.page.btn_primary.removeClass('btn-danger');
-			this.set_primary_action();
-		}
+		this.toggle_actions_menu_button(this.$checks.length > 0);
 	}
 
 	toggle_tags() {
 		this.$result.toggleClass('tags-shown');
 	}
 
-	delete_items() {
-		const docnames = this.get_checked_items(true);
-
-		frappe.confirm(__('Delete {0} items permanently?', [docnames.length]),
-			() => {
-				frappe.call({
-					method: 'frappe.desk.reportview.delete_items',
-					freeze: true,
-					args: {
-						items: docnames,
-						doctype: this.doctype
-					}
-				}).then((r) => {
-					let failed = r.message;
-					if (!failed) failed = [];
-
-					if (failed.length && !r._server_messages) {
-						frappe.throw(__('Cannot delete {0}', [failed.map(f => f.bold()).join(', ')]));
-					}
-					if (failed.length < docnames.length) {
-						frappe.utils.play_sound('delete');
-						this.refresh(true);
-					}
-				});
-			}
-		);
-	}
-
 	get_checked_items(only_docnames) {
 		const docnames = Array.from(this.$checks || [])
 			.map(check => $(check).data().name);
@@ -816,6 +792,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 				standard: true
 			});
 		}
+
 		if (frappe.model.can_set_user_permissions(doctype)) {
 			items.push({
 				label: __('User Permissions'),
@@ -825,6 +802,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 				standard: true
 			});
 		}
+
 		if (frappe.user_roles.includes('System Manager')) {
 			items.push({
 				label: __('Role Permissions Manager'),
@@ -849,124 +827,148 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 			standard: true
 		});
 
+		// add to desktop
+		items.push({
+			label: __('Add to Desktop'),
+			action: () => frappe.add_to_desktop(doctype, doctype),
+			standard: true
+		});
+
+		if (frappe.user.has_role('System Manager') && frappe.boot.developer_mode === 1) {
+			// edit doctype
+			items.push({
+				label: __('Edit DocType'),
+				action: () => frappe.set_route('Form', 'DocType', doctype),
+				standard: true
+			});
+		}
+
+		return items;
+	}
+
+	get_actions_menu_items() {
+		const doctype = this.doctype;
+		const actions_menu_items = [];
+		const bulk_operations = new BulkOperations({ 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;
+		};
+
+		const has_editable_fields = () => {
+			return frappe.meta.get_docfields(doctype).some(field_doc => is_field_editable(field_doc));
+		};
+
+		const has_submit_permission = () => {
+			return frappe.perm.has_perm('Sales Order', 0, 'submit');
+		};
+
 		// utility
 		const bulk_assignment = () => {
 			return {
 				label: __('Assign To'),
+				action: () => bulk_operations.assign(this.get_checked_items(true), this.refresh),
+				standard: true
+			};
+		};
+
+		const bulk_printing = () => {
+			return {
+				label: __('Print'),
+				action: () => bulk_operations.print(this.get_checked_items()),
+				standard: true
+			};
+		};
+
+		const bulk_delete = () => {
+			return {
+				label: __('Delete'),
+				action: () => {
+					const docnames = this.get_checked_items(true);
+					frappe.confirm(__('Delete {0} items permanently?', [docnames.length]),
+						() => bulk_operations.delete(docnames, this.refresh));
+				},
+				standard: true,
+			};
+		};
+
+		const bulk_cancel = () => {
+			return {
+				label: __('Cancel'),
 				action: () => {
 					const docnames = this.get_checked_items(true);
 					if (docnames.length > 0) {
-						const dialog = new frappe.ui.form.AssignToDialog({
-							obj: this,
-							method: 'frappe.desk.form.assign_to.add_multiple',
-							doctype: this.doctype,
-							docname: docnames,
-							bulk_assign: true,
-							re_assign: true,
-							callback: () => this.refresh(true)
-						});
-						dialog.clear();
-						dialog.show();
-					} else {
-						frappe.msgprint(__('Select records for assignment'));
+						frappe.confirm(__('Cancel {0} documents?', [docnames.length]),
+							() => bulk_operations.submit_or_cancel(docnames, 'cancel', this.refresh));
 					}
 				},
 				standard: true
 			};
 		};
 
-		const bulk_printing = () => {
-			const print_settings = frappe.model.get_doc(':Print Settings', 'Print Settings');
-			const allow_print_for_draft = cint(print_settings.allow_print_for_draft);
-			const is_submittable = frappe.model.is_submittable(this.doctype);
-			const allow_print_for_cancelled = cint(print_settings.allow_print_for_cancelled);
-
+		const bulk_submit = () => {
 			return {
-				label: __('Print'),
+				label: __('Submit'),
 				action: () => {
-					const items = this.get_checked_items();
+					const docnames = this.get_checked_items(true);
+					if (docnames.length > 0) {
+						frappe.confirm(__('Submit {0} documents?', [docnames.length]),
+							() => bulk_operations.submit_or_cancel(docnames, 'submit', this.refresh));
+					}
+				},
+				standard: true
+			};
+		};
 
-					const valid_docs = items.filter(doc => {
-						return !is_submittable || doc.docstatus === 1 ||
-							(allow_print_for_cancelled && doc.docstatus == 2) ||
-							(allow_print_for_draft && doc.docstatus == 0) ||
-							frappe.user.has_role('Administrator');
-					}).map(doc => doc.name);
+		const bulk_edit = () => {
+			return {
+				label: __('Edit'),
+				action: () => {
+					let field_mappings = {};
 
-					var invalid_docs = items.filter(doc => !valid_docs.includes(doc.name));
+					frappe.meta.get_docfields(doctype).forEach(field_doc => {
+						if (is_field_editable(field_doc)) {
+							field_mappings[field_doc.label] = Object.assign({}, field_doc);
+						}
+					});
 
-					if (invalid_docs.length > 0) {
-						frappe.msgprint(__('You selected Draft or Cancelled documents'));
-						return;
-					}
+					const docnames = this.get_checked_items(true);
 
-					if (valid_docs.length > 0) {
-						const dialog = new frappe.ui.Dialog({
-							title: __('Print Documents'),
-							fields: [{
-								'fieldtype': 'Check',
-								'label': __('With Letterhead'),
-								'fieldname': 'with_letterhead'
-							},
-							{
-								'fieldtype': 'Select',
-								'label': __('Print Format'),
-								'fieldname': 'print_sel',
-								options: frappe.meta.get_print_formats(this.doctype)
-							}]
-						});
-
-						dialog.set_primary_action(__('Print'), args => {
-							if (!args) return;
-							const default_print_format = frappe.get_meta(this.doctype).default_print_format;
-							const with_letterhead = args.with_letterhead ? 1 : 0;
-							const print_format = args.print_sel ? args.print_sel : default_print_format;
-							const json_string = JSON.stringify(valid_docs);
-
-							const w = window.open('/api/method/frappe.utils.print_format.download_multi_pdf?' +
-								'doctype=' + encodeURIComponent(this.doctype) +
-								'&name=' + encodeURIComponent(json_string) +
-								'&format=' + encodeURIComponent(print_format) +
-								'&no_letterhead=' + (with_letterhead ? '0' : '1'));
-							if (!w) {
-								frappe.msgprint(__('Please enable pop-ups'));
-								return;
-							}
-						});
-
-						dialog.show();
-					} else {
-						frappe.msgprint(__('Select atleast 1 record for printing'));
-					}
+					bulk_operations.edit(docnames, field_mappings, this.refresh);
 				},
 				standard: true
 			};
 		};
-
 		// bulk assignment
-		items.push(bulk_assignment());
+		actions_menu_items.push(bulk_assignment());
 
+		// bulk printing
 		if (frappe.model.can_print(doctype)) {
-			items.push(bulk_printing());
+			actions_menu_items.push(bulk_printing());
 		}
 
-		// add to desktop
-		items.push({
-			label: __('Add to Desktop'),
-			action: () => frappe.add_to_desktop(doctype, doctype),
-			standard: true
-		});
+		// Bulk cancel
+		if (frappe.model.can_cancel(doctype)) {
+			actions_menu_items.push(bulk_cancel());
+		}
 
-		if (frappe.user.has_role('System Manager') && frappe.boot.developer_mode === 1) {
-			// edit doctype
-			items.push({
-				label: __('Edit DocType'),
-				action: () => frappe.set_route('Form', 'DocType', doctype),
-				standard: true
-			});
+		// Bulk submit
+		if (frappe.model.is_submittable(doctype) && has_submit_permission()) {
+			actions_menu_items.push(bulk_submit());
 		}
 
-		return items;
+		// bulk delete
+		if (frappe.model.can_delete(doctype)) {
+			actions_menu_items.push(bulk_delete());
+		}
+
+		// bulk edit
+		if (has_editable_fields()) {
+			actions_menu_items.push(bulk_edit());
+		}
+
+		return actions_menu_items;
 	}
 
 	set_filters_from_route_options() {
@@ -1021,6 +1023,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
 	}
 };
 
-$(document).on('save', function (event, doc) {
+$(document).on('save', (event, doc) => {
 	frappe.views.ListView.trigger_list_update(doc);
 });
\ No newline at end of file
diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js
index 39c199d63466..b06286b0283f 100644
--- a/frappe/public/js/frappe/socketio_client.js
+++ b/frappe/public/js/frappe/socketio_client.js
@@ -43,7 +43,7 @@ frappe.socketio = {
 				if(data.percent==100) {
 					frappe.hide_progress();
 				} else {
-					frappe.show_progress(data.title || __("Progress"), data.percent, 100);
+					frappe.show_progress(data.title || __("Progress"), data.percent, 100, data.description);
 				}
 			}
 		});
diff --git a/frappe/public/js/frappe/ui/messages.js b/frappe/public/js/frappe/ui/messages.js
index f786d76d7ccc..44c526e02bff 100644
--- a/frappe/public/js/frappe/ui/messages.js
+++ b/frappe/public/js/frappe/ui/messages.js
@@ -241,11 +241,12 @@ frappe.show_progress = function(title, count, total=100, description) {
 		var dialog = new frappe.ui.Dialog({
 			title: title,
 		});
-		dialog.progress = $(`<div class="progress">
-			<div class="progress-bar"></div>
+		dialog.progress = $(`<div>
+			<div class="progress">
+				<div class="progress-bar"></div>
+			</div>
 			<p class="description text-muted small"></p>
-		</div>`)
-			.appendTo(dialog.body);
+		</div`).appendTo(dialog.body);
 		dialog.progress_bar = dialog.progress.css({"margin-top": "10px"})
 			.find(".progress-bar");
 		dialog.$wrapper.removeClass("fade");
diff --git a/frappe/public/js/frappe/ui/page.html b/frappe/public/js/frappe/ui/page.html
index cb84e2c619f6..e4c54f044190 100644
--- a/frappe/public/js/frappe/ui/page.html
+++ b/frappe/public/js/frappe/ui/page.html
@@ -4,10 +4,10 @@
 			<div class="col-md-7 col-sm-8 col-xs-6 page-title">
 				<!-- title -->
 				<h1>
-						<div class="title-image hide hidden-md hidden-lg">
-						</div>
-						<div class="ellipsis title-text"></div>
-						<span class="indicator hide"></span>
+					<div class="title-image hide hidden-md hidden-lg">
+					</div>
+					<div class="ellipsis title-text"></div>
+					<span class="indicator hide"></span>
 				</h1>
 			</div>
 			<div class="text-right col-md-5 col-sm-4 col-xs-6 page-actions">
@@ -18,27 +18,28 @@ <h6 class="ellipsis sub-heading hide text-muted"></h6>
 
 				<!-- buttons -->
 				<div class="btn-group menu-btn-group hide">
-						<button type="button" class="btn btn-default btn-sm dropdown-toggle"
-								data-toggle="dropdown" aria-expanded="false">
+					<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
 						<span class="hidden-xs">
 							<span class="menu-btn-group-label">{%= __("Menu") %}</span>
-							<span class="caret"></span></span>
-	<span class="visible-xs"><i class="octicon octicon-triangle-down"></i></span>
-						</button>
-						<ul class="dropdown-menu" role="menu">
-						</ul>
+							<span class="caret"></span>
+						</span>
+						<span class="visible-xs">
+							<i class="octicon octicon-triangle-down"></i>
+						</span>
+					</button>
+					<ul class="dropdown-menu" role="menu"></ul>
 				</div>
 				<button class="btn btn-secondary btn-default btn-sm hide"></button>
 				<div class="btn-group actions-btn-group hide">
-						<button type="button" class="btn btn-primary btn-sm dropdown-toggle"
-								data-toggle="dropdown" aria-expanded="false">
-								<span class="hidden-xs">
-									{%= __("Actions") %} <span class="caret"></span>
-								</span>
-								<span class="visible-xs octicon octicon-check"></span>
-						</button>
-						<ul class="dropdown-menu" role="menu">
-						</ul>
+					<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
+						<span class="hidden-xs">
+							{%= __("Actions") %}
+							<span class="caret"></span>
+						</span>
+						<span class="visible-xs octicon octicon-check"></span>
+					</button>
+					<ul class="dropdown-menu" role="menu">
+					</ul>
 				</div>
 				<button class="btn btn-primary btn-sm hide primary-action"></button>
 			</div>
@@ -56,4 +57,4 @@ <h6 class="ellipsis sub-heading hide text-muted"></h6>
 			<div class="clearfix"></div>
 		</div>
 	</div>
-</div>
+</div>
\ No newline at end of file
diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js
index e5714ea933b4..67182dceae0f 100644
--- a/frappe/public/js/frappe/ui/page.js
+++ b/frappe/public/js/frappe/ui/page.js
@@ -235,16 +235,30 @@ frappe.ui.Page = Class.extend({
 		this.icon_group.addClass("hide");
 	},
 
-	//--- Actions (workflow) --//
+	//--- Actions Menu--//
+	
+	show_actions_menu: function() {
+		this.actions_btn_group.removeClass("hide");
+	},
+
+	hide_actions_menu: function() {
+		this.actions_btn_group.addClass("hide");
+	},
 
+	
 	add_action_item: function(label, click, standard) {
 		return this.add_dropdown_item(label, click, standard, this.actions);
 	},
 
+	add_actions_menu_item: function(label, click, standard) {
+		return this.add_dropdown_item(label, click, standard, this.actions);
+	},
+
 	clear_actions_menu: function() {
 		this.clear_btn_group(this.actions);
 	},
 
+
 	//-- Generic --//
 
 	/*
diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py
index d807c018f126..60a512a9fef6 100644
--- a/frappe/utils/__init__.py
+++ b/frappe/utils/__init__.py
@@ -548,3 +548,11 @@ def get_site_info():
 
 	# dumps -> loads to prevent datatype conflicts
 	return json.loads(frappe.as_json(site_info))
+
+def parse_json(val):
+	"""
+	Parses json if string else return
+	"""
+	if isinstance(val, string_types):
+		return json.loads(val)
+	return val


Code review comments:
diff_hunk: @@ -119,19 +119,27 @@ frappe.ui.form.Layout = Class.extend({
 		});
 	},
 
-	make_field: function(df, colspan, render = false) {
+	change_df: function(fieldname, df, render) {
path: frappe/public/js/frappe/form/layout.js
text: @user1:
`replace_field`