From 6d41a9a647c5f006fb66d072a0d3a45f9c0e8f95 Mon Sep 17 00:00:00 2001 From: Zarrar Date: Tue, 31 Oct 2017 13:10:55 +0530 Subject: [PATCH] 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 --- erpnext/config/projects.py | 1 + erpnext/projects/doctype/task/task.js | 80 +++++--- erpnext/projects/doctype/task/task.json | 185 +++++++++++++++++- erpnext/projects/doctype/task/task.py | 69 ++++++- erpnext/projects/doctype/task/task_tree.js | 59 ++++++ .../doctype/task/{ => tests}/test_task.js | 0 .../doctype/task/tests/test_task_tree.js | 99 ++++++++++ erpnext/tests/ui/tests.txt | 1 + 8 files changed, 457 insertions(+), 37 deletions(-) create mode 100644 erpnext/projects/doctype/task/task_tree.js rename erpnext/projects/doctype/task/{ => tests}/test_task.js (100%) create mode 100644 erpnext/projects/doctype/task/tests/test_task_tree.js diff --git a/erpnext/config/projects.py b/erpnext/config/projects.py index a8514b26ff..b97e097f08 100644 --- a/erpnext/config/projects.py +++ b/erpnext/config/projects.py @@ -15,6 +15,7 @@ def get_data(): { "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 df38cfe1bd..b8f324a85f 100644 --- a/erpnext/projects/doctype/task/task.js +++ b/erpnext/projects/doctype/task/task.js @@ -19,38 +19,47 @@ frappe.ui.form.on("Task", { }, 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 @@ frappe.ui.form.on("Task", { } }, + 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 e4ab5a71e6..41950a381b 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, @@ -75,6 +74,37 @@ "set_only_once": 0, "unique": 0 }, + { + "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, @@ -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 52ae132078..5937f97f02 100644 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -5,13 +5,14 @@ from __future__ import unicode_literals 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 @@ class Task(Document): 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 @@ class Task(Document): 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 @@ def set_tasks_as_overdue(): 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 0000000000..f11c34f448 --- /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 0000000000..9cbcf851e3 --- /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 e7de60439a..24858f3646 100644 --- a/erpnext/tests/ui/tests.txt +++ b/erpnext/tests/ui/tests.txt @@ -133,3 +133,4 @@ erpnext/restaurant/doctype/restaurant/test_restaurant.js 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