* 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 | |||
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 = [] | |||
@@ -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 = {} | |||
@@ -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", | |||
@@ -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; | |||
@@ -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(); | |||
}, | |||
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; | |||
}, | |||
@@ -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)); | |||
@@ -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();}); | |||
}, | |||
} |
@@ -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; | |||