From b23aa1446b0841ca670921b2199b37f4545cbeab Mon Sep 17 00:00:00 2001 From: Prateeksha Singh Date: Mon, 15 May 2017 11:29:41 +0530 Subject: [PATCH] Multiselect dialog for getting items (#3255) * First approach by making a control * Implement multi select for child tables * Basic UI and items fetch in place * Multiselect with checkboxes * Functional modal with filters and new_doc * Map filter fields to target_doc * pass json arrays instead of strings * Get items from quotation (in SO) working * [minor] fix link route in list * [minor] cleanup * Add date, select first by default * map_docs test, default date field * [minor][fix] make new button bug * [minor] move map_docs to erpnext * [minor] format dates --- frappe/desk/search.py | 19 +- frappe/model/mapper.py | 23 +- frappe/public/build.json | 2 + frappe/public/css/desk.css | 7 + .../js/frappe/form/multi_select_dialog.js | 208 ++++++++++++++++++ frappe/public/js/frappe/model/model.js | 5 + frappe/public/js/frappe/ui/field_group.js | 1 - .../js/frappe/ui/toolbar/search_utils.js | 6 +- frappe/public/less/desk.less | 13 +- 9 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 frappe/public/js/frappe/form/multi_select_dialog.js diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 0bb6ac0a0b..734da033a3 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -3,7 +3,7 @@ # Search from __future__ import unicode_literals -import frappe +import frappe, json from frappe.utils import cstr, unique # this is called by the Link Field @@ -16,7 +16,7 @@ def search_link(doctype, txt, query=None, filters=None, page_len=20, searchfield # this is called by the search box @frappe.whitelist() def search_widget(doctype, txt, query=None, searchfield=None, start=0, - page_len=10, filters=None, as_dict=False): + page_len=10, filters=None, filter_fields=None, as_dict=False): if isinstance(filters, basestring): import json filters = json.loads(filters) @@ -76,20 +76,24 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, if meta.get("fields", {"fieldname":"disabled", "fieldtype":"Check"}): filters.append([doctype, "disabled", "!=", 1]) + # format a list of fields combining search fields and filter fields fields = get_std_fields_list(meta, searchfield or "name") + if filter_fields: + fields = list(set(fields + json.loads(filter_fields))) + formatted_fields = ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in fields] # find relevance as location of search term from the beginning of string `name`. used for sorting results. - fields.append("""locate("{_txt}", `tab{doctype}`.`name`) as `_relevance`""".format( + formatted_fields.append("""locate("{_txt}", `tab{doctype}`.`name`) as `_relevance`""".format( _txt=frappe.db.escape((txt or "").replace("%", "")), doctype=frappe.db.escape(doctype))) - + # In order_by, `idx` gets second priority, because it stores link count from frappe.model.db_query import get_order_by order_by_based_on_meta = get_order_by(doctype, meta) order_by = "if(_relevance, _relevance, 99999), idx desc, {0}".format(order_by_based_on_meta) - + values = frappe.get_list(doctype, - filters=filters, fields=fields, + filters=filters, fields=formatted_fields, or_filters = or_filters, limit_start = start, limit_page_length=page_len, order_by=order_by, @@ -97,6 +101,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, as_list=not as_dict) # remove _relevance from results + frappe.response["fields"] = fields frappe.response["values"] = [r[:-1] for r in values] def get_std_fields_list(meta, key): @@ -107,7 +112,7 @@ def get_std_fields_list(meta, key): if not key in sflist: sflist = sflist + [key] - return ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in sflist] + return sflist def build_for_autosuggest(res): results = [] diff --git a/frappe/model/mapper.py b/frappe/model/mapper.py index d8384eee09..72a9a77e3c 100644 --- a/frappe/model/mapper.py +++ b/frappe/model/mapper.py @@ -25,16 +25,21 @@ def make_mapped_doc(method, source_name, selected_children=None): return method(source_name) +@frappe.whitelist() +def map_docs(method, source_names, target_doc): + '''Returns the mapped document calling the given mapper method + with each of the given source docs on the target doc''' + method = frappe.get_attr(method) + if method not in frappe.whitelisted: + raise frappe.PermissionError + + for src in json.loads(source_names): + target_doc = method(src, target_doc) + return target_doc def get_mapped_doc(from_doctype, from_docname, table_maps, target_doc=None, postprocess=None, ignore_permissions=False, ignore_child_tables=False): - source_doc = frappe.get_doc(from_doctype, from_docname) - - if not ignore_permissions: - if not source_doc.has_permission("read"): - source_doc.raise_no_permission_to("read") - # main if not target_doc: target_doc = frappe.new_doc(table_maps[from_doctype]["doctype"]) @@ -44,6 +49,12 @@ def get_mapped_doc(from_doctype, from_docname, table_maps, target_doc=None, if not ignore_permissions and not target_doc.has_permission("create"): target_doc.raise_no_permission_to("create") + source_doc = frappe.get_doc(from_doctype, from_docname) + + if not ignore_permissions: + if not source_doc.has_permission("read"): + source_doc.raise_no_permission_to("read") + map_doc(source_doc, target_doc, table_maps[source_doc.doctype]) row_exists_for_parentfield = {} diff --git a/frappe/public/build.json b/frappe/public/build.json index 11bd5ea34e..598b964574 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -30,6 +30,7 @@ "public/js/frappe/ui/field_group.js", "public/js/frappe/form/control.js", "public/js/frappe/form/link_selector.js", + "public/js/frappe/form/multi_select_dialog.js", "public/js/frappe/ui/dialog.js" ], "css/desk.min.css": [ @@ -104,6 +105,7 @@ "public/js/frappe/ui/field_group.js", "public/js/frappe/form/control.js", "public/js/frappe/form/link_selector.js", + "public/js/frappe/form/multi_select_dialog.js", "public/js/frappe/ui/dialog.js", "public/js/frappe/ui/app_icon.js", diff --git a/frappe/public/css/desk.css b/frappe/public/css/desk.css index 76d352a6b9..302fe38647 100644 --- a/frappe/public/css/desk.css +++ b/frappe/public/css/desk.css @@ -978,6 +978,13 @@ input[type="checkbox"]:checked:before { font-size: 13px; color: #3b99fc; } +.multiselect-empty-state { + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} @-moz-document url-prefix() { input[type="checkbox"] { visibility: visible; diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js new file mode 100644 index 0000000000..dd4251bd1f --- /dev/null +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -0,0 +1,208 @@ +// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// MIT License. See license.txt + +frappe.ui.form.MultiSelectDialog = Class.extend({ + init: function(opts) { + /* Options: doctype, target, setters, get_query, action */ + $.extend(this, opts); + + var me = this; + if(this.doctype!="[Select]") { + frappe.model.with_doctype(this.doctype, function(r) { + me.make(); + }); + } else { + this.make(); + } + }, + make: function() { + let me = this; + + let fields = []; + let count = 0; + if(!this.date_field) { + this.date_field = "transaction_date"; + } + Object.keys(this.setters).forEach(function(setter) { + fields.push({ + fieldtype: me.target.fields_dict[setter].df.fieldtype, + label: me.target.fields_dict[setter].df.label, + fieldname: setter, + options: me.target.fields_dict[setter].df.options, + default: me.setters[setter] + }); + if (count++ < Object.keys(me.setters).length - 1) { + fields.push({fieldtype: "Column Break"}); + } + }); + + fields = fields.concat([ + { fieldtype: "Section Break" }, + { fieldtype: "HTML", fieldname: "results_area" }, + { fieldtype: "Button", fieldname: "make_new", label: __("Make a new " + me.doctype) } + ]); + + let doctype_plural = !this.doctype.endsWith('y') ? this.doctype + 's' + : this.doctype.slice(0, -1) + 'ies'; + + this.dialog = new frappe.ui.Dialog({ + title: __("Select {0}", [(this.doctype=='[Select]') ? __("value") : __(doctype_plural)]), + fields: fields, + primary_action_label: __("Get Items"), + primary_action: function() { + me.action(me.get_checked_values(), me.args); + } + }); + + this.$parent = $(this.dialog.body); + this.$wrapper = this.dialog.fields_dict.results_area.$wrapper.append(`
`); + this.$results = this.$wrapper.find('.results'); + this.$make_new_btn = this.dialog.fields_dict.make_new.$wrapper; + + this.$placeholder = $(`
+ + +

No ${this.doctype} found

+ +
+
`); + + this.args = {}; + + this.bind_events(); + this.get_results(); + this.dialog.show(); + }, + + bind_events: function() { + let me = this; + this.$results.on('click', '.list-item-container', function (e) { + if (!$(e.target).is(':checkbox') && !$(e.target).is('a')) { + $(this).find(':checkbox').trigger('click'); + } + }); + this.$results.on('click', '.list-item--head :checkbox', (e) => { + this.$results.find('.list-item-container .list-row-check') + .prop("checked", ($(e.target).is(':checked'))); + }); + + this.$parent.find('.input-with-feedback').on('change', (e) => { + this.get_results(); + }); + this.$parent.on('click', '.btn[data-fieldname="make_new"]', (e) => { + frappe.route_options = {}; + Object.keys(this.setters).forEach(function(setter) { + frappe.route_options[setter] = me.dialog.fields_dict[setter].get_value() || undefined; + }); + frappe.new_doc(this.doctype, true); + }); + }, + + get_checked_values: function() { + return this.$results.find('.list-item-container').map(function() { + if ($(this).find('.list-row-check:checkbox:checked').length > 0 ) { + return $(this).attr('data-item-name'); + } + }).get(); + }, + + make_list_row: function(result={}) { + var me = this; + // Make a head row by default (if result not passed) + let head = Object.keys(result).length === 0; + + let contents = ``; + let columns = (["name"].concat(Object.keys(this.setters))).concat("Date"); + columns.forEach(function(column) { + contents += `
+ ${ + head ? __(frappe.model.unscrub(column)) + + : (column !== "name" ? __(result[column]) + : ` + ${__(result[column])}`) + } +
`; + }) + + let $row = $(`
+
+ +
+ ${contents} +
`); + + head ? $row.addClass('list-item--head') + : $row = $(`
`).append($row); + return $row; + }, + + render_result_list: function(results) { + var me = this; + this.$results.empty(); + if(results.length === 0) { + this.$make_new_btn.addClass('hide'); + this.$results.append(me.$placeholder); + return; + } + this.$make_new_btn.removeClass('hide'); + + this.$results.append(this.make_list_row()); + results.forEach((result) => { + me.$results.append(me.make_list_row(result)); + }) + }, + + get_results: function() { + let me = this; + + let filters = this.get_query().filters; + Object.keys(this.setters).forEach(function(setter) { + filters[setter] = me.dialog.fields_dict[setter].get_value() || undefined; + me.args[setter] = filters[setter]; + }); + + let args = { + doctype: me.doctype, + txt: '', + filters: filters, + filter_fields: Object.keys(me.setters).concat([me.date_field]) + } + frappe.call({ + type: "GET", + method:'frappe.desk.search.search_widget', + no_spinner: true, + args: args, + callback: function(r) { + if(r.values) { + let results = []; + r.values.forEach(function(value_list) { + let result = {}; + value_list.forEach(function(value, index){ + if(r.fields[index] === me.date_field) { + result["Date"] = value; + } else { + result[r.fields[index]] = value; + } + }); + result.checked = 0; + result.parsed_date = Date.parse(result["Date"]); + results.push(result); + }); + let min_date = Math.min.apply( Math, results.map((result) => result.parsed_date) ); + + results.map( (result) => { + result["Date"] = frappe.format(result["Date"], {"fieldtype":"Date"}); + if(result.parsed_date === min_date) { + result.checked = 1; + } + }) + me.render_result_list(results); + } + } + }); + }, + +}); \ No newline at end of file diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index 543a47db1f..10a882db67 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -187,6 +187,11 @@ $.extend(frappe.model, { return txt.replace(/ /g, "_").toLowerCase(); }, + unscrub: function(txt) { + return __(txt || '').replace(/-|_/g, " ").replace(/\w*/g, + function(keywords){return keywords.charAt(0).toUpperCase() + keywords.substr(1).toLowerCase();}); + }, + can_create: function(doctype) { return frappe.boot.user.can_create.indexOf(doctype)!==-1; }, diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js index e9d9c8e248..e40b6d87f5 100644 --- a/frappe/public/js/frappe/ui/field_group.js +++ b/frappe/public/js/frappe/ui/field_group.js @@ -67,7 +67,6 @@ frappe.ui.FieldGroup = frappe.ui.form.Layout.extend({ var f = this.fields_dict[key]; if(f.get_parsed_value) { var v = f.get_parsed_value(); - if(f.df.reqd && is_null(v)) errors.push(__(f.df.label)); diff --git a/frappe/public/js/frappe/ui/toolbar/search_utils.js b/frappe/public/js/frappe/ui/toolbar/search_utils.js index 37bca09203..a771ce9122 100644 --- a/frappe/public/js/frappe/ui/toolbar/search_utils.js +++ b/frappe/public/js/frappe/ui/toolbar/search_utils.js @@ -575,10 +575,6 @@ frappe.search.utils = { return rendered; } - }, + } - unscrub_and_titlecase: function(str) { - return __(str || '').replace(/-|_/g, " ").replace(/\w*/g, - function(keywords){return keywords.charAt(0).toUpperCase() + keywords.substr(1).toLowerCase();}); - }, } \ No newline at end of file diff --git a/frappe/public/less/desk.less b/frappe/public/less/desk.less index 2a9aeb4e64..6a0ad2bb77 100644 --- a/frappe/public/less/desk.less +++ b/frappe/public/less/desk.less @@ -431,7 +431,7 @@ textarea.form-control { flex: 0 0 36px; order: -1; justify-content: flex-end; - + input[type="checkbox"] { margin-right: 0; } @@ -894,8 +894,17 @@ input[type="checkbox"] { } } +// Will not be required after commonifying lists with empty state +.multiselect-empty-state{ + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} -// mozilla doesn't support pseudo elements on checkbox +// mozilla doesn't support +// pseudo elements on checkbox @-moz-document url-prefix() { input[type="checkbox"] { visibility: visible;