* 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 datesversion-14
@@ -3,7 +3,7 @@ | |||||
# Search | # Search | ||||
from __future__ import unicode_literals | from __future__ import unicode_literals | ||||
import frappe | |||||
import frappe, json | |||||
from frappe.utils import cstr, unique | from frappe.utils import cstr, unique | ||||
# this is called by the Link Field | # 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 | # this is called by the search box | ||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def search_widget(doctype, txt, query=None, searchfield=None, start=0, | 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): | if isinstance(filters, basestring): | ||||
import json | import json | ||||
filters = json.loads(filters) | 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"}): | if meta.get("fields", {"fieldname":"disabled", "fieldtype":"Check"}): | ||||
filters.append([doctype, "disabled", "!=", 1]) | 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") | 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. | # 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))) | _txt=frappe.db.escape((txt or "").replace("%", "")), doctype=frappe.db.escape(doctype))) | ||||
# In order_by, `idx` gets second priority, because it stores link count | # In order_by, `idx` gets second priority, because it stores link count | ||||
from frappe.model.db_query import get_order_by | from frappe.model.db_query import get_order_by | ||||
order_by_based_on_meta = get_order_by(doctype, meta) | 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) | order_by = "if(_relevance, _relevance, 99999), idx desc, {0}".format(order_by_based_on_meta) | ||||
values = frappe.get_list(doctype, | values = frappe.get_list(doctype, | ||||
filters=filters, fields=fields, | |||||
filters=filters, fields=formatted_fields, | |||||
or_filters = or_filters, limit_start = start, | or_filters = or_filters, limit_start = start, | ||||
limit_page_length=page_len, | limit_page_length=page_len, | ||||
order_by=order_by, | order_by=order_by, | ||||
@@ -97,6 +101,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, | |||||
as_list=not as_dict) | as_list=not as_dict) | ||||
# remove _relevance from results | # remove _relevance from results | ||||
frappe.response["fields"] = fields | |||||
frappe.response["values"] = [r[:-1] for r in values] | frappe.response["values"] = [r[:-1] for r in values] | ||||
def get_std_fields_list(meta, key): | def get_std_fields_list(meta, key): | ||||
@@ -107,7 +112,7 @@ def get_std_fields_list(meta, key): | |||||
if not key in sflist: | if not key in sflist: | ||||
sflist = sflist + [key] | sflist = sflist + [key] | ||||
return ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in sflist] | |||||
return sflist | |||||
def build_for_autosuggest(res): | def build_for_autosuggest(res): | ||||
results = [] | results = [] | ||||
@@ -25,16 +25,21 @@ def make_mapped_doc(method, source_name, selected_children=None): | |||||
return method(source_name) | 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, | def get_mapped_doc(from_doctype, from_docname, table_maps, target_doc=None, | ||||
postprocess=None, ignore_permissions=False, ignore_child_tables=False): | 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 | # main | ||||
if not target_doc: | if not target_doc: | ||||
target_doc = frappe.new_doc(table_maps[from_doctype]["doctype"]) | 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"): | if not ignore_permissions and not target_doc.has_permission("create"): | ||||
target_doc.raise_no_permission_to("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]) | map_doc(source_doc, target_doc, table_maps[source_doc.doctype]) | ||||
row_exists_for_parentfield = {} | row_exists_for_parentfield = {} | ||||
@@ -30,6 +30,7 @@ | |||||
"public/js/frappe/ui/field_group.js", | "public/js/frappe/ui/field_group.js", | ||||
"public/js/frappe/form/control.js", | "public/js/frappe/form/control.js", | ||||
"public/js/frappe/form/link_selector.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/dialog.js" | ||||
], | ], | ||||
"css/desk.min.css": [ | "css/desk.min.css": [ | ||||
@@ -104,6 +105,7 @@ | |||||
"public/js/frappe/ui/field_group.js", | "public/js/frappe/ui/field_group.js", | ||||
"public/js/frappe/form/control.js", | "public/js/frappe/form/control.js", | ||||
"public/js/frappe/form/link_selector.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/dialog.js", | ||||
"public/js/frappe/ui/app_icon.js", | "public/js/frappe/ui/app_icon.js", | ||||
@@ -978,6 +978,13 @@ input[type="checkbox"]:checked:before { | |||||
font-size: 13px; | font-size: 13px; | ||||
color: #3b99fc; | color: #3b99fc; | ||||
} | } | ||||
.multiselect-empty-state { | |||||
min-height: 300px; | |||||
display: flex; | |||||
align-items: center; | |||||
justify-content: center; | |||||
height: 100%; | |||||
} | |||||
@-moz-document url-prefix() { | @-moz-document url-prefix() { | ||||
input[type="checkbox"] { | input[type="checkbox"] { | ||||
visibility: visible; | visibility: visible; | ||||
@@ -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(`<div class="results" | |||||
style="border: 1px solid #d1d8dd; border-radius: 3px; height: 300px; overflow: auto;"></div>`); | |||||
this.$results = this.$wrapper.find('.results'); | |||||
this.$make_new_btn = this.dialog.fields_dict.make_new.$wrapper; | |||||
this.$placeholder = $(`<div class="multiselect-empty-state"> | |||||
<span class="text-center" style="margin-top: -40px;"> | |||||
<i class="fa fa-2x fa-tags text-extra-muted"></i> | |||||
<p class="text-extra-muted">No ${this.doctype} found</p> | |||||
<button class="btn btn-default btn-xs text-muted" data-fieldtype="Button" | |||||
data-fieldname="make_new" placeholder="" value="">Make a new ${this.doctype}</button> | |||||
</span> | |||||
</div>`); | |||||
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 += `<div class="list-item__content ellipsis"> | |||||
${ | |||||
head ? __(frappe.model.unscrub(column)) | |||||
: (column !== "name" ? __(result[column]) | |||||
: `<a href="${"#Form/"+ me.doctype + "/" + result[column]}" class="list-id"> | |||||
${__(result[column])}</a>`) | |||||
} | |||||
</div>`; | |||||
}) | |||||
let $row = $(`<div class="list-item"> | |||||
<div class="list-item__content ellipsis" style="flex: 0 0 10px;"> | |||||
<input type="checkbox" class="list-row-check" ${result.checked ? 'checked' : ''}/> | |||||
</div> | |||||
${contents} | |||||
</div>`); | |||||
head ? $row.addClass('list-item--head') | |||||
: $row = $(`<div class="list-item-container" data-item-name="${result.name}"></div>`).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); | |||||
} | |||||
} | |||||
}); | |||||
}, | |||||
}); |
@@ -187,6 +187,11 @@ $.extend(frappe.model, { | |||||
return txt.replace(/ /g, "_").toLowerCase(); | 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) { | can_create: function(doctype) { | ||||
return frappe.boot.user.can_create.indexOf(doctype)!==-1; | return frappe.boot.user.can_create.indexOf(doctype)!==-1; | ||||
}, | }, | ||||
@@ -67,7 +67,6 @@ frappe.ui.FieldGroup = frappe.ui.form.Layout.extend({ | |||||
var f = this.fields_dict[key]; | var f = this.fields_dict[key]; | ||||
if(f.get_parsed_value) { | if(f.get_parsed_value) { | ||||
var v = f.get_parsed_value(); | var v = f.get_parsed_value(); | ||||
if(f.df.reqd && is_null(v)) | if(f.df.reqd && is_null(v)) | ||||
errors.push(__(f.df.label)); | errors.push(__(f.df.label)); | ||||
@@ -575,10 +575,6 @@ frappe.search.utils = { | |||||
return rendered; | 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();}); | |||||
}, | |||||
} | } |
@@ -431,7 +431,7 @@ textarea.form-control { | |||||
flex: 0 0 36px; | flex: 0 0 36px; | ||||
order: -1; | order: -1; | ||||
justify-content: flex-end; | justify-content: flex-end; | ||||
input[type="checkbox"] { | input[type="checkbox"] { | ||||
margin-right: 0; | 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() { | @-moz-document url-prefix() { | ||||
input[type="checkbox"] { | input[type="checkbox"] { | ||||
visibility: visible; | visibility: visible; | ||||