Converting Task to a Tree structure (#11117)
* added support for tree view
* nestedset added to handle tree based structure
* treeview ui added
* removed is_group dependency
* added validation while editing a group-task
* codacy fix
* BOM like filter added
* Added ui-test for treeview-task
diff --git a/erpnext/config/projects.py b/erpnext/config/projects.py
index a8514b2..b97e097 100644
--- a/erpnext/config/projects.py
+++ b/erpnext/config/projects.py
@@ -15,6 +15,7 @@
{
"type": "doctype",
"name": "Task",
+ "route": "Tree/Task",
"description": _("Project activity / task."),
},
{
diff --git a/erpnext/projects/doctype/task/task.js b/erpnext/projects/doctype/task/task.js
index df38cfe..b8f324a 100644
--- a/erpnext/projects/doctype/task/task.js
+++ b/erpnext/projects/doctype/task/task.js
@@ -19,38 +19,47 @@
},
refresh: function(frm) {
- var doc = frm.doc;
- if(doc.__islocal) {
- if(!frm.doc.exp_end_date) {
- frm.set_value("exp_end_date", frappe.datetime.add_days(new Date(), 7));
+ frm.fields_dict['parent_task'].get_query = function() {
+ return {
+ filters: {
+ "is_group": 1,
+ }
}
}
-
- if(!doc.__islocal) {
- if(frappe.model.can_read("Timesheet")) {
- frm.add_custom_button(__("Timesheet"), function() {
- frappe.route_options = {"project": doc.project, "task": doc.name}
- frappe.set_route("List", "Timesheet");
- }, __("View"), true);
- }
- if(frappe.model.can_read("Expense Claim")) {
- frm.add_custom_button(__("Expense Claims"), function() {
- frappe.route_options = {"project": doc.project, "task": doc.name}
- frappe.set_route("List", "Expense Claim");
- }, __("View"), true);
+ if(!frm.is_group){
+ var doc = frm.doc;
+ if(doc.__islocal) {
+ if(!frm.doc.exp_end_date) {
+ frm.set_value("exp_end_date", frappe.datetime.add_days(new Date(), 7));
+ }
}
- if(frm.perm[0].write) {
- if(frm.doc.status!=="Closed" && frm.doc.status!=="Cancelled") {
- frm.add_custom_button(__("Close"), function() {
- frm.set_value("status", "Closed");
- frm.save();
- });
- } else {
- frm.add_custom_button(__("Reopen"), function() {
- frm.set_value("status", "Open");
- frm.save();
- });
+ if(!doc.__islocal) {
+ if(frappe.model.can_read("Timesheet")) {
+ frm.add_custom_button(__("Timesheet"), function() {
+ frappe.route_options = {"project": doc.project, "task": doc.name}
+ frappe.set_route("List", "Timesheet");
+ }, __("View"), true);
+ }
+ if(frappe.model.can_read("Expense Claim")) {
+ frm.add_custom_button(__("Expense Claims"), function() {
+ frappe.route_options = {"project": doc.project, "task": doc.name}
+ frappe.set_route("List", "Expense Claim");
+ }, __("View"), true);
+ }
+
+ if(frm.perm[0].write) {
+ if(frm.doc.status!=="Closed" && frm.doc.status!=="Cancelled") {
+ frm.add_custom_button(__("Close"), function() {
+ frm.set_value("status", "Closed");
+ frm.save();
+ });
+ } else {
+ frm.add_custom_button(__("Reopen"), function() {
+ frm.set_value("status", "Open");
+ frm.save();
+ });
+ }
}
}
}
@@ -71,6 +80,21 @@
}
},
+ is_group: function(frm) {
+ frappe.call({
+ method:"erpnext.projects.doctype.task.task.check_if_child_exists",
+ args: {
+ name: frm.doc.name
+ },
+ callback: function(r){
+ if(r.message){
+ frappe.msgprint(__('Cannot convert it to non-group. Child Tasks exist.'));
+ frm.reload_doc();
+ }
+ }
+ })
+ },
+
validate: function(frm) {
frm.doc.project && frappe.model.remove_from_locals("Project",
frm.doc.project);
diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json
index e4ab5a7..41950a3 100644
--- a/erpnext/projects/doctype/task/task.json
+++ b/erpnext/projects/doctype/task/task.json
@@ -3,7 +3,7 @@
"allow_guest_to_view": 0,
"allow_import": 1,
"allow_rename": 1,
- "autoname": "TASK.#####",
+ "autoname": "field:subject",
"beta": 0,
"creation": "2013-01-29 19:25:50",
"custom": 0,
@@ -30,9 +30,8 @@
"label": "Subject",
"length": 0,
"no_copy": 0,
- "oldfieldname": "subject",
- "oldfieldtype": "Data",
"permlevel": 0,
+ "precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
@@ -78,6 +77,37 @@
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
+ "bold": 1,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "0",
+ "fieldname": "is_group",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_standard_filter": 0,
+ "label": "Is Group",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
@@ -173,9 +203,42 @@
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
- "bold": 0,
+ "bold": 1,
"collapsible": 0,
"columns": 0,
+ "fieldname": "parent_task",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "ignore_user_permissions": 1,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Parent Task",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Task",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 1,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": "",
+ "columns": 0,
+ "depends_on": "",
"fieldname": "section_break_10",
"fieldtype": "Section Break",
"hidden": 0,
@@ -205,6 +268,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "exp_start_date",
"fieldtype": "Date",
"hidden": 0,
@@ -237,6 +301,7 @@
"collapsible": 0,
"columns": 0,
"default": "0",
+ "depends_on": "",
"description": "",
"fieldname": "expected_time",
"fieldtype": "Float",
@@ -269,6 +334,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "task_weight",
"fieldtype": "Float",
"hidden": 0,
@@ -328,6 +394,7 @@
"bold": 1,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "exp_end_date",
"fieldtype": "Date",
"hidden": 0,
@@ -359,6 +426,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "progress",
"fieldtype": "Percent",
"hidden": 0,
@@ -389,6 +457,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "is_milestone",
"fieldtype": "Check",
"hidden": 0,
@@ -418,7 +487,9 @@
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
+ "collapsible_depends_on": "",
"columns": 0,
+ "depends_on": "",
"fieldname": "section_break0",
"fieldtype": "Section Break",
"hidden": 0,
@@ -449,6 +520,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "description",
"fieldtype": "Text Editor",
"hidden": 0,
@@ -481,7 +553,9 @@
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
+ "collapsible_depends_on": "",
"columns": 0,
+ "depends_on": "",
"fieldname": "section_break",
"fieldtype": "Section Break",
"hidden": 0,
@@ -512,6 +586,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "depends_on",
"fieldtype": "Table",
"hidden": 0,
@@ -543,6 +618,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "depends_on_tasks",
"fieldtype": "Data",
"hidden": 1,
@@ -572,7 +648,9 @@
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
+ "collapsible_depends_on": "",
"columns": 0,
+ "depends_on": "",
"description": "",
"fieldname": "actual",
"fieldtype": "Section Break",
@@ -606,6 +684,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "act_start_date",
"fieldtype": "Date",
"hidden": 0,
@@ -638,6 +717,7 @@
"collapsible": 0,
"columns": 0,
"default": "",
+ "depends_on": "",
"description": "",
"fieldname": "actual_time",
"fieldtype": "Float",
@@ -699,6 +779,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "act_end_date",
"fieldtype": "Date",
"hidden": 0,
@@ -730,6 +811,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "section_break_17",
"fieldtype": "Section Break",
"hidden": 0,
@@ -759,6 +841,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "total_costing_amount",
"fieldtype": "Currency",
"hidden": 0,
@@ -791,6 +874,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "total_expense_claim",
"fieldtype": "Currency",
"hidden": 0,
@@ -851,6 +935,7 @@
"bold": 0,
"collapsible": 0,
"columns": 0,
+ "depends_on": "",
"fieldname": "total_billing_amount",
"fieldtype": "Currency",
"hidden": 0,
@@ -1025,6 +1110,96 @@
"search_index": 0,
"set_only_once": 0,
"unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "lft",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "lft",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 1,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "rgt",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "rgt",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 1,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "old_parent",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "ignore_user_permissions": 1,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Old Parent",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 1,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
}
],
"has_web_view": 0,
@@ -1039,7 +1214,7 @@
"istable": 0,
"max_attachments": 5,
"menu_index": 0,
- "modified": "2017-05-23 11:28:28.161600",
+ "modified": "2017-10-06 03:57:37.901446",
"modified_by": "Administrator",
"module": "Projects",
"name": "Task",
diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py
index 52ae132..5937f97 100644
--- a/erpnext/projects/doctype/task/task.py
+++ b/erpnext/projects/doctype/task/task.py
@@ -5,13 +5,14 @@
import frappe, json
from frappe.utils import getdate, date_diff, add_days, cstr
-from frappe import _
-
-from frappe.model.document import Document
+from frappe import _, throw
+from frappe.utils.nestedset import NestedSet, rebuild_tree
class CircularReferenceError(frappe.ValidationError): pass
-class Task(Document):
+class Task(NestedSet):
+ nsm_parent_field = 'parent_task'
+
def get_feed(self):
return '{0}: {1}'.format(_(self.status), self.subject)
@@ -59,11 +60,16 @@
depends_on_tasks += d.task + ","
self.depends_on_tasks = depends_on_tasks
+ def update_nsm_model(self):
+ frappe.utils.nestedset.update_nsm(self)
+
def on_update(self):
+ self.update_nsm_model()
self.check_recursion()
self.reschedule_dependent_tasks()
self.update_project()
self.unassign_todo()
+ rebuild_tree("Task", "parent_task")
def unassign_todo(self):
if self.status == "Closed" or self.status == "Cancelled":
@@ -128,6 +134,17 @@
if project_user:
return True
+ def on_trash(self):
+ if check_if_child_exists(self.name):
+ throw(_("Child Task exists for this Task. You can not delete this Task."))
+
+ self.update_nsm_model()
+
+@frappe.whitelist()
+def check_if_child_exists(name):
+ return frappe.db.sql("""select name from `tabTask`
+ where parent_task = %s""", name)
+
@frappe.whitelist()
def get_events(start, end, filters=None):
"""Returns events for Gantt / Calendar view rendering.
@@ -177,4 +194,48 @@
and exp_end_date < CURDATE()
and `status` not in ('Closed', 'Cancelled')""")
+@frappe.whitelist()
+def get_children():
+ doctype = frappe.local.form_dict.get('doctype')
+ parent_field = 'parent_' + doctype.lower().replace(' ', '_')
+ parent = frappe.form_dict.get("parent") or ""
+
+ if parent == "task":
+ parent = ""
+
+ tasks = frappe.db.sql("""select name as value,
+ is_group as expandable
+ from `tab{doctype}`
+ where docstatus < 2
+ and ifnull(`{parent_field}`,'') = %s
+ order by name""".format(doctype=frappe.db.escape(doctype),
+ parent_field=frappe.db.escape(parent_field)), (parent), as_dict=1)
+
+ # return tasks
+ return tasks
+
+@frappe.whitelist()
+def add_node():
+ from frappe.desk.treeview import make_tree_args
+ args = frappe.form_dict
+ args.update({
+ "name_field": "subject"
+ })
+ args = make_tree_args(**args)
+
+ if args.parent_task == 'task':
+ args.parent_task = None
+
+ frappe.get_doc(args).insert()
+
+@frappe.whitelist()
+def add_multiple_tasks(data, parent):
+ data = json.loads(data)['tasks']
+ tasks = data.split('\n')
+ new_doc = {'doctype': 'Task', 'parent_task': parent}
+
+ for d in tasks:
+ new_doc['subject'] = d
+ new_task = frappe.get_doc(new_doc)
+ new_task.insert()
diff --git a/erpnext/projects/doctype/task/task_tree.js b/erpnext/projects/doctype/task/task_tree.js
new file mode 100644
index 0000000..f11c34f
--- /dev/null
+++ b/erpnext/projects/doctype/task/task_tree.js
@@ -0,0 +1,59 @@
+frappe.provide("frappe.treeview_settings");
+
+frappe.treeview_settings['Task'] = {
+ get_tree_nodes: "erpnext.projects.doctype.task.task.get_children",
+ add_tree_node: "erpnext.projects.doctype.task.task.add_node",
+ filters: [
+ {
+ fieldname: "task",
+ fieldtype:"Link",
+ options: "Task",
+ label: __("Task"),
+ get_query: function(){
+ return {
+ filters: [["Task", 'is_group', '=', 1]]
+ };
+ }
+ }
+ ],
+ title: "Task",
+ breadcrumb: "Projects",
+ get_tree_root: false,
+ root_label: "task",
+ ignore_fields:["parent_task"],
+ get_label: function(node) {
+ return node.data.value;
+ },
+ onload: function(me){
+ me.make_tree();
+ me.set_root = true;
+ },
+ toolbar: [
+ {
+ label:__("Add Multiple"),
+ condition: function(node) {
+ return node.expandable;
+ },
+ click: function(node) {
+ var d = new frappe.ui.Dialog({
+ 'fields': [
+ {'fieldname': 'tasks', 'label': 'Tasks', 'fieldtype': 'Text'},
+ ],
+ primary_action: function(){
+ d.hide();
+ return frappe.call({
+ method: "erpnext.projects.doctype.task.task.add_multiple_tasks",
+ args: {
+ data: d.get_values(),
+ parent: node.data.value
+ },
+ callback: function() { }
+ });
+ }
+ });
+ d.show();
+ }
+ }
+ ],
+ extend_toolbar: true
+};
\ No newline at end of file
diff --git a/erpnext/projects/doctype/task/test_task.js b/erpnext/projects/doctype/task/tests/test_task.js
similarity index 100%
rename from erpnext/projects/doctype/task/test_task.js
rename to erpnext/projects/doctype/task/tests/test_task.js
diff --git a/erpnext/projects/doctype/task/tests/test_task_tree.js b/erpnext/projects/doctype/task/tests/test_task_tree.js
new file mode 100644
index 0000000..9cbcf85
--- /dev/null
+++ b/erpnext/projects/doctype/task/tests/test_task_tree.js
@@ -0,0 +1,99 @@
+/* eslint-disable */
+// rename this file from _test_[name] to test_[name] to activate
+// and remove above this line
+
+QUnit.test("test: Task Tree", function (assert) {
+ let done = assert.async();
+
+ // number of asserts
+ assert.expect(5);
+
+ frappe.run_serially([
+ // insert a new Task
+ () => frappe.set_route('Tree', 'Task'),
+ () => frappe.timeout(0.5),
+
+ // Checking adding child without selecting any Node
+ () => frappe.tests.click_button('New'),
+ () => frappe.timeout(0.5),
+ () => {assert.equal($(`.msgprint`).text(), "Select a group node first.", "Error message success");},
+ () => frappe.tests.click_button('Close'),
+ () => frappe.timeout(0.5),
+
+ // Creating child nodes
+ () => frappe.tests.click_link('task'),
+ () => frappe.map_group.make('Test-1'),
+ () => frappe.map_group.make('Test-2'),
+ () => frappe.map_group.make('Test-3', 1),
+ () => frappe.timeout(1),
+ () => frappe.tests.click_link('Test-3'),
+ () => frappe.map_group.make('Test-4', 0),
+
+ // Checking Edit button
+ () => frappe.timeout(0.5),
+ () => frappe.tests.click_link('Test-1'),
+ () => frappe.tests.click_button('Edit'),
+ () => frappe.timeout(0.5),
+ () => {assert.deepEqual(frappe.get_route(), ["Form", "Task", "Test-1"], "Edit route checks");},
+
+ // Deleting child Node
+ () => frappe.set_route('Tree', 'Task'),
+ () => frappe.timeout(0.5),
+ () => frappe.tests.click_link('Test-1'),
+ () => frappe.tests.click_button('Delete'),
+ () => frappe.timeout(0.5),
+ () => frappe.tests.click_button('Yes'),
+
+ // Deleting Group Node that has child nodes in it
+ () => frappe.timeout(0.5),
+ () => frappe.tests.click_link('Test-3'),
+ () => frappe.tests.click_button('Delete'),
+ () => frappe.timeout(0.5),
+ () => frappe.tests.click_button('Yes'),
+ () => frappe.timeout(1),
+ () => {assert.equal(cur_dialog.title, 'Message', 'Error thrown correctly');},
+ () => frappe.tests.click_button('Close'),
+
+ // Renaming Child node
+ () => frappe.timeout(0.5),
+ () => frappe.tests.click_link('Test-2'),
+ () => frappe.tests.click_button('Rename'),
+ () => frappe.timeout(1),
+ () => cur_dialog.set_value('new_name', 'Test-5'),
+ () => frappe.timeout(1.5),
+ () => cur_dialog.get_primary_btn().click(),
+ () => frappe.timeout(1),
+ () => {assert.equal($(`a:contains("Test-5"):visible`).length, 1, 'Rename successfull');},
+
+ // Add multiple child tasks
+ () => frappe.tests.click_link('Test-3'),
+ () => frappe.timeout(0.5),
+ () => frappe.click_button('Add Multiple'),
+ () => frappe.timeout(1),
+ () => cur_dialog.set_value('tasks', 'Test-6\nTest-7'),
+ () => frappe.timeout(0.5),
+ () => frappe.click_button('Submit'),
+ () => frappe.timeout(2),
+ () => frappe.click_button('Expand All'),
+ () => frappe.timeout(1),
+ () => {
+ let count = $(`a:contains("Test-6"):visible`).length + $(`a:contains("Test-7"):visible`).length;
+ assert.equal(count, 2, "Multiple Tasks added successfully");
+ },
+
+ () => done()
+ ]);
+});
+
+frappe.map_group = {
+ make:function(subject, is_group = 0){
+ return frappe.run_serially([
+ () => frappe.click_button('Add Child'),
+ () => frappe.timeout(1),
+ () => cur_dialog.set_value('is_group', is_group),
+ () => cur_dialog.set_value('subject', subject),
+ () => frappe.click_button('Create New'),
+ () => frappe.timeout(1.5)
+ ]);
+ }
+};
diff --git a/erpnext/tests/ui/tests.txt b/erpnext/tests/ui/tests.txt
index e7de604..24858f3 100644
--- a/erpnext/tests/ui/tests.txt
+++ b/erpnext/tests/ui/tests.txt
@@ -133,3 +133,4 @@
erpnext/restaurant/doctype/restaurant_table/test_restaurant_table.js
erpnext/restaurant/doctype/restaurant_menu/test_restaurant_menu.js
erpnext/restaurant/doctype/restaurant_order_entry/restaurant_order_entry.js
+erpnext/projects/doctype/task/tests/test_task_tree.js