* [sales goal] in company; graph, notifs * cleanup notifications.js, summary and specific val in graph * Add line graph, add goal data methods in goal.py * [tests] targets in notification config * [minor] type of graph as argument in parent * Update graph docs * remove company dependent test for notification * [fix] test * look for monthly history in cache * check for cached graph data in fieldversion-14
@@ -3,18 +3,21 @@ | |||
"doctype": "Event", | |||
"subject":"_Test Event 1", | |||
"starts_on": "2014-01-01", | |||
"event_type": "Public" | |||
"event_type": "Public", | |||
"creation": "2014-01-01" | |||
}, | |||
{ | |||
"doctype": "Event", | |||
"starts_on": "2014-01-01", | |||
"subject":"_Test Event 2", | |||
"event_type": "Private" | |||
"starts_on": "2014-01-01", | |||
"event_type": "Private", | |||
"creation": "2014-01-01" | |||
}, | |||
{ | |||
"doctype": "Event", | |||
"starts_on": "2014-01-01", | |||
"subject": "_Test Event 3", | |||
"event_type": "Private" | |||
"starts_on": "2014-02-01", | |||
"event_type": "Private", | |||
"creation": "2014-02-01" | |||
} | |||
] |
@@ -13,10 +13,12 @@ def get_notifications(): | |||
return | |||
config = get_notification_config() | |||
groups = config.get("for_doctype").keys() + config.get("for_module").keys() | |||
cache = frappe.cache() | |||
notification_count = {} | |||
notification_percent = {} | |||
for name in groups: | |||
count = cache.hget("notification_count:" + name, frappe.session.user) | |||
@@ -27,6 +29,7 @@ def get_notifications(): | |||
"open_count_doctype": get_notifications_for_doctypes(config, notification_count), | |||
"open_count_module": get_notifications_for_modules(config, notification_count), | |||
"open_count_other": get_notifications_for_other(config, notification_count), | |||
"targets": get_notifications_for_targets(config, notification_percent), | |||
"new_messages": get_new_messages() | |||
} | |||
@@ -111,6 +114,49 @@ def get_notifications_for_doctypes(config, notification_count): | |||
return open_count_doctype | |||
def get_notifications_for_targets(config, notification_percent): | |||
"""Notifications for doc targets""" | |||
can_read = frappe.get_user().get_can_read() | |||
doc_target_percents = {} | |||
# doc_target_percents = { | |||
# "Company": { | |||
# "Acme": 87, | |||
# "RobotsRUs": 50, | |||
# }, {}... | |||
# } | |||
for doctype in config.targets: | |||
if doctype in can_read: | |||
if doctype in notification_percent: | |||
doc_target_percents[doctype] = notification_percent[doctype] | |||
else: | |||
doc_target_percents[doctype] = {} | |||
d = config.targets[doctype] | |||
condition = d["filters"] | |||
target_field = d["target_field"] | |||
value_field = d["value_field"] | |||
try: | |||
if isinstance(condition, dict): | |||
doc_list = frappe.get_list(doctype, fields=["name", target_field, value_field], | |||
filters=condition, limit_page_length = 100, ignore_ifnull=True) | |||
except frappe.PermissionError: | |||
frappe.clear_messages() | |||
pass | |||
except Exception as e: | |||
if e.args[0]!=1412: | |||
raise | |||
else: | |||
for doc in doc_list: | |||
value = doc[value_field] | |||
target = doc[target_field] | |||
doc_target_percents[doctype][doc.name] = (value/target * 100) if value < target else 100 | |||
return doc_target_percents | |||
def clear_notifications(user=None): | |||
if frappe.flags.in_install: | |||
return | |||
@@ -163,7 +209,7 @@ def get_notification_config(): | |||
config = frappe._dict() | |||
for notification_config in frappe.get_hooks().notification_config: | |||
nc = frappe.get_attr(notification_config)() | |||
for key in ("for_doctype", "for_module", "for_other"): | |||
for key in ("for_doctype", "for_module", "for_other", "targets"): | |||
config.setdefault(key, {}) | |||
config[key].update(nc.get(key, {})) | |||
return config | |||
@@ -0,0 +1,61 @@ | |||
# Making Graphs | |||
The Frappe UI **Graph** object enables you to render simple line and bar graphs for a discreet set of data points. You can also set special checkpoint values and summary stats. | |||
### Example: Line graph | |||
Here's is an example of a simple sales graph: | |||
render_graph: function() { | |||
$('.form-graph').empty(); | |||
var months = ['Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul']; | |||
var values = [2410, 3100, 1700, 1200, 2700, 1600, 2740, 1000, 850, 1500, 400, 2013]; | |||
var goal = 2500; | |||
var current_val = 2013; | |||
new frappe.ui.Graph({ | |||
parent: $('.form-graph'), | |||
width: 700, | |||
height: 140, | |||
mode: 'line-graph', | |||
title: 'Sales', | |||
subtitle: 'Monthly', | |||
y_values: values, | |||
x_points: months, | |||
specific_values: [ | |||
{ | |||
name: "Goal", | |||
line_type: "dashed", // "dashed" or "solid" | |||
value: goal | |||
}, | |||
], | |||
summary_values: [ | |||
{ | |||
name: "This month", | |||
color: 'green', // Indicator colors: 'grey', 'blue', 'red', | |||
// 'green', 'orange', 'purple', 'darkgrey', | |||
// 'black', 'yellow', 'lightblue' | |||
value: '₹ ' + current_val | |||
}, | |||
{ | |||
name: "Goal", | |||
color: 'blue', | |||
value: '₹ ' + goal | |||
}, | |||
{ | |||
name: "Completed", | |||
color: 'green', | |||
value: (current_val/goal*100).toFixed(1) + "%" | |||
} | |||
] | |||
}); | |||
}, | |||
<img src="{{docs_base_url}}/assets/img/desk/line_graph.png" class="screenshot"> | |||
Setting the mode to 'bar-graph': | |||
<img src="{{docs_base_url}}/assets/img/desk/bar_graph.png" class="screenshot"> |
@@ -161,6 +161,7 @@ | |||
"public/js/frappe/query_string.js", | |||
"public/js/frappe/ui/charts.js", | |||
"public/js/frappe/ui/graph.js", | |||
"public/js/frappe/misc/rating_icons.html", | |||
"public/js/frappe/feedback.js" | |||
@@ -508,6 +508,17 @@ fieldset[disabled] .form-control { | |||
cursor: pointer; | |||
margin-right: 10px; | |||
} | |||
a.progress-small .progress-chart { | |||
width: 60px; | |||
margin-top: 4px; | |||
float: right; | |||
} | |||
a.progress-small .progress { | |||
margin-bottom: 0; | |||
} | |||
a.progress-small .progress-bar { | |||
background-color: #98d85b; | |||
} | |||
/* on small screens, show only icons on top */ | |||
@media (max-width: 767px) { | |||
.module-view-layout .nav-stacked > li { | |||
@@ -642,6 +642,92 @@ select.form-control { | |||
box-shadow: none; | |||
} | |||
} | |||
/* goals */ | |||
.goals-page-container { | |||
background-color: #fafbfc; | |||
padding-top: 1px; | |||
} | |||
.goals-page-container .goal-container { | |||
background-color: #fff; | |||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); | |||
border-radius: 2px; | |||
padding: 10px; | |||
margin: 10px; | |||
} | |||
.graph-container .graphics { | |||
margin-top: 10px; | |||
padding: 10px 0px; | |||
} | |||
.graph-container .stats-group { | |||
display: flex; | |||
justify-content: space-around; | |||
flex: 1; | |||
} | |||
.graph-container .stats-container { | |||
display: flex; | |||
justify-content: space-around; | |||
} | |||
.graph-container .stats-container .stats { | |||
padding-bottom: 15px; | |||
} | |||
.graph-container .stats-container .stats-title { | |||
color: #8D99A6; | |||
} | |||
.graph-container .stats-container .stats-value { | |||
font-size: 20px; | |||
font-weight: 300; | |||
} | |||
.graph-container .stats-container .stats-description { | |||
font-size: 12px; | |||
color: #8D99A6; | |||
} | |||
.graph-container .stats-container .graph-data .stats-value { | |||
color: #98d85b; | |||
} | |||
.bar-graph .axis, | |||
.line-graph .axis { | |||
font-size: 10px; | |||
fill: #6a737d; | |||
} | |||
.bar-graph .axis line, | |||
.line-graph .axis line { | |||
stroke: rgba(27, 31, 35, 0.1); | |||
} | |||
.data-points circle { | |||
fill: #28a745; | |||
stroke: #fff; | |||
stroke-width: 2; | |||
} | |||
.data-points g.mini { | |||
fill: #98d85b; | |||
} | |||
.data-points path { | |||
fill: none; | |||
stroke: #28a745; | |||
stroke-opacity: 1; | |||
stroke-width: 2px; | |||
} | |||
.line-graph .path { | |||
fill: none; | |||
stroke: #28a745; | |||
stroke-opacity: 1; | |||
stroke-width: 2px; | |||
} | |||
line.dashed { | |||
stroke-dasharray: 5,3; | |||
} | |||
.tick.x-axis-label { | |||
display: block; | |||
} | |||
.tick .specific-value { | |||
text-anchor: start; | |||
} | |||
.tick .y-value-text { | |||
text-anchor: end; | |||
} | |||
.tick .x-value-text { | |||
text-anchor: middle; | |||
} | |||
body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] { | |||
height: 80px !important; | |||
} |
@@ -11,7 +11,7 @@ frappe.ui.form.Dashboard = Class.extend({ | |||
this.progress_area = this.wrapper.find(".progress-area"); | |||
this.heatmap_area = this.wrapper.find('.form-heatmap'); | |||
this.chart_area = this.wrapper.find('.form-chart'); | |||
this.graph_area = this.wrapper.find('.form-graph'); | |||
this.stats_area = this.wrapper.find('.form-stats'); | |||
this.stats_area_row = this.stats_area.find('.row'); | |||
this.links_area = this.wrapper.find('.form-links'); | |||
@@ -43,9 +43,9 @@ frappe.ui.form.Dashboard = Class.extend({ | |||
this.frm.layout.show_message(); | |||
}, | |||
add_comment: function(text, permanent) { | |||
add_comment: function(text, alert_class, permanent) { | |||
var me = this; | |||
this.set_headline_alert(text); | |||
this.set_headline_alert(text, alert_class); | |||
if(!permanent) { | |||
setTimeout(function() { | |||
me.clear_headline(); | |||
@@ -91,6 +91,7 @@ frappe.ui.form.Dashboard = Class.extend({ | |||
this.show(); | |||
}, | |||
format_percent: function(title, percent) { | |||
var width = cint(percent) < 1 ? 1 : cint(percent); | |||
var progress_class = ""; | |||
@@ -138,6 +139,11 @@ frappe.ui.form.Dashboard = Class.extend({ | |||
show = true; | |||
} | |||
if(this.data.graph) { | |||
this.setup_graph(); | |||
show = true; | |||
} | |||
if(show) { | |||
this.show(); | |||
} | |||
@@ -383,13 +389,50 @@ frappe.ui.form.Dashboard = Class.extend({ | |||
}, | |||
//graphs | |||
setup_graph: function() { | |||
var me = this; | |||
var method = this.data.graph_method; | |||
var args = { | |||
doctype: this.frm.doctype, | |||
docname: this.frm.doc.name, | |||
}; | |||
$.extend(args, this.data.graph_method_args); | |||
frappe.call({ | |||
type: "GET", | |||
method: method, | |||
args: args, | |||
callback: function(r) { | |||
if(r.message) { | |||
me.render_graph(r.message); | |||
} | |||
} | |||
}); | |||
}, | |||
render_graph: function(args) { | |||
var me = this; | |||
this.graph_area.empty().removeClass('hidden'); | |||
$.extend(args, { | |||
parent: me.graph_area, | |||
width: 700, | |||
height: 140, | |||
mode: 'line-graph' | |||
}); | |||
new frappe.ui.Graph(args); | |||
}, | |||
setup_chart: function(opts) { | |||
var me = this; | |||
this.chart_area.removeClass('hidden'); | |||
this.graph_area.removeClass('hidden'); | |||
$.extend(opts, { | |||
wrapper: me.wrapper.find('.form-chart'), | |||
wrapper: me.graph_area, | |||
padding: { | |||
right: 30, | |||
bottom: 30 | |||
@@ -5,7 +5,7 @@ | |||
<div id="heatmap-{{ frappe.model.scrub(frm.doctype) }}"></div> | |||
<div class="text-muted small heatmap-message hidden"></div> | |||
</div> | |||
<div class="form-chart form-dashboard-section hidden"></div> | |||
<div class="form-graph form-dashboard-section hidden"></div> | |||
<div class="form-stats form-dashboard-section hidden"> | |||
<div class="row"></div> | |||
</div> | |||
@@ -0,0 +1,308 @@ | |||
// specific_values = [ | |||
// { | |||
// name: "Average", | |||
// line_type: "dashed", // "dashed" or "solid" | |||
// value: 10 | |||
// }, | |||
// summary_values = [ | |||
// { | |||
// name: "Total", | |||
// color: 'blue', // Indicator colors: 'grey', 'blue', 'red', 'green', 'orange', | |||
// // 'purple', 'darkgrey', 'black', 'yellow', 'lightblue' | |||
// value: 80 | |||
// } | |||
// ] | |||
frappe.ui.Graph = class Graph { | |||
constructor({ | |||
parent = null, | |||
width = 0, height = 0, | |||
title = '', subtitle = '', | |||
y_values = [], | |||
x_points = [], | |||
specific_values = [], | |||
summary_values = [], | |||
color = '', | |||
mode = '', | |||
} = {}) { | |||
if(Object.getPrototypeOf(this) === frappe.ui.Graph.prototype) { | |||
if(mode === 'line-graph') { | |||
return new frappe.ui.LineGraph(arguments[0]); | |||
} else if(mode === 'bar-graph') { | |||
return new frappe.ui.BarGraph(arguments[0]); | |||
} | |||
} | |||
this.parent = parent; | |||
this.width = width; | |||
this.height = height; | |||
this.title = title; | |||
this.subtitle = subtitle; | |||
this.y_values = y_values; | |||
this.x_points = x_points; | |||
this.specific_values = specific_values; | |||
this.summary_values = summary_values; | |||
this.color = color; | |||
this.mode = mode; | |||
this.$graph = null; | |||
frappe.require("assets/frappe/js/lib/snap.svg-min.js", this.setup.bind(this)); | |||
} | |||
setup() { | |||
this.setup_container(); | |||
this.refresh(); | |||
} | |||
refresh() { | |||
this.setup_values(); | |||
this.setup_components(); | |||
this.make_y_axis(); | |||
this.make_x_axis(); | |||
this.make_units(); | |||
if(this.specific_values.length > 0) { | |||
this.show_specific_values(); | |||
} | |||
this.setup_group(); | |||
if(this.summary_values.length > 0) { | |||
this.show_summary(); | |||
} | |||
} | |||
setup_container() { | |||
this.container = $('<div>') | |||
.addClass('graph-container') | |||
.append($(`<h6 class="title" style="margin-top: 15px;">${this.title}</h6>`)) | |||
.append($(`<h6 class="sub-title uppercase">${this.subtitle}</h6>`)) | |||
.append($(`<div class="graphics"></div>`)) | |||
.append($(`<div class="stats-container"></div>`)) | |||
.appendTo(this.parent); | |||
let $graphics = this.container.find('.graphics'); | |||
this.$stats_container = this.container.find('.stats-container'); | |||
this.$graph = $('<div>') | |||
.addClass(this.mode) | |||
.appendTo($graphics); | |||
this.$svg = $(`<svg class="svg" width="${this.width}" height="${this.height}"></svg>`); | |||
this.$graph.append(this.$svg); | |||
this.snap = new Snap(this.$svg[0]); | |||
} | |||
setup_values() { | |||
this.upper_graph_bound = this.get_upper_limit_and_parts(this.y_values)[0]; | |||
this.y_axis = this.get_y_axis(this.y_values); | |||
this.avg_unit_width = (this.width-50)/(this.x_points.length - 1); | |||
} | |||
setup_components() { | |||
this.y_axis_group = this.snap.g().attr({ | |||
class: "y axis" | |||
}); | |||
this.x_axis_group = this.snap.g().attr({ | |||
class: "x axis" | |||
}); | |||
this.graph_list = this.snap.g().attr({ | |||
class: "data-points", | |||
}); | |||
this.specific_y_lines = this.snap.g().attr({ | |||
class: "specific axis", | |||
}); | |||
} | |||
setup_group() { | |||
this.snap.g( | |||
this.y_axis_group, | |||
this.x_axis_group, | |||
this.graph_list, | |||
this.specific_y_lines | |||
).attr({ | |||
transform: "translate(40, 10)" // default | |||
}); | |||
} | |||
show_specific_values() { | |||
this.specific_values.map(d => { | |||
this.specific_y_lines.add(this.snap.g( | |||
this.snap.line(0, 0, this.width - 50, 0).attr({ | |||
class: d.line_type === "dashed" ? "dashed": "" | |||
}), | |||
this.snap.text(this.width - 100, 0, d.name.toUpperCase()).attr({ | |||
dy: ".32em", | |||
class: "specific-value", | |||
}) | |||
).attr({ | |||
class: "tick", | |||
transform: `translate(0, ${100 - 100/(this.upper_graph_bound/d.value) })` | |||
})); | |||
}); | |||
} | |||
show_summary() { | |||
this.summary_values.map(d => { | |||
this.$stats_container.append($(`<div class="stats"> | |||
<span class="indicator ${d.color}">${d.name}: ${d.value}</span> | |||
</div>`)); | |||
}); | |||
} | |||
// Helpers | |||
get_upper_limit_and_parts(array) { | |||
let specific_values = this.specific_values.map(d => d.value); | |||
let max_val = Math.max(...array, ...specific_values); | |||
if((max_val+"").length <= 1) { | |||
return [10, 5]; | |||
} else { | |||
let multiplier = Math.pow(10, ((max_val+"").length - 1)); | |||
let significant = Math.ceil(max_val/multiplier); | |||
if(significant % 2 !== 0) significant++; | |||
let parts = (significant < 5) ? significant : significant/2; | |||
return [significant * multiplier, parts]; | |||
} | |||
} | |||
get_y_axis(array) { | |||
let upper_limit, parts; | |||
[upper_limit, parts] = this.get_upper_limit_and_parts(array); | |||
let y_axis = []; | |||
for(var i = 0; i <= parts; i++){ | |||
y_axis.push(upper_limit / parts * i); | |||
} | |||
return y_axis; | |||
} | |||
}; | |||
frappe.ui.BarGraph = class BarGraph extends frappe.ui.Graph { | |||
constructor(args = {}) { | |||
super(args); | |||
} | |||
setup_values() { | |||
super.setup_values(); | |||
this.avg_unit_width = (this.width-50)/(this.x_points.length + 2); | |||
} | |||
make_y_axis() { | |||
this.y_axis.map((point) => { | |||
this.y_axis_group.add(this.snap.g( | |||
this.snap.line(0, 0, this.width, 0), | |||
this.snap.text(-3, 0, point+"").attr({ | |||
dy: ".32em", | |||
class: "y-value-text" | |||
}) | |||
).attr({ | |||
class: "tick", | |||
transform: `translate(0, ${100 - (100/(this.y_axis.length-1) * this.y_axis.indexOf(point)) })` | |||
})); | |||
}); | |||
} | |||
make_x_axis() { | |||
this.x_axis_group.attr({ | |||
transform: "translate(0,100)" | |||
}); | |||
this.x_points.map((point, i) => { | |||
this.x_axis_group.add(this.snap.g( | |||
this.snap.line(0, 0, 0, 6), | |||
this.snap.text(0, 9, point).attr({ | |||
dy: ".71em", | |||
class: "x-value-text" | |||
}) | |||
).attr({ | |||
class: "tick x-axis-label", | |||
transform: `translate(${ ((this.avg_unit_width - 5)*3/2) + i * (this.avg_unit_width + 5) }, 0)` | |||
})); | |||
}); | |||
} | |||
make_units() { | |||
this.y_values.map((value, i) => { | |||
this.graph_list.add(this.snap.g( | |||
this.snap.rect( | |||
0, | |||
(100 - 100/(this.upper_graph_bound/value)), | |||
this.avg_unit_width - 5, | |||
100/(this.upper_graph_bound/value) | |||
) | |||
).attr({ | |||
class: "bar mini", | |||
transform: `translate(${ (this.avg_unit_width - 5) + i * (this.avg_unit_width + 5) }, 0)`, | |||
})); | |||
}); | |||
} | |||
}; | |||
frappe.ui.LineGraph = class LineGraph extends frappe.ui.Graph { | |||
constructor(args = {}) { | |||
super(args); | |||
} | |||
make_y_axis() { | |||
this.y_axis.map((point) => { | |||
this.y_axis_group.add(this.snap.g( | |||
this.snap.line(0, 0, -6, 0), | |||
this.snap.text(-9, 0, point+"").attr({ | |||
dy: ".32em", | |||
class: "y-value-text" | |||
}) | |||
).attr({ | |||
class: "tick", | |||
transform: `translate(0, ${100 - (100/(this.y_axis.length-1) | |||
* this.y_axis.indexOf(point)) })` | |||
})); | |||
}); | |||
} | |||
make_x_axis() { | |||
this.x_axis_group.attr({ | |||
transform: "translate(0,-7)" | |||
}); | |||
this.x_points.map((point, i) => { | |||
this.x_axis_group.add(this.snap.g( | |||
this.snap.line(0, 0, 0, this.height - 25), | |||
this.snap.text(0, this.height - 15, point).attr({ | |||
dy: ".71em", | |||
class: "x-value-text" | |||
}) | |||
).attr({ | |||
class: "tick", | |||
transform: `translate(${ i * this.avg_unit_width }, 0)` | |||
})); | |||
}); | |||
} | |||
make_units() { | |||
let points_list = []; | |||
this.y_values.map((value, i) => { | |||
let x = i * this.avg_unit_width; | |||
let y = (100 - 100/(this.upper_graph_bound/value)); | |||
this.graph_list.add(this.snap.circle( x, y, 4)); | |||
points_list.push(x+","+y); | |||
}); | |||
this.make_path("M"+points_list.join("L")); | |||
} | |||
make_path(path_str) { | |||
this.graph_list.prepend(this.snap.path(path_str)); | |||
} | |||
}; |
@@ -1,125 +1,112 @@ | |||
frappe.provide("frappe.ui.notifications") | |||
frappe.ui.notifications.update_notifications = function() { | |||
frappe.ui.notifications.total = 0; | |||
var doctypes = Object.keys(frappe.boot.notification_info.open_count_doctype).sort(); | |||
var modules = Object.keys(frappe.boot.notification_info.open_count_module).sort(); | |||
var other = Object.keys(frappe.boot.notification_info.open_count_other).sort(); | |||
// clear toolbar / sidebar notifications | |||
frappe.ui.notifications.dropdown_notification = $("#dropdown-notification").empty(); | |||
// add these first. | |||
frappe.ui.notifications.add_notification("Comment"); | |||
frappe.ui.notifications.add_notification("ToDo"); | |||
frappe.ui.notifications.add_notification("Event"); | |||
// add other | |||
$.each(other, function(i, name) { | |||
frappe.ui.notifications.add_notification(name, frappe.boot.notification_info.open_count_other); | |||
}); | |||
// add a divider | |||
if(frappe.ui.notifications.total) { | |||
var divider = '<li class="divider"></li>'; | |||
frappe.ui.notifications.dropdown_notification.append($(divider)); | |||
} | |||
// add to toolbar and sidebar | |||
$.each(doctypes, function(i, doctype) { | |||
if(!in_list(["ToDo", "Comment", "Event"], doctype)) { | |||
frappe.ui.notifications.add_notification(doctype); | |||
} | |||
}); | |||
// set click events | |||
$("#dropdown-notification a").on("click", function() { | |||
var doctype = $(this).attr("data-doctype"); | |||
var config = frappe.ui.notifications.config[doctype] || {}; | |||
if (config.route) { | |||
frappe.set_route(config.route); | |||
} else if (config.click) { | |||
config.click(); | |||
} else { | |||
frappe.views.show_open_count_list(this); | |||
} | |||
}); | |||
// switch colour on the navbar and disable if no notifications | |||
$(".navbar-new-comments") | |||
.html(frappe.ui.notifications.total > 20 ? '20+' : frappe.ui.notifications.total) | |||
.toggleClass("navbar-new-comments-true", frappe.ui.notifications.total ? true : false) | |||
.parent().toggleClass("disabled", frappe.ui.notifications.total ? false : true); | |||
} | |||
frappe.ui.notifications.add_notification = function(doctype, notifications_map) { | |||
if(!notifications_map) { | |||
notifications_map = frappe.boot.notification_info.open_count_doctype; | |||
} | |||
frappe.provide("frappe.ui.notifications"); | |||
frappe.ui.notifications = { | |||
config: { | |||
"ToDo": { label: __("To Do") }, | |||
"Chat": { label: __("Chat"), route: "chat"}, | |||
"Event": { label: __("Calendar"), route: "List/Event/Calendar" }, | |||
"Email": { label: __("Email"), route: "List/Communication/Inbox" }, | |||
"Likes": { label: __("Likes"), | |||
click: function() { | |||
frappe.route_options = { show_likes: true }; | |||
if (frappe.get_route()[0]=="activity") { | |||
frappe.pages['activity'].page.list.refresh(); | |||
} else { | |||
frappe.set_route("activity"); | |||
} | |||
} | |||
}, | |||
}, | |||
var count = notifications_map[doctype]; | |||
if(count) { | |||
var config = frappe.ui.notifications.config[doctype] || {}; | |||
var label = config.label || doctype; | |||
var notification_row = repl('<li><a class="badge-hover" data-doctype="%(data_doctype)s">\ | |||
<span class="badge pull-right">\ | |||
%(count)s</span> \ | |||
%(label)s </a></li>', { | |||
label: __(label), | |||
count: count > 20 ? '20+' : count, | |||
data_doctype: doctype | |||
update_notifications: function() { | |||
this.total = 0; | |||
this.dropdown = $("#dropdown-notification").empty(); | |||
this.boot_info = frappe.boot.notification_info; | |||
let defaults = ["Comment", "ToDo", "Event"]; | |||
this.get_counts(this.boot_info.open_count_doctype, 0, defaults); | |||
this.get_counts(this.boot_info.open_count_other, 1); | |||
// Target counts are stored for docs per doctype | |||
let targets = { doctypes : {} }, map = this.boot_info.targets; | |||
Object.keys(map).map(doctype => { | |||
Object.keys(map[doctype]).map(doc => { | |||
targets[doc] = map[doctype][doc]; | |||
targets.doctypes[doc] = doctype; | |||
}); | |||
}); | |||
this.get_counts(targets, 1, null, ["doctypes"], true); | |||
this.get_counts(this.boot_info.open_count_doctype, | |||
0, null, defaults); | |||
this.bind_list(); | |||
// switch colour on the navbar and disable if no notifications | |||
$(".navbar-new-comments") | |||
.html(this.total > 20 ? '20+' : this.total) | |||
.toggleClass("navbar-new-comments-true", this.total ? true : false) | |||
.parent().toggleClass("disabled", this.total ? false : true); | |||
}, | |||
frappe.ui.notifications.dropdown_notification.append($(notification_row)); | |||
frappe.ui.notifications.total += count; | |||
} | |||
} | |||
get_counts: function(map, divide, keys, excluded = [], target = false) { | |||
keys = keys ? keys | |||
: Object.keys(map).sort().filter(e => !excluded.includes(e)); | |||
keys.map(key => { | |||
let doc_dt = (map.doctypes) ? map.doctypes[key] : undefined; | |||
if(map[key] > 0) { | |||
this.add_notification(key, map[key], doc_dt, target); | |||
} | |||
}); | |||
if(divide) | |||
this.dropdown.append($('<li class="divider"></li>')); | |||
}, | |||
// default notification config | |||
frappe.ui.notifications.config = { | |||
"ToDo": { label: __("To Do") }, | |||
"Chat": { label: __("Chat"), route: "chat"}, | |||
"Event": { label: __("Calendar"), route: "List/Event/Calendar" }, | |||
"Email": { label: __("Email"), route: "List/Communication/Inbox" }, | |||
"Likes": { | |||
label: __("Likes"), | |||
click: function() { | |||
frappe.route_options = { | |||
show_likes: true | |||
}; | |||
add_notification: function(name, value, doc_dt, target = false) { | |||
let label = this.config[name] ? this.config[name].label : name; | |||
let $list_item = !target | |||
? $(`<li><a class="badge-hover" data-doctype="${name}">${label} | |||
<span class="badge pull-right">${value}</span> | |||
</a></li>`) | |||
: $(`<li><a class="progress-small" data-doctype="${doc_dt}" | |||
data-doc="${name}">${label} | |||
<div class="progress-chart"><div class="progress"> | |||
<div class="progress-bar" style="width: ${value}%"></div> | |||
</div></div> | |||
</a></li>`); | |||
this.dropdown.append($list_item); | |||
if(!target) this.total += value; | |||
}, | |||
if (frappe.get_route()[0]=="activity") { | |||
frappe.pages['activity'].page.list.refresh(); | |||
bind_list: function() { | |||
var me = this; | |||
$("#dropdown-notification a").on("click", function() { | |||
var doctype = $(this).attr("data-doctype"); | |||
var doc = $(this).attr("data-doc"); | |||
if(!doc) { | |||
var config = me.config[doctype] || {}; | |||
if (config.route) { | |||
frappe.set_route(config.route); | |||
} else if (config.click) { | |||
config.click(); | |||
} else { | |||
frappe.ui.notifications.show_open_count_list(doctype); | |||
} | |||
} else { | |||
frappe.set_route("activity"); | |||
frappe.set_route("Form", doctype, doc); | |||
} | |||
} | |||
}); | |||
}, | |||
}; | |||
frappe.views.show_open_count_list = function(element) { | |||
var doctype = $(element).attr("data-doctype"); | |||
var filters = frappe.ui.notifications.get_filters(doctype); | |||
if(filters) { | |||
frappe.route_options = filters; | |||
} | |||
var route = frappe.get_route(); | |||
if(route[0]==="List" && route[1]===doctype) { | |||
frappe.pages["List/" + doctype].list_view.refresh(); | |||
} else { | |||
frappe.set_route("List", doctype); | |||
} | |||
} | |||
frappe.ui.notifications.get_filters = function(doctype) { | |||
var conditions = frappe.boot.notification_info.conditions[doctype]; | |||
if(conditions && $.isPlainObject(conditions)) { | |||
return conditions; | |||
} | |||
} | |||
show_open_count_list: function(doctype) { | |||
let filters = this.boot_info.conditions[doctype]; | |||
if(filters && $.isPlainObject(filters)) { | |||
frappe.route_options = filters; | |||
} | |||
let route = frappe.get_route(); | |||
if(route[0]==="List" && route[1]===doctype) { | |||
frappe.pages["List/" + doctype].list_view.refresh(); | |||
} else { | |||
frappe.set_route("List", doctype); | |||
} | |||
}, | |||
} |
@@ -335,7 +335,7 @@ _f.Frm.prototype.refresh_header = function(is_a_different_doc) { | |||
! this.is_dirty() && | |||
! this.is_new() && | |||
this.doc.docstatus===0) { | |||
this.dashboard.add_comment(__('Submit this document to confirm'), true); | |||
this.dashboard.add_comment(__('Submit this document to confirm'), 'alert-warning', true); | |||
} | |||
this.clear_custom_buttons(); | |||
@@ -66,8 +66,8 @@ a[disabled="disabled"] { | |||
#alert-container .desk-alert { | |||
-webkit-box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); | |||
-moz-box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); | |||
box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); | |||
-moz-box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); | |||
box-shadow: 0 0px 5px rgba(0, 0, 0, 0.1); | |||
padding: 10px 40px 10px 20px; | |||
max-width: 400px; | |||
@@ -318,19 +318,35 @@ textarea.form-control { | |||
} | |||
.open-notification { | |||
position:relative; | |||
position:relative; | |||
left: 2px; | |||
display:inline-block; | |||
background:#ff5858; | |||
font-size: @text-medium; | |||
line-height:20px; | |||
padding:0 8px; | |||
color:#fff; | |||
border-radius:10px; | |||
display:inline-block; | |||
background:#ff5858; | |||
font-size: @text-medium; | |||
line-height:20px; | |||
padding:0 8px; | |||
color:#fff; | |||
border-radius:10px; | |||
cursor: pointer; | |||
margin-right: 10px; | |||
} | |||
a.progress-small { | |||
.progress-chart { | |||
width: 60px; | |||
margin-top: 4px; | |||
float: right; | |||
} | |||
.progress { | |||
margin-bottom: 0; | |||
} | |||
.progress-bar { | |||
background-color: #98d85b; | |||
} | |||
} | |||
/* on small screens, show only icons on top */ | |||
@media (max-width: 767px) { | |||
.module-view-layout .nav-stacked > li { | |||
@@ -825,7 +841,7 @@ textarea.form-control { | |||
} | |||
.c3-line { | |||
stroke-width: 3px; | |||
stroke-width: 3px; | |||
} | |||
.c3-tooltip { | |||
@@ -897,10 +913,10 @@ 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%; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
height: 100%; | |||
} | |||
// mozilla doesn't support | |||
@@ -827,6 +827,123 @@ select.form-control { | |||
} | |||
} | |||
/* goals */ | |||
.goals-page-container { | |||
background-color: #fafbfc; | |||
padding-top: 1px; | |||
.goal-container { | |||
background-color: #fff; | |||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); | |||
border-radius: 2px; | |||
padding: 10px; | |||
margin: 10px; | |||
} | |||
} | |||
.graph-container { | |||
.graphics { | |||
margin-top: 10px; | |||
padding: 10px 0px; | |||
} | |||
.stats-group { | |||
display: flex; | |||
justify-content: space-around; | |||
flex: 1; | |||
} | |||
.stats-container { | |||
display: flex; | |||
justify-content: space-around; | |||
.stats { | |||
padding-bottom: 15px; | |||
} | |||
.stats-title { | |||
color: #8D99A6; | |||
} | |||
.stats-value { | |||
font-size: 20px; | |||
font-weight: 300; | |||
} | |||
.stats-description { | |||
font-size: 12px; | |||
color: #8D99A6; | |||
} | |||
.graph-data .stats-value { | |||
color: #98d85b; | |||
} | |||
} | |||
} | |||
.bar-graph, .line-graph { | |||
.axis { | |||
font-size: 10px; | |||
fill: #6a737d; | |||
line { | |||
stroke: rgba(27,31,35,0.1); | |||
} | |||
} | |||
} | |||
.data-points { | |||
circle { | |||
fill: #28a745; | |||
stroke: #fff; | |||
stroke-width: 2; | |||
} | |||
g.mini { | |||
fill: #98d85b; | |||
} | |||
path { | |||
fill: none; | |||
stroke: #28a745; | |||
stroke-opacity: 1; | |||
stroke-width: 2px; | |||
} | |||
} | |||
.line-graph { | |||
.path { | |||
fill: none; | |||
stroke: #28a745; | |||
stroke-opacity: 1; | |||
stroke-width: 2px; | |||
} | |||
} | |||
line.dashed { | |||
stroke-dasharray: 5,3; | |||
} | |||
.tick { | |||
&.x-axis-label { | |||
display: block; | |||
} | |||
.specific-value { | |||
text-anchor: start; | |||
} | |||
.y-value-text { | |||
text-anchor: end; | |||
} | |||
.x-value-text { | |||
text-anchor: middle; | |||
} | |||
} | |||
body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] { | |||
height: 80px !important; | |||
} | |||
@@ -0,0 +1,34 @@ | |||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | |||
# MIT License. See license.txt | |||
from __future__ import unicode_literals | |||
import unittest | |||
import frappe | |||
from frappe.utils.goal import get_monthly_results, get_monthly_goal_graph_data | |||
from frappe.test_runner import make_test_objects | |||
import frappe.utils | |||
class TestGoal(unittest.TestCase): | |||
def setUp(self): | |||
make_test_objects('Event', reset=True) | |||
def tearDown(self): | |||
frappe.db.sql('delete from `tabEvent`') | |||
# make_test_objects('Event', reset=True) | |||
frappe.db.commit() | |||
def test_get_monthly_results(self): | |||
'''Test monthly aggregation values of a field''' | |||
result_dict = get_monthly_results('Event', 'subject', 'creation', 'event_type="Private"', 'count') | |||
from frappe.utils import today, formatdate | |||
self.assertEquals(result_dict[formatdate(today(), "MM-yyyy")], 2) | |||
def test_get_monthly_goal_graph_data(self): | |||
'''Test for accurate values in graph data (based on test_get_monthly_results)''' | |||
docname = frappe.get_list('Event', filters = {"subject": ["=", "_Test Event 1"]})[0]["name"] | |||
frappe.db.set_value('Event', docname, 'description', 1) | |||
data = get_monthly_goal_graph_data('Test', 'Event', docname, 'description', 'description', 'description', | |||
'Event', '', 'description', 'creation', 'starts_on = "2014-01-01"', 'count') | |||
self.assertEquals(float(data['y_values'][-1]), 1) |
@@ -0,0 +1,128 @@ | |||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | |||
# License: GNU General Public License v3. See license.txt | |||
from __future__ import unicode_literals | |||
import frappe | |||
def get_monthly_results(goal_doctype, goal_field, date_col, filter_str, aggregation = 'sum'): | |||
'''Get monthly aggregation values for given field of doctype''' | |||
where_clause = ('where ' + filter_str) if filter_str else '' | |||
results = frappe.db.sql(''' | |||
select | |||
{0}({1}) as {1}, date_format({2}, '%m-%Y') as month_year | |||
from | |||
`{3}` | |||
{4} | |||
group by | |||
month_year'''.format(aggregation, goal_field, date_col, "tab" + | |||
goal_doctype, where_clause), as_dict=True) | |||
month_to_value_dict = {} | |||
for d in results: | |||
month_to_value_dict[d['month_year']] = d[goal_field] | |||
return month_to_value_dict | |||
@frappe.whitelist() | |||
def get_monthly_goal_graph_data(title, doctype, docname, goal_value_field, goal_total_field, goal_history_field, | |||
goal_doctype, goal_doctype_link, goal_field, date_field, filter_str, aggregation="sum"): | |||
''' | |||
Get month-wise graph data for a doctype based on aggregation values of a field in the goal doctype | |||
:param title: Graph title | |||
:param doctype: doctype of graph doc | |||
:param docname: of the doc to set the graph in | |||
:param goal_value_field: goal field of doctype | |||
:param goal_total_field: current month value field of doctype | |||
:param goal_history_field: cached history field | |||
:param goal_doctype: doctype the goal is based on | |||
:param goal_doctype_link: doctype link field in goal_doctype | |||
:param goal_field: field from which the goal is calculated | |||
:param filter_str: where clause condition | |||
:param aggregation: a value like 'count', 'sum', 'avg' | |||
:return: dict of graph data | |||
''' | |||
from frappe.utils.formatters import format_value | |||
import json | |||
meta = frappe.get_meta(doctype) | |||
doc = frappe.get_doc(doctype, docname) | |||
goal = doc.get(goal_value_field) | |||
formatted_goal = format_value(goal, meta.get_field(goal_value_field), doc) | |||
current_month_value = doc.get(goal_total_field) | |||
formatted_value = format_value(current_month_value, meta.get_field(goal_total_field), doc) | |||
from frappe.utils import today, getdate, formatdate, add_months | |||
current_month_year = formatdate(today(), "MM-yyyy") | |||
history = doc.get(goal_history_field) | |||
try: | |||
month_to_value_dict = json.loads(history) if history and '{' in history else None | |||
except ValueError: | |||
month_to_value_dict = None | |||
if month_to_value_dict is None: | |||
doc_filter = (goal_doctype_link + ' = "' + docname + '"') if doctype != goal_doctype else '' | |||
if filter_str: | |||
doc_filter += ' and ' + filter_str if doc_filter else filter_str | |||
month_to_value_dict = get_monthly_results(goal_doctype, goal_field, date_field, doc_filter, aggregation) | |||
frappe.db.set_value(doctype, docname, goal_history_field, json.dumps(month_to_value_dict)) | |||
month_to_value_dict[current_month_year] = current_month_value | |||
months = [] | |||
values = [] | |||
for i in xrange(0, 12): | |||
month_value = formatdate(add_months(today(), -i), "MM-yyyy") | |||
month_word = getdate(month_value).strftime('%b') | |||
months.insert(0, month_word) | |||
if month_value in month_to_value_dict: | |||
values.insert(0, month_to_value_dict[month_value]) | |||
else: | |||
values.insert(0, 0) | |||
specific_values = [] | |||
summary_values = [ | |||
{ | |||
'name': "This month", | |||
'color': 'green', | |||
'value': formatted_value | |||
} | |||
] | |||
if float(goal) > 0: | |||
specific_values = [ | |||
{ | |||
'name': "Goal", | |||
'line_type': "dashed", | |||
'value': goal | |||
}, | |||
] | |||
summary_values += [ | |||
{ | |||
'name': "Goal", | |||
'color': 'blue', | |||
'value': formatted_goal | |||
}, | |||
{ | |||
'name': "Completed", | |||
'color': 'green', | |||
'value': str(int(round(float(current_month_value)/float(goal)*100))) + "%" | |||
} | |||
] | |||
data = { | |||
'title': title, | |||
# 'subtitle': | |||
'y_values': values, | |||
'x_points': months, | |||
'specific_values': specific_values, | |||
'summary_values': summary_values | |||
} | |||
return data |