From c1ba35b25b056053c886b732cd59ab6c08ba1484 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 20 Jun 2021 12:42:06 +0530 Subject: [PATCH 01/44] feat: Organizational Chart --- erpnext/hr/doctype/employee/employee.py | 5 +- .../hr/page/organizational_chart/__init__.py | 0 .../page/organizational_chart/node_card.html | 27 ++ .../organizational_chart.js | 409 ++++++++++++++++++ .../organizational_chart.json | 26 ++ .../organizational_chart.py | 49 +++ erpnext/public/build.json | 3 +- erpnext/public/scss/organizational_chart.scss | 209 +++++++++ 8 files changed, 725 insertions(+), 3 deletions(-) create mode 100644 erpnext/hr/page/organizational_chart/__init__.py create mode 100644 erpnext/hr/page/organizational_chart/node_card.html create mode 100644 erpnext/hr/page/organizational_chart/organizational_chart.js create mode 100644 erpnext/hr/page/organizational_chart/organizational_chart.json create mode 100644 erpnext/hr/page/organizational_chart/organizational_chart.py create mode 100644 erpnext/public/scss/organizational_chart.scss diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index ed7d588434..7917e3abf5 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -476,13 +476,14 @@ def get_employee_emails(employee_list): return employee_emails @frappe.whitelist() -def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False): +def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False, fields=None): filters = [['status', '!=', 'Left']] if company and company != 'All Companies': filters.append(['company', '=', company]) - fields = ['name as value', 'employee_name as title'] + if not fields: + fields = ['name as value', 'employee_name as title'] if is_root: parent = '' diff --git a/erpnext/hr/page/organizational_chart/__init__.py b/erpnext/hr/page/organizational_chart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/hr/page/organizational_chart/node_card.html b/erpnext/hr/page/organizational_chart/node_card.html new file mode 100644 index 0000000000..057c45ee86 --- /dev/null +++ b/erpnext/hr/page/organizational_chart/node_card.html @@ -0,0 +1,27 @@ +
+
+
+ + + +
+
+
+ {{ name }} +
+ {{ frappe.utils.icon("edit", "xs") }} + {{ __("Edit") }} +
+
+
+
{{ title }}
+ + {% if connections == 1 %} +
· {{ connections }} {{ __("Connection") }}
+ {% else %} +
· {{ connections }} {{ __("Connections") }}
+ {% endif %} +
+
+
+
\ No newline at end of file diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js new file mode 100644 index 0000000000..04bd9422bd --- /dev/null +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -0,0 +1,409 @@ +frappe.pages['organizational-chart'].on_page_load = function(wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + title: __('Organizational Chart'), + single_column: true + }); + + let organizational_chart = new OrganizationalChart(wrapper); + $(wrapper).bind('show', ()=> { + organizational_chart.show(); + }); +}; + +class OrganizationalChart { + + constructor(wrapper) { + this.wrapper = $(wrapper); + this.page = wrapper.page; + + this.page.main.css({ + 'min-height': '300px', + 'max-height': '600px', + 'overflow': 'auto', + 'position': 'relative' + }); + this.page.main.addClass('frappe-card'); + + this.nodes = {}; + this.setup_node_class(); + } + + setup_node_class() { + let me = this; + this.Node = class { + constructor({ + id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line + }) { + // to setup values passed via constructor + $.extend(this, arguments[0]); + + this.expanded = 0; + + me.nodes[this.id] = this; + me.make_node_element(this); + me.setup_node_click_action(this); + } + } + } + + make_node_element(node) { + let node_card = frappe.render_template('node_card', { + id: node.id, + name: node.name, + title: node.title, + image: node.image, + parent: node.parent_id, + connections: node.connections + }); + + node.parent.append(node_card); + node.$link = $(`#${node.id}`); + } + + show() { + frappe.breadcrumbs.add('HR'); + + let me = this; + let company = this.page.add_field({ + fieldtype: 'Link', + options: 'Company', + fieldname: 'company', + placeholder: __('Select Company'), + default: frappe.defaults.get_default('company'), + only_select: true, + reqd: 1, + change: () => { + me.company = undefined; + + if (company.get_value() && me.company != company.get_value()) { + me.company = company.get_value(); + + // svg for connectors + me.make_svg_markers() + + if (me.$hierarchy) + me.$hierarchy.remove(); + + // setup hierarchy + me.$hierarchy = $( + ``); + + me.page.main.append(me.$hierarchy); + me.render_root_node(); + } + } + }); + + company.refresh(); + $(`[data-fieldname="company"]`).trigger('change'); + } + + make_svg_markers() { + $('#arrows').remove(); + + this.page.main.prepend(` + + + + + + + + + + + + + + + + + + + `); + } + + render_root_node() { + this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; + + let me = this; + + frappe.call({ + method: me.method, + args: { + company: me.company + }, + callback: function(r) { + if (r.message.length) { + let data = r.message[0]; + + let root_node = new me.Node({ + id: data.name, + parent: me.$hierarchy.find('.root-level'), + parent_id: undefined, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: true, + connections: data.connections, + is_root: true, + }); + + me.expand_node(root_node); + } + } + }) + } + + expand_node(node) { + let is_sibling = this.selected_node && this.selected_node.parent_id === node.parent_id; + this.set_selected_node(node); + this.show_active_path(node); + this.collapse_previous_level_nodes(node); + + // since the previous node collapses, all connections to that node need to be rebuilt + // if a sibling node is clicked, connections don't need to be rebuilt + if (!is_sibling) { + // rebuild outgoing connections + this.refresh_connectors(node.parent_id); + + // rebuild incoming connections + let grandparent = $(`#${node.parent_id}`).attr('data-parent'); + this.refresh_connectors(grandparent) + } + + if (node.expandable && !node.expanded) { + return this.load_children(node); + } + } + + collapse_node() { + if (this.selected_node.expandable) { + this.selected_node.$children.hide(); + $(`path[data-parent="${this.selected_node.id}"]`).hide(); + this.selected_node.expanded = false; + } + } + + show_active_path(node) { + // mark node parent on active path + $(`#${node.parent_id}`).addClass('active-path'); + } + + load_children(node) { + frappe.run_serially([ + () => this.get_child_nodes(node.id), + (child_nodes) => this.render_child_nodes(node, child_nodes) + ]); + } + + get_child_nodes(node_id) { + let me = this; + return new Promise(resolve => { + frappe.call({ + method: this.method, + args: { + parent: node_id, + company: me.company + }, + callback: (r) => { + resolve(r.message); + } + }); + }); + } + + render_child_nodes(node, child_nodes) { + const last_level = this.$hierarchy.find('.level:last').index(); + const current_level = $(`#${node.id}`).parent().parent().parent().index(); + + if (last_level === current_level) { + this.$hierarchy.append(` +
  • + `); + } + + if (!node.$children) { + node.$children = $('') + .hide() + .appendTo(this.$hierarchy.find('.level:last')); + + node.$children.empty(); + + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_node(node, data); + + setTimeout(() => { + this.add_connector(node.id, data.name); + }, 250); + }); + } + } + + node.$children.show(); + $(`path[data-parent="${node.id}"]`).show(); + node.expanded = true; + } + + add_node(node, data) { + var $li = $('
  • '); + + return new this.Node({ + id: data.name, + parent: $li.appendTo(node.$children), + parent_id: node.id, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: data.expandable, + connections: data.connections, + children: undefined + }); + } + + add_connector(parent_id, child_id) { + let parent_node = document.querySelector(`#${parent_id}`); + let child_node = document.querySelector(`#${child_id}`); + + // variable for the namespace + const svgns = 'http://www.w3.org/2000/svg'; + let path = document.createElementNS(svgns, 'path'); + + // we need to connect right side of the parent to the left side of the child node + let pos_parent_right = { + x: parent_node.offsetLeft + parent_node.offsetWidth, + y: parent_node.offsetTop + parent_node.offsetHeight / 2 + }; + let pos_child_left = { + x: child_node.offsetLeft - 5, + y: child_node.offsetTop + child_node.offsetHeight / 2 + }; + + let connector = + "M" + + (pos_parent_right.x) + "," + (pos_parent_right.y) + " " + + "C" + + (pos_parent_right.x + 100) + "," + (pos_parent_right.y) + " " + + (pos_child_left.x - 100) + "," + (pos_child_left.y) + " " + + (pos_child_left.x) + "," + (pos_child_left.y); + + path.setAttribute("d", connector); + path.setAttribute("data-parent", parent_id); + path.setAttribute("data-child", child_id); + + if ($(`#${parent_id}`).hasClass('active')) { + path.setAttribute("class", "active-connector"); + path.setAttribute("marker-start", "url(#arrowstart-active)"); + path.setAttribute("marker-end", "url(#arrowhead-active)"); + } else if ($(`#${parent_id}`).hasClass('active-path')) { + path.setAttribute("class", "collapsed-connector"); + path.setAttribute("marker-start", "url(#arrowstart-collapsed)"); + path.setAttribute("marker-end", "url(#arrowhead-collapsed)"); + } + + $('#connectors').append(path); + } + + set_selected_node(node) { + // remove .active class from the current node + $('.active').removeClass('active'); + + // add active class to the newly selected node + this.selected_node = node; + node.$link.addClass('active'); + } + + collapse_previous_level_nodes(node) { + let node_parent = $(`#${node.parent_id}`); + + let previous_level_nodes = node_parent.parent().parent().children('li'); + if (node_parent.parent().hasClass('root-level')) { + previous_level_nodes = node_parent.parent().children('li'); + } + + let node_card = undefined; + + previous_level_nodes.each(function() { + node_card = $(this).find('.node-card'); + + if (!node_card.hasClass('active-path')) { + node_card.addClass('collapsed'); + } + }); + } + + refresh_connectors(node_parent) { + if (!node_parent) return; + + $(`path[data-parent="${node_parent}"]`).remove(); + + frappe.run_serially([ + () => this.get_child_nodes(node_parent), + (child_nodes) => { + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_connector(node_parent, data.name); + }); + } + } + ]); + } + + setup_node_click_action(node) { + let me = this; + let node_element = $(`#${node.id}`); + + node_element.click(function() { + let is_sibling = me.selected_node.parent_id === node.parent_id; + + if (is_sibling) { + me.collapse_node(); + } else if (node_element.is(':visible') + && (node_element.hasClass('collapsed') || node_element.hasClass('active-path'))) { + me.remove_levels_after_node(node); + me.remove_orphaned_connectors(); + } + + me.expand_node(node); + }); + } + + remove_levels_after_node(node) { + let level = $(`#${node.id}`).parent().parent().parent(); + + if ($(`#${node.id}`).parent().hasClass('root-level')) { + level = $(`#${node.id}`).parent(); + } + + level = $('.hierarchy > li:eq('+ level.index() + ')'); + level.nextAll('li').remove(); + + let nodes = level.find('.node-card'); + let node_object = undefined; + + $.each(nodes, (_i, element) => { + node_object = this.nodes[element.id]; + node_object.expanded = 0; + node_object.$children = undefined; + }); + + nodes.removeClass('collapsed active-path'); + } + + remove_orphaned_connectors() { + let paths = $('#connectors > path'); + $.each(paths, (_i, path) => { + let parent = $(path).data('parent'); + let child = $(path).data('child'); + + if ($(parent).length || $(child).length) + return; + + $(path).remove(); + }) + } +} diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.json b/erpnext/hr/page/organizational_chart/organizational_chart.json new file mode 100644 index 0000000000..d802781320 --- /dev/null +++ b/erpnext/hr/page/organizational_chart/organizational_chart.json @@ -0,0 +1,26 @@ +{ + "content": null, + "creation": "2021-05-25 10:53:10.107241", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2021-05-25 10:53:18.201931", + "modified_by": "Administrator", + "module": "HR", + "name": "organizational-chart", + "owner": "Administrator", + "page_name": "Organizational Chart", + "roles": [ + { + "role": "HR User" + }, + { + "role": "HR Manager" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Organizational Chart" +} \ No newline at end of file diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py new file mode 100644 index 0000000000..be2964530b --- /dev/null +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals +import frappe + +@frappe.whitelist() +def get_children(parent=None, company=None, is_root=False, is_tree=False, fields=None): + + filters = [['status', '!=', 'Left']] + if company and company != 'All Companies': + filters.append(['company', '=', company]) + + if not fields: + fields = ['employee_name', 'name', 'reports_to', 'image', 'designation'] + + if is_root: + parent = '' + if parent and company and parent!=company: + filters.append(['reports_to', '=', parent]) + else: + filters.append(['reports_to', '=', '']) + + employees = frappe.get_list('Employee', fields=fields, + filters=filters, order_by='name') + + for employee in employees: + is_expandable = frappe.get_all('Employee', filters=[ + ['reports_to', '=', employee.get('name')] + ]) + employee.connections = get_connections(employee.name) + employee.expandable = 1 if is_expandable else 0 + + return employees + + +def get_connections(employee): + num_connections = 0 + + connections = frappe.get_list('Employee', filters=[ + ['reports_to', '=', employee] + ]) + num_connections += len(connections) + + while connections: + for entry in connections: + connections = frappe.get_list('Employee', filters=[ + ['reports_to', '=', entry.name] + ]) + num_connections += len(connections) + + return num_connections \ No newline at end of file diff --git a/erpnext/public/build.json b/erpnext/public/build.json index 7a3cb838a9..d3ebcdf7e7 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -3,7 +3,8 @@ "public/less/erpnext.less", "public/less/hub.less", "public/scss/call_popup.scss", - "public/scss/point-of-sale.scss" + "public/scss/point-of-sale.scss", + "public/scss/organizational_chart.scss" ], "css/marketplace.css": [ "public/less/hub.less" diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/organizational_chart.scss new file mode 100644 index 0000000000..62f6ddcb6e --- /dev/null +++ b/erpnext/public/scss/organizational_chart.scss @@ -0,0 +1,209 @@ +.node-card { + background: white; + stroke: 1px solid var(--gray-200); + box-shadow: var(--shadow-base); + border-radius: 0.5rem; + padding: 0.75rem; + margin-left: 3rem; + width: 18rem; + + .btn-edit-node { + display: none; + } + + .edit-chart-node { + display: none; + } + + .node-edit-icon { + display: none; + } +} + +.node-image { + width: 3.0rem; + height: 3.0rem; +} + +.node-name { + font-size: 1rem; + line-height: 1.72; +} + +.node-title { + font-size: 0.75rem; + line-height: 1.35; +} + +.node-connections { + font-size: 0.75rem; + line-height: 1.35; +} + +.node-card.active { + background: var(--blue-50); + border: 1px solid var(--blue-500); + box-shadow: var(--shadow-md); + border-radius: 0.5rem; + padding: 0.75rem; + width: 18rem; + height: 5rem; + + .btn-edit-node { + display: flex; + background: var(--blue-100); + color: var(--blue-500); + padding: .25rem .5rem; + font-size: .75rem; + justify-content: center; + box-shadow: var(--shadow-sm); + } + + .edit-chart-node { + display: block; + } + + .node-edit-icon { + display: block; + } + + .edit-chart-node { + margin-right: 0.25rem; + } + + .node-edit-icon > .icon{ + stroke: var(--blue-500); + } + + .node-name { + align-items: center; + justify-content: space-between; + margin-bottom: 2px; + } +} + +.node-card.active-path { + background: var(--blue-100); + border: 1px solid var(--blue-300); + box-shadow: var(--shadow-sm); + border-radius: 0.5rem; + padding: 0.75rem; + width: 15rem; + height: 3.0rem; + + .btn-edit-node { + display: none !important; + } + + .edit-chart-node { + display: none; + } + + .node-edit-icon { + display: none; + } + + .node-info { + display: none; + } + + .node-title { + display: none; + } + + .node-connections { + display: none; + } + + .node-name { + font-size: 0.85rem; + line-height: 1.35; + } + + .node-image { + width: 1.5rem; + height: 1.5rem; + } + + .node-meta { + align-items: baseline; + } +} + +.node-card.collapsed { + background: white; + stroke: 1px solid var(--gray-200); + box-shadow: var(--shadow-sm); + border-radius: 0.5rem; + padding: 0.75rem; + width: 15rem; + height: 3.0rem; + + .btn-edit-node { + display: none !important; + } + + .edit-chart-node { + display: none; + } + + .node-edit-icon { + display: none; + } + + .node-info { + display: none; + } + + .node-title { + display: none; + } + + .node-connections { + display: none; + } + + .node-name { + font-size: 0.85rem; + line-height: 1.35; + } + + .node-image { + width: 1.5rem; + height: 1.5rem; + } + + .node-meta { + align-items: baseline; + } +} + +// horizontal hierarchy tree view +.hierarchy { + display: flex; + padding-top: 30px; +} + +.hierarchy li { + list-style-type: none; +} + +.child-node { + margin: 0px 0px 16px 0px; +} + +.level { + margin-right: 8px; +} + +#arrows { + position: absolute; +} + +.active-connector { + stroke: var(--blue-500); +} + +.collapsed-connector { + stroke: var(--blue-300); +} From cce19db826be54b401a9514cb6517810c98e6f7c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 21 Jun 2021 21:55:50 +0530 Subject: [PATCH 02/44] feat: org chart mobile interactions --- .../page/organizational_chart/node_card.html | 4 +- .../organizational_chart.js | 329 +++++++++++++++++- .../organizational_chart.py | 6 +- erpnext/public/scss/organizational_chart.scss | 70 ++++ 4 files changed, 404 insertions(+), 5 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/node_card.html b/erpnext/hr/page/organizational_chart/node_card.html index 057c45ee86..e42e54f690 100644 --- a/erpnext/hr/page/organizational_chart/node_card.html +++ b/erpnext/hr/page/organizational_chart/node_card.html @@ -17,9 +17,9 @@
    {{ title }}
    {% if connections == 1 %} -
    · {{ connections }} {{ __("Connection") }}
    +
    · {{ connections }}
    {% else %} -
    · {{ connections }} {{ __("Connections") }}
    +
    · {{ connections }}
    {% endif %} diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index 04bd9422bd..5739a112de 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -5,13 +5,20 @@ frappe.pages['organizational-chart'].on_page_load = function(wrapper) { single_column: true }); - let organizational_chart = new OrganizationalChart(wrapper); + // let organizational_chart = undefined; + // if (frappe.is_mobile()) { + // organizational_chart = new OrgChartMobile(wrapper); + // } else { + // organizational_chart = new OrgChart(wrapper); + // } + + let organizational_chart = new OrgChartMobile(wrapper); $(wrapper).bind('show', ()=> { organizational_chart.show(); }); }; -class OrganizationalChart { +class OrgChart { constructor(wrapper) { this.wrapper = $(wrapper); @@ -407,3 +414,321 @@ class OrganizationalChart { }) } } + + +class OrgChartMobile { + + constructor(wrapper) { + this.wrapper = $(wrapper); + this.page = wrapper.page; + + this.page.main.css({ + 'min-height': '300px', + 'max-height': '600px', + 'overflow': 'auto', + 'position': 'relative' + }); + this.page.main.addClass('frappe-card'); + + this.nodes = {}; + this.setup_node_class(); + } + + setup_node_class() { + let me = this; + this.Node = class { + constructor({ + id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line + }) { + // to setup values passed via constructor + $.extend(this, arguments[0]); + + this.expanded = 0; + + me.nodes[this.id] = this; + me.make_node_element(this); + me.setup_node_click_action(this); + } + } + } + + make_node_element(node) { + let node_card = frappe.render_template('node_card', { + id: node.id, + name: node.name, + title: node.title, + image: node.image, + parent: node.parent_id, + connections: node.connections, + is_mobile: 1 + }); + + node.parent.append(node_card); + node.$link = $(`#${node.id}`); + node.$link.addClass('mobile-node'); + } + + show() { + frappe.breadcrumbs.add('HR'); + + let me = this; + let company = this.page.add_field({ + fieldtype: 'Link', + options: 'Company', + fieldname: 'company', + placeholder: __('Select Company'), + default: frappe.defaults.get_default('company'), + only_select: true, + reqd: 1, + change: () => { + me.company = undefined; + + if (company.get_value() && me.company != company.get_value()) { + me.company = company.get_value(); + + if (me.$hierarchy) + me.$hierarchy.remove(); + + // setup hierarchy + me.$hierarchy = $( + ``); + + me.page.main.append(me.$hierarchy); + me.render_root_node(); + } + } + }); + + company.refresh(); + $(`[data-fieldname="company"]`).trigger('change'); + } + + render_root_node() { + this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; + + let me = this; + + frappe.call({ + method: me.method, + args: { + company: me.company + }, + callback: function(r) { + if (r.message.length) { + let data = r.message[0]; + + let root_node = new me.Node({ + id: data.name, + parent: me.$hierarchy.find('.root-level'), + parent_id: undefined, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: true, + connections: data.connections, + is_root: true, + }); + + me.expand_node(root_node); + } + } + }) + } + + expand_node(node) { + this.set_selected_node(node); + this.show_active_path(node); + + if (node.expandable && !node.expanded) { + return this.load_children(node); + } + } + + collapse_node() { + let node = this.selected_node; + if (node.expandable) { + node.$children.hide(); + node.expanded = false; + + // add a collapsed level to show the collapsed parent + // and a button beside it to move to that level + let node_parent = node.$link.parent(); + node_parent.prepend( + `
    ` + ); + + node_parent + .find('.collapsed-level') + .append(node.$link); + + frappe.run_serially([ + () => this.get_child_nodes(node.parent_id, node.id), + (child_nodes) => this.get_node_group(child_nodes, node.id), + (node_group) => { + node_parent.find('.collapsed-level') + .append(node_group); + } + ]); + } + } + + show_active_path(node) { + // mark node parent on active path + $(`#${node.parent_id}`).addClass('active-path'); + } + + load_children(node) { + frappe.run_serially([ + () => this.get_child_nodes(node.id), + (child_nodes) => this.render_child_nodes(node, child_nodes) + ]); + } + + get_child_nodes(node_id, exclude_node=null) { + let me = this; + return new Promise(resolve => { + frappe.call({ + method: this.method, + args: { + parent: node_id, + company: me.company, + exclude_node: exclude_node + }, + callback: (r) => { + resolve(r.message); + } + }); + }); + } + + render_child_nodes(node, child_nodes) { + if (!node.$children) { + node.$children = $('') + .hide() + .appendTo(node.$link.parent()); + + node.$children.empty(); + + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_node(node, data); + $(`#${data.name}`).addClass('active-child'); + }); + } + } + + node.$children.show(); + node.expanded = true; + } + + add_node(node, data) { + var $li = $('
  • '); + + return new this.Node({ + id: data.name, + parent: $li.appendTo(node.$children), + parent_id: node.id, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: data.expandable, + connections: data.connections, + children: undefined + }); + } + + set_selected_node(node) { + // remove .active class from the current node + $('.active').removeClass('active'); + + // add active class to the newly selected node + this.selected_node = node; + node.$link.addClass('active'); + } + + setup_node_click_action(node) { + let me = this; + let node_element = $(`#${node.id}`); + let node_object = null; + + node_element.click(function() { + if (node_element.is(':visible') && node_element.hasClass('active-path')) { + me.remove_levels_after_node(node); + } else { + me.add_node_to_hierarchy(node, true); + me.collapse_node(); + } + + me.expand_node(node); + }); + } + + add_node_to_hierarchy(node) { + this.$hierarchy.append(` +
  • +
    +
    +
  • + `); + + node.$link.appendTo(this.$hierarchy.find('.level:last')); + } + + get_node_group(nodes, sibling) { + let limit = 2; + const display_nodes = nodes.slice(0, limit); + const extra_nodes = nodes.slice(limit); + + let html = display_nodes.map(node => + this.get_avatar(node) + ).join(''); + + if (extra_nodes.length === 1) { + let node = extra_nodes[0]; + html += this.get_avatar(node); + } else if (extra_nodes.length > 1) { + html = ` + ${html} + +
    + +${extra_nodes.length} +
    +
    + `; + } + + const $node_group = + $(`
    +
    + ${html} +
    +
    `); + + return $node_group; + } + + get_avatar(node) { + return ` + + ` + } + + remove_levels_after_node(node) { + let level = $(`#${node.id}`).parent().parent(); + + level = $('.hierarchy-mobile > li:eq('+ (level.index()) + ')'); + level.nextAll('li').remove(); + + let current_node = level.find(`#${node.id}`); + let node_object = this.nodes[node.id]; + + current_node.removeClass('active-child active-path'); + node_object.expanded = 0; + node_object.$children = undefined; + + level.empty().append(current_node); + } +} \ No newline at end of file diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py index be2964530b..ae91a919b2 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.py +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import frappe @frappe.whitelist() -def get_children(parent=None, company=None, is_root=False, is_tree=False, fields=None): +def get_children(parent=None, company=None, exclude_node=None, is_root=False, is_tree=False, fields=None): filters = [['status', '!=', 'Left']] if company and company != 'All Companies': @@ -13,6 +13,10 @@ def get_children(parent=None, company=None, is_root=False, is_tree=False, fields if is_root: parent = '' + + if exclude_node: + filters.append(['name', '!=', exclude_node]) + if parent and company and parent!=company: filters.append(['reports_to', '=', parent]) else: diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/organizational_chart.scss index 62f6ddcb6e..02446be11a 100644 --- a/erpnext/public/scss/organizational_chart.scss +++ b/erpnext/public/scss/organizational_chart.scss @@ -6,6 +6,7 @@ padding: 0.75rem; margin-left: 3rem; width: 18rem; + overflow: hidden; .btn-edit-node { display: none; @@ -207,3 +208,72 @@ .collapsed-connector { stroke: var(--blue-300); } + +// mobile + +.hierarchy-mobile { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 30px; + padding-left: 0px; +} + +.hierarchy-mobile li { + list-style-type: none; + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.mobile-node { + margin-left: 0; +} + +.mobile-node.active-path { + width: 12.25rem; +} + +.active-child { + width: 15.5rem; +} + +.mobile-node .node-connections { + max-width: 80px; +} + +.hierarchy-mobile .node-children { + margin-top: 16px; +} + +// node group + +.collapsed-level { + margin-bottom: 16px; +} + +.node-group { + background: white; + border: 1px solid var(--gray-300); + box-shadow: var(--shadow-sm); + border-radius: 0.5rem; + padding: 0.75rem; + margin-left: 12px; + width: 5rem; + height: 3rem; + overflow: hidden; +} + +.node-group .avatar-group { + margin-left: 0px; +} + +.node-group .avatar-extra-count { + background-color: var(--blue-100); + color: var(--blue-500); +} + +.node-group .avatar-frame { + width: 1.5rem; + height: 1.5rem; +} \ No newline at end of file From 6e3a7b4a751d6fa53b8659695753cf17518f1579 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 11:12:47 +0530 Subject: [PATCH 03/44] feat(mobile): sibling node group expansion and rendering --- .../organizational_chart.js | 64 +++++++++++++++---- erpnext/public/scss/organizational_chart.scss | 9 ++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index 5739a112de..edaf46162e 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -565,11 +565,12 @@ class OrgChartMobile { frappe.run_serially([ () => this.get_child_nodes(node.parent_id, node.id), - (child_nodes) => this.get_node_group(child_nodes, node.id), + (child_nodes) => this.get_node_group(child_nodes, node.parent_id), (node_group) => { node_parent.find('.collapsed-level') .append(node_group); - } + }, + () => this.setup_node_group_action() ]); } } @@ -651,7 +652,6 @@ class OrgChartMobile { setup_node_click_action(node) { let me = this; let node_element = $(`#${node.id}`); - let node_object = null; node_element.click(function() { if (node_element.is(':visible') && node_element.hasClass('active-path')) { @@ -665,6 +665,15 @@ class OrgChartMobile { }); } + setup_node_group_action() { + let me = this; + + $('.node-group').on('click', function() { + let parent = $(this).attr('data-parent'); + me.expand_sibling_group_node(parent); + }); + } + add_node_to_hierarchy(node) { this.$hierarchy.append(`
  • @@ -676,7 +685,7 @@ class OrgChartMobile { node.$link.appendTo(this.$hierarchy.find('.level:last')); } - get_node_group(nodes, sibling) { + get_node_group(nodes, parent, collapsed=true) { let limit = 2; const display_nodes = nodes.slice(0, limit); const extra_nodes = nodes.slice(limit); @@ -700,14 +709,23 @@ class OrgChartMobile { `; } - const $node_group = - $(`
    -
    - ${html} -
    -
    `); + if (html) { + const $node_group = + $(`
    +
    + ${html} +
    +
    `); - return $node_group; + if (collapsed) + $node_group.addClass('collapsed'); + else + $node_group.addClass('mb-4'); + + return $node_group; + } + + return null; } get_avatar(node) { @@ -716,6 +734,30 @@ class OrgChartMobile { ` } + expand_sibling_group_node(parent) { + let node_object = this.nodes[parent]; + let node = node_object.$link; + node.removeClass('active-child active-path'); + node_object.expanded = 0; + node_object.$children = undefined; + + // show parent's siblings and expand parent node + frappe.run_serially([ + () => this.get_child_nodes(node_object.parent_id, node_object.id), + (child_nodes) => this.get_node_group(child_nodes, node_object.parent_id, false), + (node_group) => { + this.$hierarchy.empty().append(node_group) }, + () => this.setup_node_group_action(), + () => { + this.$hierarchy.append(` +
  • + `); + this.$hierarchy.append(node); + this.expand_node(node_object); + } + ]); + } + remove_levels_after_node(node) { let level = $(`#${node.id}`).parent().parent(); diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/organizational_chart.scss index 02446be11a..b6d50a0470 100644 --- a/erpnext/public/scss/organizational_chart.scss +++ b/erpnext/public/scss/organizational_chart.scss @@ -258,10 +258,10 @@ box-shadow: var(--shadow-sm); border-radius: 0.5rem; padding: 0.75rem; - margin-left: 12px; - width: 5rem; + width: 18rem; height: 3rem; overflow: hidden; + align-items: center; } .node-group .avatar-group { @@ -276,4 +276,9 @@ .node-group .avatar-frame { width: 1.5rem; height: 1.5rem; +} + +.node-group.collapsed { + width: 5rem; + margin-left: 12px; } \ No newline at end of file From 25e8723032aec69e7347ece7e714331cc2a35815 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 15:06:09 +0530 Subject: [PATCH 04/44] fix: expanded node group interactions and visibility --- .../organizational_chart.js | 23 +++++++++++++++---- erpnext/public/scss/organizational_chart.scss | 9 +++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index edaf46162e..f693cf6ba6 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -486,6 +486,13 @@ class OrgChartMobile { if (company.get_value() && me.company != company.get_value()) { me.company = company.get_value(); + if (me.$sibling_group) + me.$sibling_group.remove(); + + // setup sibling group wrapper + me.$sibling_group = $(`
    `); + me.page.main.append(me.$sibling_group); + if (me.$hierarchy) me.$hierarchy.remove(); @@ -541,6 +548,12 @@ class OrgChartMobile { this.set_selected_node(node); this.show_active_path(node); + if (this.$sibling_group) { + const sibling_parent = this.$sibling_group.find('.node-group').attr('data-parent'); + if (node.parent_id !== sibling_parent) + this.$sibling_group.empty(); + } + if (node.expandable && !node.expanded) { return this.load_children(node); } @@ -719,8 +732,6 @@ class OrgChartMobile { if (collapsed) $node_group.addClass('collapsed'); - else - $node_group.addClass('mb-4'); return $node_group; } @@ -746,13 +757,15 @@ class OrgChartMobile { () => this.get_child_nodes(node_object.parent_id, node_object.id), (child_nodes) => this.get_node_group(child_nodes, node_object.parent_id, false), (node_group) => { - this.$hierarchy.empty().append(node_group) }, + if (node_group) + this.$sibling_group.empty().append(node_group); + }, () => this.setup_node_group_action(), () => { - this.$hierarchy.append(` + this.$hierarchy.empty().append(`
  • `); - this.$hierarchy.append(node); + this.$hierarchy.find('.level').append(node); this.expand_node(node_object); } ]); diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/organizational_chart.scss index b6d50a0470..6012c01573 100644 --- a/erpnext/public/scss/organizational_chart.scss +++ b/erpnext/public/scss/organizational_chart.scss @@ -215,7 +215,7 @@ display: flex; flex-direction: column; align-items: center; - padding-top: 30px; + padding-top: 10px; padding-left: 0px; } @@ -250,6 +250,7 @@ .collapsed-level { margin-bottom: 16px; + width: 18rem; } .node-group { @@ -281,4 +282,10 @@ .node-group.collapsed { width: 5rem; margin-left: 12px; +} + +.sibling-group { + display: flex; + flex-direction: column; + align-items: center; } \ No newline at end of file From f5314293c6215841e87dab7462fc44d3c777804d Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 17:48:44 +0530 Subject: [PATCH 05/44] feat: connectors for mobile node cards --- .../organizational_chart.js | 138 ++++++++++++++++++ erpnext/public/scss/organizational_chart.scss | 1 + 2 files changed, 139 insertions(+) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index f693cf6ba6..15334bd4ca 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -486,6 +486,9 @@ class OrgChartMobile { if (company.get_value() && me.company != company.get_value()) { me.company = company.get_value(); + // svg for connectors + me.make_svg_markers() + if (me.$sibling_group) me.$sibling_group.remove(); @@ -512,6 +515,31 @@ class OrgChartMobile { $(`[data-fieldname="company"]`).trigger('change'); } + make_svg_markers() { + $('#arrows').remove(); + + this.page.main.prepend(` + + + + + + + + + + + + + + + + + + + `); + } + render_root_node() { this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; @@ -554,6 +582,14 @@ class OrgChartMobile { this.$sibling_group.empty(); } + // since the previous/parent node collapses, all connections to that node need to be rebuilt + // rebuild outgoing connections of parent + this.refresh_connectors(node.parent_id, node.id); + + // rebuild incoming connections of parent + let grandparent = $(`#${node.parent_id}`).attr('data-parent'); + this.refresh_connectors(grandparent, node.parent_id); + if (node.expandable && !node.expanded) { return this.load_children(node); } @@ -629,6 +665,10 @@ class OrgChartMobile { $.each(child_nodes, (_i, data) => { this.add_node(node, data); $(`#${data.name}`).addClass('active-child'); + + setTimeout(() => { + this.add_connector(node.id, data.name); + }, 250); }); } } @@ -653,6 +693,83 @@ class OrgChartMobile { }); } + add_connector(parent_id, child_id) { + let parent_node = document.querySelector(`#${parent_id}`); + let child_node = document.querySelector(`#${child_id}`); + + // variable for the namespace + const svgns = 'http://www.w3.org/2000/svg'; + let path = document.createElementNS(svgns, 'path'); + + let connector = undefined; + + if ($(`#${parent_id}`).hasClass('active')) { + connector = this.get_connector_for_active_node(parent_node, child_node); + } else if ($(`#${parent_id}`).hasClass('active-path')) { + connector = this.get_connector_for_collapsed_node(parent_node, child_node); + } + + path.setAttribute("d", connector); + this.set_path_attributes(path, parent_id, child_id); + + $('#connectors').append(path); + } + + get_connector_for_active_node(parent_node, child_node) { + // we need to connect the bottom left of the parent to the left side of the child node + let pos_parent_bottom = { + x: parent_node.offsetLeft + 20, + y: parent_node.offsetTop + parent_node.offsetHeight + }; + let pos_child_left = { + x: child_node.offsetLeft - 5, + y: child_node.offsetTop + child_node.offsetHeight / 2 + }; + + let connector = + "M" + + (pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " + + "L" + + (pos_parent_bottom.x) + "," + (pos_child_left.y) + " " + + "L" + + (pos_child_left.x) + "," + (pos_child_left.y); + + return connector; + } + + get_connector_for_collapsed_node(parent_node, child_node) { + // we need to connect the bottom left of the parent to the top left of the child node + let pos_parent_bottom = { + x: parent_node.offsetLeft + 20, + y: parent_node.offsetTop + parent_node.offsetHeight + }; + let pos_child_top = { + x: child_node.offsetLeft + 20, + y: child_node.offsetTop + }; + + let connector = + "M" + + (pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " + + "L" + + (pos_child_top.x) + "," + (pos_child_top.y); + + return connector; + } + + set_path_attributes(path, parent_id, child_id) { + path.setAttribute("data-parent", parent_id); + path.setAttribute("data-child", child_id); + + if ($(`#${parent_id}`).hasClass('active')) { + path.setAttribute("class", "active-connector"); + path.setAttribute("marker-start", "url(#arrowstart-active)"); + path.setAttribute("marker-end", "url(#arrowhead-active)"); + } else if ($(`#${parent_id}`).hasClass('active-path')) { + path.setAttribute("class", "collapsed-connector"); + } + } + set_selected_node(node) { // remove .active class from the current node $('.active').removeClass('active'); @@ -669,6 +786,7 @@ class OrgChartMobile { node_element.click(function() { if (node_element.is(':visible') && node_element.hasClass('active-path')) { me.remove_levels_after_node(node); + me.remove_orphaned_connectors(); } else { me.add_node_to_hierarchy(node, true); me.collapse_node(); @@ -786,4 +904,24 @@ class OrgChartMobile { level.empty().append(current_node); } + + remove_orphaned_connectors() { + let paths = $('#connectors > path'); + $.each(paths, (_i, path) => { + let parent = $(path).data('parent'); + let child = $(path).data('child'); + + if ($(parent).length || $(child).length) + return; + + $(path).remove(); + }) + } + + refresh_connectors(node_parent, node_id) { + if (!node_parent) return; + + $(`path[data-parent="${node_parent}"]`).remove(); + this.add_connector(node_parent, node_id); + } } \ No newline at end of file diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/organizational_chart.scss index 6012c01573..16b8792432 100644 --- a/erpnext/public/scss/organizational_chart.scss +++ b/erpnext/public/scss/organizational_chart.scss @@ -199,6 +199,7 @@ #arrows { position: absolute; + overflow: visible; } .active-connector { From b7c61ff6510b4c6afcda7f315388c61068c13765 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 18:21:42 +0530 Subject: [PATCH 06/44] fix: don't refresh connections for same node - remove all connectors while expanding a group node --- .../organizational_chart/organizational_chart.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index 15334bd4ca..efb367ad44 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -573,6 +573,7 @@ class OrgChartMobile { } expand_node(node) { + const is_same_node = (this.selected_node && this.selected_node.id === node.id); this.set_selected_node(node); this.show_active_path(node); @@ -582,13 +583,15 @@ class OrgChartMobile { this.$sibling_group.empty(); } - // since the previous/parent node collapses, all connections to that node need to be rebuilt - // rebuild outgoing connections of parent - this.refresh_connectors(node.parent_id, node.id); + if (!is_same_node) { + // since the previous/parent node collapses, all connections to that node need to be rebuilt + // rebuild outgoing connections of parent + this.refresh_connectors(node.parent_id, node.id); - // rebuild incoming connections of parent - let grandparent = $(`#${node.parent_id}`).attr('data-parent'); - this.refresh_connectors(grandparent, node.parent_id); + // rebuild incoming connections of parent + let grandparent = $(`#${node.parent_id}`).attr('data-parent'); + this.refresh_connectors(grandparent, node.parent_id); + } if (node.expandable && !node.expanded) { return this.load_children(node); @@ -884,6 +887,7 @@ class OrgChartMobile {
  • `); this.$hierarchy.find('.level').append(node); + $(`#connectors`).empty(); this.expand_node(node_object); } ]); From bcc998e8c236766f4a8eadd5f0080050dfc7f160 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 19:15:08 +0530 Subject: [PATCH 07/44] chore: create separate files for Desktop and Mobile view and bundle assets --- .../organizational_chart.js | 930 +----------------- erpnext/public/build.json | 9 +- .../hierarchy_chart_desktop.js | 396 ++++++++ .../hierarchy_chart/hierarchy_chart_mobile.js | 513 ++++++++++ .../js/templates}/node_card.html | 0 ...tional_chart.scss => hierarchy_chart.scss} | 0 6 files changed, 925 insertions(+), 923 deletions(-) create mode 100644 erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js create mode 100644 erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js rename erpnext/{hr/page/organizational_chart => public/js/templates}/node_card.html (100%) rename erpnext/public/scss/{organizational_chart.scss => hierarchy_chart.scss} (100%) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index efb367ad44..0fe724c78e 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -5,927 +5,15 @@ frappe.pages['organizational-chart'].on_page_load = function(wrapper) { single_column: true }); - // let organizational_chart = undefined; - // if (frappe.is_mobile()) { - // organizational_chart = new OrgChartMobile(wrapper); - // } else { - // organizational_chart = new OrgChart(wrapper); - // } - - let organizational_chart = new OrgChartMobile(wrapper); - $(wrapper).bind('show', ()=> { - organizational_chart.show(); - }); -}; - -class OrgChart { - - constructor(wrapper) { - this.wrapper = $(wrapper); - this.page = wrapper.page; - - this.page.main.css({ - 'min-height': '300px', - 'max-height': '600px', - 'overflow': 'auto', - 'position': 'relative' - }); - this.page.main.addClass('frappe-card'); - - this.nodes = {}; - this.setup_node_class(); - } - - setup_node_class() { - let me = this; - this.Node = class { - constructor({ - id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line - }) { - // to setup values passed via constructor - $.extend(this, arguments[0]); - - this.expanded = 0; - - me.nodes[this.id] = this; - me.make_node_element(this); - me.setup_node_click_action(this); - } - } - } - - make_node_element(node) { - let node_card = frappe.render_template('node_card', { - id: node.id, - name: node.name, - title: node.title, - image: node.image, - parent: node.parent_id, - connections: node.connections - }); - - node.parent.append(node_card); - node.$link = $(`#${node.id}`); - } - - show() { - frappe.breadcrumbs.add('HR'); - - let me = this; - let company = this.page.add_field({ - fieldtype: 'Link', - options: 'Company', - fieldname: 'company', - placeholder: __('Select Company'), - default: frappe.defaults.get_default('company'), - only_select: true, - reqd: 1, - change: () => { - me.company = undefined; - - if (company.get_value() && me.company != company.get_value()) { - me.company = company.get_value(); - - // svg for connectors - me.make_svg_markers() - - if (me.$hierarchy) - me.$hierarchy.remove(); - - // setup hierarchy - me.$hierarchy = $( - `
      -
    • -
    `); - - me.page.main.append(me.$hierarchy); - me.render_root_node(); - } - } - }); - - company.refresh(); - $(`[data-fieldname="company"]`).trigger('change'); - } - - make_svg_markers() { - $('#arrows').remove(); - - this.page.main.prepend(` - - - - - - - - - - - - - - - - - - - `); - } - - render_root_node() { - this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; - - let me = this; - - frappe.call({ - method: me.method, - args: { - company: me.company - }, - callback: function(r) { - if (r.message.length) { - let data = r.message[0]; - - let root_node = new me.Node({ - id: data.name, - parent: me.$hierarchy.find('.root-level'), - parent_id: undefined, - image: data.image, - name: data.employee_name, - title: data.designation, - expandable: true, - connections: data.connections, - is_root: true, - }); - - me.expand_node(root_node); - } - } - }) - } - - expand_node(node) { - let is_sibling = this.selected_node && this.selected_node.parent_id === node.parent_id; - this.set_selected_node(node); - this.show_active_path(node); - this.collapse_previous_level_nodes(node); - - // since the previous node collapses, all connections to that node need to be rebuilt - // if a sibling node is clicked, connections don't need to be rebuilt - if (!is_sibling) { - // rebuild outgoing connections - this.refresh_connectors(node.parent_id); - - // rebuild incoming connections - let grandparent = $(`#${node.parent_id}`).attr('data-parent'); - this.refresh_connectors(grandparent) - } - - if (node.expandable && !node.expanded) { - return this.load_children(node); - } - } - - collapse_node() { - if (this.selected_node.expandable) { - this.selected_node.$children.hide(); - $(`path[data-parent="${this.selected_node.id}"]`).hide(); - this.selected_node.expanded = false; - } - } - - show_active_path(node) { - // mark node parent on active path - $(`#${node.parent_id}`).addClass('active-path'); - } - - load_children(node) { - frappe.run_serially([ - () => this.get_child_nodes(node.id), - (child_nodes) => this.render_child_nodes(node, child_nodes) - ]); - } - - get_child_nodes(node_id) { - let me = this; - return new Promise(resolve => { - frappe.call({ - method: this.method, - args: { - parent: node_id, - company: me.company - }, - callback: (r) => { - resolve(r.message); - } - }); - }); - } - - render_child_nodes(node, child_nodes) { - const last_level = this.$hierarchy.find('.level:last').index(); - const current_level = $(`#${node.id}`).parent().parent().parent().index(); - - if (last_level === current_level) { - this.$hierarchy.append(` -
  • - `); - } - - if (!node.$children) { - node.$children = $('
      ') - .hide() - .appendTo(this.$hierarchy.find('.level:last')); - - node.$children.empty(); - - if (child_nodes) { - $.each(child_nodes, (_i, data) => { - this.add_node(node, data); - - setTimeout(() => { - this.add_connector(node.id, data.name); - }, 250); - }); - } - } - - node.$children.show(); - $(`path[data-parent="${node.id}"]`).show(); - node.expanded = true; - } - - add_node(node, data) { - var $li = $('
    • '); - - return new this.Node({ - id: data.name, - parent: $li.appendTo(node.$children), - parent_id: node.id, - image: data.image, - name: data.employee_name, - title: data.designation, - expandable: data.expandable, - connections: data.connections, - children: undefined - }); - } - - add_connector(parent_id, child_id) { - let parent_node = document.querySelector(`#${parent_id}`); - let child_node = document.querySelector(`#${child_id}`); - - // variable for the namespace - const svgns = 'http://www.w3.org/2000/svg'; - let path = document.createElementNS(svgns, 'path'); - - // we need to connect right side of the parent to the left side of the child node - let pos_parent_right = { - x: parent_node.offsetLeft + parent_node.offsetWidth, - y: parent_node.offsetTop + parent_node.offsetHeight / 2 - }; - let pos_child_left = { - x: child_node.offsetLeft - 5, - y: child_node.offsetTop + child_node.offsetHeight / 2 - }; - - let connector = - "M" + - (pos_parent_right.x) + "," + (pos_parent_right.y) + " " + - "C" + - (pos_parent_right.x + 100) + "," + (pos_parent_right.y) + " " + - (pos_child_left.x - 100) + "," + (pos_child_left.y) + " " + - (pos_child_left.x) + "," + (pos_child_left.y); - - path.setAttribute("d", connector); - path.setAttribute("data-parent", parent_id); - path.setAttribute("data-child", child_id); - - if ($(`#${parent_id}`).hasClass('active')) { - path.setAttribute("class", "active-connector"); - path.setAttribute("marker-start", "url(#arrowstart-active)"); - path.setAttribute("marker-end", "url(#arrowhead-active)"); - } else if ($(`#${parent_id}`).hasClass('active-path')) { - path.setAttribute("class", "collapsed-connector"); - path.setAttribute("marker-start", "url(#arrowstart-collapsed)"); - path.setAttribute("marker-end", "url(#arrowhead-collapsed)"); - } - - $('#connectors').append(path); - } - - set_selected_node(node) { - // remove .active class from the current node - $('.active').removeClass('active'); - - // add active class to the newly selected node - this.selected_node = node; - node.$link.addClass('active'); - } - - collapse_previous_level_nodes(node) { - let node_parent = $(`#${node.parent_id}`); - - let previous_level_nodes = node_parent.parent().parent().children('li'); - if (node_parent.parent().hasClass('root-level')) { - previous_level_nodes = node_parent.parent().children('li'); - } - - let node_card = undefined; - - previous_level_nodes.each(function() { - node_card = $(this).find('.node-card'); - - if (!node_card.hasClass('active-path')) { - node_card.addClass('collapsed'); - } - }); - } - - refresh_connectors(node_parent) { - if (!node_parent) return; - - $(`path[data-parent="${node_parent}"]`).remove(); - - frappe.run_serially([ - () => this.get_child_nodes(node_parent), - (child_nodes) => { - if (child_nodes) { - $.each(child_nodes, (_i, data) => { - this.add_connector(node_parent, data.name); - }); - } - } - ]); - } - - setup_node_click_action(node) { - let me = this; - let node_element = $(`#${node.id}`); - - node_element.click(function() { - let is_sibling = me.selected_node.parent_id === node.parent_id; - - if (is_sibling) { - me.collapse_node(); - } else if (node_element.is(':visible') - && (node_element.hasClass('collapsed') || node_element.hasClass('active-path'))) { - me.remove_levels_after_node(node); - me.remove_orphaned_connectors(); - } - - me.expand_node(node); - }); - } - - remove_levels_after_node(node) { - let level = $(`#${node.id}`).parent().parent().parent(); - - if ($(`#${node.id}`).parent().hasClass('root-level')) { - level = $(`#${node.id}`).parent(); - } - - level = $('.hierarchy > li:eq('+ level.index() + ')'); - level.nextAll('li').remove(); - - let nodes = level.find('.node-card'); - let node_object = undefined; - - $.each(nodes, (_i, element) => { - node_object = this.nodes[element.id]; - node_object.expanded = 0; - node_object.$children = undefined; - }); - - nodes.removeClass('collapsed active-path'); - } - - remove_orphaned_connectors() { - let paths = $('#connectors > path'); - $.each(paths, (_i, path) => { - let parent = $(path).data('parent'); - let child = $(path).data('child'); - - if ($(parent).length || $(child).length) - return; - - $(path).remove(); - }) - } -} - - -class OrgChartMobile { - - constructor(wrapper) { - this.wrapper = $(wrapper); - this.page = wrapper.page; - - this.page.main.css({ - 'min-height': '300px', - 'max-height': '600px', - 'overflow': 'auto', - 'position': 'relative' - }); - this.page.main.addClass('frappe-card'); - - this.nodes = {}; - this.setup_node_class(); - } - - setup_node_class() { - let me = this; - this.Node = class { - constructor({ - id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line - }) { - // to setup values passed via constructor - $.extend(this, arguments[0]); - - this.expanded = 0; - - me.nodes[this.id] = this; - me.make_node_element(this); - me.setup_node_click_action(this); - } - } - } - - make_node_element(node) { - let node_card = frappe.render_template('node_card', { - id: node.id, - name: node.name, - title: node.title, - image: node.image, - parent: node.parent_id, - connections: node.connections, - is_mobile: 1 - }); - - node.parent.append(node_card); - node.$link = $(`#${node.id}`); - node.$link.addClass('mobile-node'); - } - - show() { - frappe.breadcrumbs.add('HR'); - - let me = this; - let company = this.page.add_field({ - fieldtype: 'Link', - options: 'Company', - fieldname: 'company', - placeholder: __('Select Company'), - default: frappe.defaults.get_default('company'), - only_select: true, - reqd: 1, - change: () => { - me.company = undefined; - - if (company.get_value() && me.company != company.get_value()) { - me.company = company.get_value(); - - // svg for connectors - me.make_svg_markers() - - if (me.$sibling_group) - me.$sibling_group.remove(); - - // setup sibling group wrapper - me.$sibling_group = $(`
      `); - me.page.main.append(me.$sibling_group); - - if (me.$hierarchy) - me.$hierarchy.remove(); - - // setup hierarchy - me.$hierarchy = $( - `
        -
      • -
      `); - - me.page.main.append(me.$hierarchy); - me.render_root_node(); - } - } - }); - - company.refresh(); - $(`[data-fieldname="company"]`).trigger('change'); - } - - make_svg_markers() { - $('#arrows').remove(); - - this.page.main.prepend(` - - - - - - - - - - - - - - - - - - - `); - } - - render_root_node() { - this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; - - let me = this; - - frappe.call({ - method: me.method, - args: { - company: me.company - }, - callback: function(r) { - if (r.message.length) { - let data = r.message[0]; - - let root_node = new me.Node({ - id: data.name, - parent: me.$hierarchy.find('.root-level'), - parent_id: undefined, - image: data.image, - name: data.employee_name, - title: data.designation, - expandable: true, - connections: data.connections, - is_root: true, - }); - - me.expand_node(root_node); - } - } - }) - } - - expand_node(node) { - const is_same_node = (this.selected_node && this.selected_node.id === node.id); - this.set_selected_node(node); - this.show_active_path(node); - - if (this.$sibling_group) { - const sibling_parent = this.$sibling_group.find('.node-group').attr('data-parent'); - if (node.parent_id !== sibling_parent) - this.$sibling_group.empty(); - } - - if (!is_same_node) { - // since the previous/parent node collapses, all connections to that node need to be rebuilt - // rebuild outgoing connections of parent - this.refresh_connectors(node.parent_id, node.id); - - // rebuild incoming connections of parent - let grandparent = $(`#${node.parent_id}`).attr('data-parent'); - this.refresh_connectors(grandparent, node.parent_id); - } - - if (node.expandable && !node.expanded) { - return this.load_children(node); - } - } - - collapse_node() { - let node = this.selected_node; - if (node.expandable) { - node.$children.hide(); - node.expanded = false; - - // add a collapsed level to show the collapsed parent - // and a button beside it to move to that level - let node_parent = node.$link.parent(); - node_parent.prepend( - `
      ` - ); - - node_parent - .find('.collapsed-level') - .append(node.$link); - - frappe.run_serially([ - () => this.get_child_nodes(node.parent_id, node.id), - (child_nodes) => this.get_node_group(child_nodes, node.parent_id), - (node_group) => { - node_parent.find('.collapsed-level') - .append(node_group); - }, - () => this.setup_node_group_action() - ]); - } - } - - show_active_path(node) { - // mark node parent on active path - $(`#${node.parent_id}`).addClass('active-path'); - } - - load_children(node) { - frappe.run_serially([ - () => this.get_child_nodes(node.id), - (child_nodes) => this.render_child_nodes(node, child_nodes) - ]); - } - - get_child_nodes(node_id, exclude_node=null) { - let me = this; - return new Promise(resolve => { - frappe.call({ - method: this.method, - args: { - parent: node_id, - company: me.company, - exclude_node: exclude_node - }, - callback: (r) => { - resolve(r.message); - } - }); - }); - } - - render_child_nodes(node, child_nodes) { - if (!node.$children) { - node.$children = $('
        ') - .hide() - .appendTo(node.$link.parent()); - - node.$children.empty(); - - if (child_nodes) { - $.each(child_nodes, (_i, data) => { - this.add_node(node, data); - $(`#${data.name}`).addClass('active-child'); - - setTimeout(() => { - this.add_connector(node.id, data.name); - }, 250); - }); - } - } - - node.$children.show(); - node.expanded = true; - } - - add_node(node, data) { - var $li = $('
      • '); - - return new this.Node({ - id: data.name, - parent: $li.appendTo(node.$children), - parent_id: node.id, - image: data.image, - name: data.employee_name, - title: data.designation, - expandable: data.expandable, - connections: data.connections, - children: undefined - }); - } - - add_connector(parent_id, child_id) { - let parent_node = document.querySelector(`#${parent_id}`); - let child_node = document.querySelector(`#${child_id}`); - - // variable for the namespace - const svgns = 'http://www.w3.org/2000/svg'; - let path = document.createElementNS(svgns, 'path'); - - let connector = undefined; - - if ($(`#${parent_id}`).hasClass('active')) { - connector = this.get_connector_for_active_node(parent_node, child_node); - } else if ($(`#${parent_id}`).hasClass('active-path')) { - connector = this.get_connector_for_collapsed_node(parent_node, child_node); - } - - path.setAttribute("d", connector); - this.set_path_attributes(path, parent_id, child_id); - - $('#connectors').append(path); - } - - get_connector_for_active_node(parent_node, child_node) { - // we need to connect the bottom left of the parent to the left side of the child node - let pos_parent_bottom = { - x: parent_node.offsetLeft + 20, - y: parent_node.offsetTop + parent_node.offsetHeight - }; - let pos_child_left = { - x: child_node.offsetLeft - 5, - y: child_node.offsetTop + child_node.offsetHeight / 2 - }; - - let connector = - "M" + - (pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " + - "L" + - (pos_parent_bottom.x) + "," + (pos_child_left.y) + " " + - "L" + - (pos_child_left.x) + "," + (pos_child_left.y); - - return connector; - } - - get_connector_for_collapsed_node(parent_node, child_node) { - // we need to connect the bottom left of the parent to the top left of the child node - let pos_parent_bottom = { - x: parent_node.offsetLeft + 20, - y: parent_node.offsetTop + parent_node.offsetHeight - }; - let pos_child_top = { - x: child_node.offsetLeft + 20, - y: child_node.offsetTop - }; - - let connector = - "M" + - (pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " + - "L" + - (pos_child_top.x) + "," + (pos_child_top.y); - - return connector; - } - - set_path_attributes(path, parent_id, child_id) { - path.setAttribute("data-parent", parent_id); - path.setAttribute("data-child", child_id); - - if ($(`#${parent_id}`).hasClass('active')) { - path.setAttribute("class", "active-connector"); - path.setAttribute("marker-start", "url(#arrowstart-active)"); - path.setAttribute("marker-end", "url(#arrowhead-active)"); - } else if ($(`#${parent_id}`).hasClass('active-path')) { - path.setAttribute("class", "collapsed-connector"); - } - } - - set_selected_node(node) { - // remove .active class from the current node - $('.active').removeClass('active'); - - // add active class to the newly selected node - this.selected_node = node; - node.$link.addClass('active'); - } - - setup_node_click_action(node) { - let me = this; - let node_element = $(`#${node.id}`); - - node_element.click(function() { - if (node_element.is(':visible') && node_element.hasClass('active-path')) { - me.remove_levels_after_node(node); - me.remove_orphaned_connectors(); + $(wrapper).bind('show', () => { + frappe.require('/assets/js/hierarchy-chart.min.js', () => { + let organizational_chart = undefined; + if (frappe.is_mobile()) { + organizational_chart = new erpnext.HierarchyChartMobile(wrapper); } else { - me.add_node_to_hierarchy(node, true); - me.collapse_node(); + organizational_chart = new erpnext.HierarchyChart(wrapper); } - - me.expand_node(node); + organizational_chart.show(); }); - } - - setup_node_group_action() { - let me = this; - - $('.node-group').on('click', function() { - let parent = $(this).attr('data-parent'); - me.expand_sibling_group_node(parent); - }); - } - - add_node_to_hierarchy(node) { - this.$hierarchy.append(` -
      • -
        -
        -
      • - `); - - node.$link.appendTo(this.$hierarchy.find('.level:last')); - } - - get_node_group(nodes, parent, collapsed=true) { - let limit = 2; - const display_nodes = nodes.slice(0, limit); - const extra_nodes = nodes.slice(limit); - - let html = display_nodes.map(node => - this.get_avatar(node) - ).join(''); - - if (extra_nodes.length === 1) { - let node = extra_nodes[0]; - html += this.get_avatar(node); - } else if (extra_nodes.length > 1) { - html = ` - ${html} - -
        - +${extra_nodes.length} -
        -
        - `; - } - - if (html) { - const $node_group = - $(`
        -
        - ${html} -
        -
        `); - - if (collapsed) - $node_group.addClass('collapsed'); - - return $node_group; - } - - return null; - } - - get_avatar(node) { - return ` - - ` - } - - expand_sibling_group_node(parent) { - let node_object = this.nodes[parent]; - let node = node_object.$link; - node.removeClass('active-child active-path'); - node_object.expanded = 0; - node_object.$children = undefined; - - // show parent's siblings and expand parent node - frappe.run_serially([ - () => this.get_child_nodes(node_object.parent_id, node_object.id), - (child_nodes) => this.get_node_group(child_nodes, node_object.parent_id, false), - (node_group) => { - if (node_group) - this.$sibling_group.empty().append(node_group); - }, - () => this.setup_node_group_action(), - () => { - this.$hierarchy.empty().append(` -
      • - `); - this.$hierarchy.find('.level').append(node); - $(`#connectors`).empty(); - this.expand_node(node_object); - } - ]); - } - - remove_levels_after_node(node) { - let level = $(`#${node.id}`).parent().parent(); - - level = $('.hierarchy-mobile > li:eq('+ (level.index()) + ')'); - level.nextAll('li').remove(); - - let current_node = level.find(`#${node.id}`); - let node_object = this.nodes[node.id]; - - current_node.removeClass('active-child active-path'); - node_object.expanded = 0; - node_object.$children = undefined; - - level.empty().append(current_node); - } - - remove_orphaned_connectors() { - let paths = $('#connectors > path'); - $.each(paths, (_i, path) => { - let parent = $(path).data('parent'); - let child = $(path).data('child'); - - if ($(parent).length || $(child).length) - return; - - $(path).remove(); - }) - } - - refresh_connectors(node_parent, node_id) { - if (!node_parent) return; - - $(`path[data-parent="${node_parent}"]`).remove(); - this.add_connector(node_parent, node_id); - } -} \ No newline at end of file + }); +}; \ No newline at end of file diff --git a/erpnext/public/build.json b/erpnext/public/build.json index d3ebcdf7e7..3c60e3ee50 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -4,7 +4,7 @@ "public/less/hub.less", "public/scss/call_popup.scss", "public/scss/point-of-sale.scss", - "public/scss/organizational_chart.scss" + "public/scss/hierarchy_chart.scss" ], "css/marketplace.css": [ "public/less/hub.less" @@ -44,7 +44,8 @@ "public/js/call_popup/call_popup.js", "public/js/utils/dimension_tree_filter.js", "public/js/telephony.js", - "public/js/templates/call_link.html" + "public/js/templates/call_link.html", + "public/js/templates/node_card.html" ], "js/item-dashboard.min.js": [ "stock/dashboard/item_dashboard.html", @@ -67,5 +68,9 @@ "public/js/bank_reconciliation_tool/data_table_manager.js", "public/js/bank_reconciliation_tool/number_card.js", "public/js/bank_reconciliation_tool/dialog_manager.js" + ], + "js/hierarchy-chart.min.js": [ + "public/js/hierarchy_chart/hierarchy_chart_desktop.js", + "public/js/hierarchy_chart/hierarchy_chart_mobile.js" ] } diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js new file mode 100644 index 0000000000..fd84d4ea5c --- /dev/null +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -0,0 +1,396 @@ +erpnext.HierarchyChart = class { + + constructor(wrapper) { + this.wrapper = $(wrapper); + this.page = wrapper.page; + + this.page.main.css({ + 'min-height': '300px', + 'max-height': '600px', + 'overflow': 'auto', + 'position': 'relative' + }); + this.page.main.addClass('frappe-card'); + + this.nodes = {}; + this.setup_node_class(); + } + + setup_node_class() { + let me = this; + this.Node = class { + constructor({ + id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line + }) { + // to setup values passed via constructor + $.extend(this, arguments[0]); + + this.expanded = 0; + + me.nodes[this.id] = this; + me.make_node_element(this); + me.setup_node_click_action(this); + } + } + } + + make_node_element(node) { + let node_card = frappe.render_template('node_card', { + id: node.id, + name: node.name, + title: node.title, + image: node.image, + parent: node.parent_id, + connections: node.connections + }); + + node.parent.append(node_card); + node.$link = $(`#${node.id}`); + } + + show() { + frappe.breadcrumbs.add('HR'); + + let me = this; + let company = this.page.add_field({ + fieldtype: 'Link', + options: 'Company', + fieldname: 'company', + placeholder: __('Select Company'), + default: frappe.defaults.get_default('company'), + only_select: true, + reqd: 1, + change: () => { + me.company = undefined; + + if (company.get_value() && me.company != company.get_value()) { + me.company = company.get_value(); + + // svg for connectors + me.make_svg_markers() + + if (me.$hierarchy) + me.$hierarchy.remove(); + + // setup hierarchy + me.$hierarchy = $( + `
          +
        • +
        `); + + me.page.main.append(me.$hierarchy); + me.render_root_node(); + } + } + }); + + company.refresh(); + $(`[data-fieldname="company"]`).trigger('change'); + } + + make_svg_markers() { + $('#arrows').remove(); + + this.page.main.prepend(` + + + + + + + + + + + + + + + + + + + `); + } + + render_root_node() { + this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; + + let me = this; + + frappe.call({ + method: me.method, + args: { + company: me.company + }, + callback: function(r) { + if (r.message.length) { + let data = r.message[0]; + + let root_node = new me.Node({ + id: data.name, + parent: me.$hierarchy.find('.root-level'), + parent_id: undefined, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: true, + connections: data.connections, + is_root: true, + }); + + me.expand_node(root_node); + } + } + }) + } + + expand_node(node) { + let is_sibling = this.selected_node && this.selected_node.parent_id === node.parent_id; + this.set_selected_node(node); + this.show_active_path(node); + this.collapse_previous_level_nodes(node); + + // since the previous node collapses, all connections to that node need to be rebuilt + // if a sibling node is clicked, connections don't need to be rebuilt + if (!is_sibling) { + // rebuild outgoing connections + this.refresh_connectors(node.parent_id); + + // rebuild incoming connections + let grandparent = $(`#${node.parent_id}`).attr('data-parent'); + this.refresh_connectors(grandparent) + } + + if (node.expandable && !node.expanded) { + return this.load_children(node); + } + } + + collapse_node() { + if (this.selected_node.expandable) { + this.selected_node.$children.hide(); + $(`path[data-parent="${this.selected_node.id}"]`).hide(); + this.selected_node.expanded = false; + } + } + + show_active_path(node) { + // mark node parent on active path + $(`#${node.parent_id}`).addClass('active-path'); + } + + load_children(node) { + frappe.run_serially([ + () => this.get_child_nodes(node.id), + (child_nodes) => this.render_child_nodes(node, child_nodes) + ]); + } + + get_child_nodes(node_id) { + let me = this; + return new Promise(resolve => { + frappe.call({ + method: this.method, + args: { + parent: node_id, + company: me.company + }, + callback: (r) => { + resolve(r.message); + } + }); + }); + } + + render_child_nodes(node, child_nodes) { + const last_level = this.$hierarchy.find('.level:last').index(); + const current_level = $(`#${node.id}`).parent().parent().parent().index(); + + if (last_level === current_level) { + this.$hierarchy.append(` +
      • + `); + } + + if (!node.$children) { + node.$children = $('
          ') + .hide() + .appendTo(this.$hierarchy.find('.level:last')); + + node.$children.empty(); + + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_node(node, data); + + setTimeout(() => { + this.add_connector(node.id, data.name); + }, 250); + }); + } + } + + node.$children.show(); + $(`path[data-parent="${node.id}"]`).show(); + node.expanded = true; + } + + add_node(node, data) { + var $li = $('
        • '); + + return new this.Node({ + id: data.name, + parent: $li.appendTo(node.$children), + parent_id: node.id, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: data.expandable, + connections: data.connections, + children: undefined + }); + } + + add_connector(parent_id, child_id) { + let parent_node = document.querySelector(`#${parent_id}`); + let child_node = document.querySelector(`#${child_id}`); + + // variable for the namespace + const svgns = 'http://www.w3.org/2000/svg'; + let path = document.createElementNS(svgns, 'path'); + + // we need to connect right side of the parent to the left side of the child node + let pos_parent_right = { + x: parent_node.offsetLeft + parent_node.offsetWidth, + y: parent_node.offsetTop + parent_node.offsetHeight / 2 + }; + let pos_child_left = { + x: child_node.offsetLeft - 5, + y: child_node.offsetTop + child_node.offsetHeight / 2 + }; + + let connector = + "M" + + (pos_parent_right.x) + "," + (pos_parent_right.y) + " " + + "C" + + (pos_parent_right.x + 100) + "," + (pos_parent_right.y) + " " + + (pos_child_left.x - 100) + "," + (pos_child_left.y) + " " + + (pos_child_left.x) + "," + (pos_child_left.y); + + path.setAttribute("d", connector); + path.setAttribute("data-parent", parent_id); + path.setAttribute("data-child", child_id); + + if ($(`#${parent_id}`).hasClass('active')) { + path.setAttribute("class", "active-connector"); + path.setAttribute("marker-start", "url(#arrowstart-active)"); + path.setAttribute("marker-end", "url(#arrowhead-active)"); + } else if ($(`#${parent_id}`).hasClass('active-path')) { + path.setAttribute("class", "collapsed-connector"); + path.setAttribute("marker-start", "url(#arrowstart-collapsed)"); + path.setAttribute("marker-end", "url(#arrowhead-collapsed)"); + } + + $('#connectors').append(path); + } + + set_selected_node(node) { + // remove .active class from the current node + $('.active').removeClass('active'); + + // add active class to the newly selected node + this.selected_node = node; + node.$link.addClass('active'); + } + + collapse_previous_level_nodes(node) { + let node_parent = $(`#${node.parent_id}`); + + let previous_level_nodes = node_parent.parent().parent().children('li'); + if (node_parent.parent().hasClass('root-level')) { + previous_level_nodes = node_parent.parent().children('li'); + } + + let node_card = undefined; + + previous_level_nodes.each(function() { + node_card = $(this).find('.node-card'); + + if (!node_card.hasClass('active-path')) { + node_card.addClass('collapsed'); + } + }); + } + + refresh_connectors(node_parent) { + if (!node_parent) return; + + $(`path[data-parent="${node_parent}"]`).remove(); + + frappe.run_serially([ + () => this.get_child_nodes(node_parent), + (child_nodes) => { + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_connector(node_parent, data.name); + }); + } + } + ]); + } + + setup_node_click_action(node) { + let me = this; + let node_element = $(`#${node.id}`); + + node_element.click(function() { + let is_sibling = me.selected_node.parent_id === node.parent_id; + + if (is_sibling) { + me.collapse_node(); + } else if (node_element.is(':visible') + && (node_element.hasClass('collapsed') || node_element.hasClass('active-path'))) { + me.remove_levels_after_node(node); + me.remove_orphaned_connectors(); + } + + me.expand_node(node); + }); + } + + remove_levels_after_node(node) { + let level = $(`#${node.id}`).parent().parent().parent(); + + if ($(`#${node.id}`).parent().hasClass('root-level')) { + level = $(`#${node.id}`).parent(); + } + + level = $('.hierarchy > li:eq('+ level.index() + ')'); + level.nextAll('li').remove(); + + let nodes = level.find('.node-card'); + let node_object = undefined; + + $.each(nodes, (_i, element) => { + node_object = this.nodes[element.id]; + node_object.expanded = 0; + node_object.$children = undefined; + }); + + nodes.removeClass('collapsed active-path'); + } + + remove_orphaned_connectors() { + let paths = $('#connectors > path'); + $.each(paths, (_i, path) => { + let parent = $(path).data('parent'); + let child = $(path).data('child'); + + if ($(parent).length || $(child).length) + return; + + $(path).remove(); + }) + } +} \ No newline at end of file diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js new file mode 100644 index 0000000000..c705681438 --- /dev/null +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -0,0 +1,513 @@ +erpnext.HierarchyChartMobile = class { + + constructor(wrapper) { + this.wrapper = $(wrapper); + this.page = wrapper.page; + + this.page.main.css({ + 'min-height': '300px', + 'max-height': '600px', + 'overflow': 'auto', + 'position': 'relative' + }); + this.page.main.addClass('frappe-card'); + + this.nodes = {}; + this.setup_node_class(); + } + + setup_node_class() { + let me = this; + this.Node = class { + constructor({ + id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line + }) { + // to setup values passed via constructor + $.extend(this, arguments[0]); + + this.expanded = 0; + + me.nodes[this.id] = this; + me.make_node_element(this); + me.setup_node_click_action(this); + } + } + } + + make_node_element(node) { + let node_card = frappe.render_template('node_card', { + id: node.id, + name: node.name, + title: node.title, + image: node.image, + parent: node.parent_id, + connections: node.connections, + is_mobile: 1 + }); + + node.parent.append(node_card); + node.$link = $(`#${node.id}`); + node.$link.addClass('mobile-node'); + } + + show() { + frappe.breadcrumbs.add('HR'); + + let me = this; + let company = this.page.add_field({ + fieldtype: 'Link', + options: 'Company', + fieldname: 'company', + placeholder: __('Select Company'), + default: frappe.defaults.get_default('company'), + only_select: true, + reqd: 1, + change: () => { + me.company = undefined; + + if (company.get_value() && me.company != company.get_value()) { + me.company = company.get_value(); + + // svg for connectors + me.make_svg_markers() + + if (me.$sibling_group) + me.$sibling_group.remove(); + + // setup sibling group wrapper + me.$sibling_group = $(`
          `); + me.page.main.append(me.$sibling_group); + + if (me.$hierarchy) + me.$hierarchy.remove(); + + // setup hierarchy + me.$hierarchy = $( + `
            +
          • +
          `); + + me.page.main.append(me.$hierarchy); + me.render_root_node(); + } + } + }); + + company.refresh(); + $(`[data-fieldname="company"]`).trigger('change'); + } + + make_svg_markers() { + $('#arrows').remove(); + + this.page.main.prepend(` + + + + + + + + + + + + + + + + + + + `); + } + + render_root_node() { + this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; + + let me = this; + + frappe.call({ + method: me.method, + args: { + company: me.company + }, + callback: function(r) { + if (r.message.length) { + let data = r.message[0]; + + let root_node = new me.Node({ + id: data.name, + parent: me.$hierarchy.find('.root-level'), + parent_id: undefined, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: true, + connections: data.connections, + is_root: true, + }); + + me.expand_node(root_node); + } + } + }) + } + + expand_node(node) { + const is_same_node = (this.selected_node && this.selected_node.id === node.id); + this.set_selected_node(node); + this.show_active_path(node); + + if (this.$sibling_group) { + const sibling_parent = this.$sibling_group.find('.node-group').attr('data-parent'); + if (node.parent_id !== sibling_parent) + this.$sibling_group.empty(); + } + + if (!is_same_node) { + // since the previous/parent node collapses, all connections to that node need to be rebuilt + // rebuild outgoing connections of parent + this.refresh_connectors(node.parent_id, node.id); + + // rebuild incoming connections of parent + let grandparent = $(`#${node.parent_id}`).attr('data-parent'); + this.refresh_connectors(grandparent, node.parent_id); + } + + if (node.expandable && !node.expanded) { + return this.load_children(node); + } + } + + collapse_node() { + let node = this.selected_node; + if (node.expandable) { + node.$children.hide(); + node.expanded = false; + + // add a collapsed level to show the collapsed parent + // and a button beside it to move to that level + let node_parent = node.$link.parent(); + node_parent.prepend( + `
          ` + ); + + node_parent + .find('.collapsed-level') + .append(node.$link); + + frappe.run_serially([ + () => this.get_child_nodes(node.parent_id, node.id), + (child_nodes) => this.get_node_group(child_nodes, node.parent_id), + (node_group) => { + node_parent.find('.collapsed-level') + .append(node_group); + }, + () => this.setup_node_group_action() + ]); + } + } + + show_active_path(node) { + // mark node parent on active path + $(`#${node.parent_id}`).addClass('active-path'); + } + + load_children(node) { + frappe.run_serially([ + () => this.get_child_nodes(node.id), + (child_nodes) => this.render_child_nodes(node, child_nodes) + ]); + } + + get_child_nodes(node_id, exclude_node=null) { + let me = this; + return new Promise(resolve => { + frappe.call({ + method: this.method, + args: { + parent: node_id, + company: me.company, + exclude_node: exclude_node + }, + callback: (r) => { + resolve(r.message); + } + }); + }); + } + + render_child_nodes(node, child_nodes) { + if (!node.$children) { + node.$children = $('
            ') + .hide() + .appendTo(node.$link.parent()); + + node.$children.empty(); + + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_node(node, data); + $(`#${data.name}`).addClass('active-child'); + + setTimeout(() => { + this.add_connector(node.id, data.name); + }, 250); + }); + } + } + + node.$children.show(); + node.expanded = true; + } + + add_node(node, data) { + var $li = $('
          • '); + + return new this.Node({ + id: data.name, + parent: $li.appendTo(node.$children), + parent_id: node.id, + image: data.image, + name: data.employee_name, + title: data.designation, + expandable: data.expandable, + connections: data.connections, + children: undefined + }); + } + + add_connector(parent_id, child_id) { + let parent_node = document.querySelector(`#${parent_id}`); + let child_node = document.querySelector(`#${child_id}`); + + // variable for the namespace + const svgns = 'http://www.w3.org/2000/svg'; + let path = document.createElementNS(svgns, 'path'); + + let connector = undefined; + + if ($(`#${parent_id}`).hasClass('active')) { + connector = this.get_connector_for_active_node(parent_node, child_node); + } else if ($(`#${parent_id}`).hasClass('active-path')) { + connector = this.get_connector_for_collapsed_node(parent_node, child_node); + } + + path.setAttribute("d", connector); + this.set_path_attributes(path, parent_id, child_id); + + $('#connectors').append(path); + } + + get_connector_for_active_node(parent_node, child_node) { + // we need to connect the bottom left of the parent to the left side of the child node + let pos_parent_bottom = { + x: parent_node.offsetLeft + 20, + y: parent_node.offsetTop + parent_node.offsetHeight + }; + let pos_child_left = { + x: child_node.offsetLeft - 5, + y: child_node.offsetTop + child_node.offsetHeight / 2 + }; + + let connector = + "M" + + (pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " + + "L" + + (pos_parent_bottom.x) + "," + (pos_child_left.y) + " " + + "L" + + (pos_child_left.x) + "," + (pos_child_left.y); + + return connector; + } + + get_connector_for_collapsed_node(parent_node, child_node) { + // we need to connect the bottom left of the parent to the top left of the child node + let pos_parent_bottom = { + x: parent_node.offsetLeft + 20, + y: parent_node.offsetTop + parent_node.offsetHeight + }; + let pos_child_top = { + x: child_node.offsetLeft + 20, + y: child_node.offsetTop + }; + + let connector = + "M" + + (pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " + + "L" + + (pos_child_top.x) + "," + (pos_child_top.y); + + return connector; + } + + set_path_attributes(path, parent_id, child_id) { + path.setAttribute("data-parent", parent_id); + path.setAttribute("data-child", child_id); + + if ($(`#${parent_id}`).hasClass('active')) { + path.setAttribute("class", "active-connector"); + path.setAttribute("marker-start", "url(#arrowstart-active)"); + path.setAttribute("marker-end", "url(#arrowhead-active)"); + } else if ($(`#${parent_id}`).hasClass('active-path')) { + path.setAttribute("class", "collapsed-connector"); + } + } + + set_selected_node(node) { + // remove .active class from the current node + $('.active').removeClass('active'); + + // add active class to the newly selected node + this.selected_node = node; + node.$link.addClass('active'); + } + + setup_node_click_action(node) { + let me = this; + let node_element = $(`#${node.id}`); + + node_element.click(function() { + if (node_element.is(':visible') && node_element.hasClass('active-path')) { + me.remove_levels_after_node(node); + me.remove_orphaned_connectors(); + } else { + me.add_node_to_hierarchy(node, true); + me.collapse_node(); + } + + me.expand_node(node); + }); + } + + setup_node_group_action() { + let me = this; + + $('.node-group').on('click', function() { + let parent = $(this).attr('data-parent'); + me.expand_sibling_group_node(parent); + }); + } + + add_node_to_hierarchy(node) { + this.$hierarchy.append(` +
          • +
            +
            +
          • + `); + + node.$link.appendTo(this.$hierarchy.find('.level:last')); + } + + get_node_group(nodes, parent, collapsed=true) { + let limit = 2; + const display_nodes = nodes.slice(0, limit); + const extra_nodes = nodes.slice(limit); + + let html = display_nodes.map(node => + this.get_avatar(node) + ).join(''); + + if (extra_nodes.length === 1) { + let node = extra_nodes[0]; + html += this.get_avatar(node); + } else if (extra_nodes.length > 1) { + html = ` + ${html} + +
            + +${extra_nodes.length} +
            +
            + `; + } + + if (html) { + const $node_group = + $(`
            +
            + ${html} +
            +
            `); + + if (collapsed) + $node_group.addClass('collapsed'); + + return $node_group; + } + + return null; + } + + get_avatar(node) { + return ` + + ` + } + + expand_sibling_group_node(parent) { + let node_object = this.nodes[parent]; + let node = node_object.$link; + node.removeClass('active-child active-path'); + node_object.expanded = 0; + node_object.$children = undefined; + + // show parent's siblings and expand parent node + frappe.run_serially([ + () => this.get_child_nodes(node_object.parent_id, node_object.id), + (child_nodes) => this.get_node_group(child_nodes, node_object.parent_id, false), + (node_group) => { + if (node_group) + this.$sibling_group.empty().append(node_group); + }, + () => this.setup_node_group_action(), + () => { + this.$hierarchy.empty().append(` +
          • + `); + this.$hierarchy.find('.level').append(node); + $(`#connectors`).empty(); + this.expand_node(node_object); + } + ]); + } + + remove_levels_after_node(node) { + let level = $(`#${node.id}`).parent().parent(); + + level = $('.hierarchy-mobile > li:eq('+ (level.index()) + ')'); + level.nextAll('li').remove(); + + let current_node = level.find(`#${node.id}`); + let node_object = this.nodes[node.id]; + + current_node.removeClass('active-child active-path'); + node_object.expanded = 0; + node_object.$children = undefined; + + level.empty().append(current_node); + } + + remove_orphaned_connectors() { + let paths = $('#connectors > path'); + $.each(paths, (_i, path) => { + let parent = $(path).data('parent'); + let child = $(path).data('child'); + + if ($(parent).length || $(child).length) + return; + + $(path).remove(); + }) + } + + refresh_connectors(node_parent, node_id) { + if (!node_parent) return; + + $(`path[data-parent="${node_parent}"]`).remove(); + this.add_connector(node_parent, node_id); + } +} \ No newline at end of file diff --git a/erpnext/hr/page/organizational_chart/node_card.html b/erpnext/public/js/templates/node_card.html similarity index 100% rename from erpnext/hr/page/organizational_chart/node_card.html rename to erpnext/public/js/templates/node_card.html diff --git a/erpnext/public/scss/organizational_chart.scss b/erpnext/public/scss/hierarchy_chart.scss similarity index 100% rename from erpnext/public/scss/organizational_chart.scss rename to erpnext/public/scss/hierarchy_chart.scss From f15e8b7f5a50cc686e3139b4aee63726a01f6413 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 21:26:47 +0530 Subject: [PATCH 08/44] refactor: add options to chart - method to return the node data - wrapper for showing the hierarchy --- .../organizational_chart.js | 6 ++-- .../organizational_chart.py | 6 ++-- .../hierarchy_chart_desktop.js | 28 +++++++++------- .../hierarchy_chart/hierarchy_chart_mobile.js | 32 +++++++++++-------- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index 0fe724c78e..ca9855286c 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -8,10 +8,12 @@ frappe.pages['organizational-chart'].on_page_load = function(wrapper) { $(wrapper).bind('show', () => { frappe.require('/assets/js/hierarchy-chart.min.js', () => { let organizational_chart = undefined; + let method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; + if (frappe.is_mobile()) { - organizational_chart = new erpnext.HierarchyChartMobile(wrapper); + organizational_chart = new erpnext.HierarchyChartMobile(wrapper, method); } else { - organizational_chart = new erpnext.HierarchyChart(wrapper); + organizational_chart = new erpnext.HierarchyChart(wrapper, method); } organizational_chart.show(); }); diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py index ae91a919b2..f3aa13897d 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.py +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -9,7 +9,7 @@ def get_children(parent=None, company=None, exclude_node=None, is_root=False, is filters.append(['company', '=', company]) if not fields: - fields = ['employee_name', 'name', 'reports_to', 'image', 'designation'] + fields = ['employee_name as name', 'name as id', 'reports_to', 'image', 'designation as title'] if is_root: parent = '' @@ -27,9 +27,9 @@ def get_children(parent=None, company=None, exclude_node=None, is_root=False, is for employee in employees: is_expandable = frappe.get_all('Employee', filters=[ - ['reports_to', '=', employee.get('name')] + ['reports_to', '=', employee.get('id')] ]) - employee.connections = get_connections(employee.name) + employee.connections = get_connections(employee.id) employee.expandable = 1 if is_expandable else 0 return employees diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index fd84d4ea5c..052f140c13 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -1,8 +1,14 @@ erpnext.HierarchyChart = class { - - constructor(wrapper) { + /* Options: + - wrapper: wrapper for the hierarchy view + - method: + - to get the data for each node + - this method should return id, name, title, image, and connections for each node + */ + constructor(wrapper, method) { this.wrapper = $(wrapper); this.page = wrapper.page; + this.method = method; this.page.main.css({ 'min-height': '300px', @@ -114,8 +120,6 @@ erpnext.HierarchyChart = class { } render_root_node() { - this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; - let me = this; frappe.call({ @@ -128,12 +132,12 @@ erpnext.HierarchyChart = class { let data = r.message[0]; let root_node = new me.Node({ - id: data.name, + id: data.id, parent: me.$hierarchy.find('.root-level'), parent_id: undefined, image: data.image, - name: data.employee_name, - title: data.designation, + name: data.name, + title: data.title, expandable: true, connections: data.connections, is_root: true, @@ -225,7 +229,7 @@ erpnext.HierarchyChart = class { this.add_node(node, data); setTimeout(() => { - this.add_connector(node.id, data.name); + this.add_connector(node.id, data.id); }, 250); }); } @@ -240,12 +244,12 @@ erpnext.HierarchyChart = class { var $li = $('
          • '); return new this.Node({ - id: data.name, + id: data.id, parent: $li.appendTo(node.$children), parent_id: node.id, image: data.image, - name: data.employee_name, - title: data.designation, + name: data.name, + title: data.title, expandable: data.expandable, connections: data.connections, children: undefined @@ -333,7 +337,7 @@ erpnext.HierarchyChart = class { (child_nodes) => { if (child_nodes) { $.each(child_nodes, (_i, data) => { - this.add_connector(node_parent, data.name); + this.add_connector(node_parent, data.id); }); } } diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index c705681438..1b8bc2e8e0 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -1,8 +1,14 @@ erpnext.HierarchyChartMobile = class { - - constructor(wrapper) { + /* Options: + - wrapper: wrapper for the hierarchy view + - method: + - to get the data for each node + - this method should return id, name, title, image, and connections for each node + */ + constructor(wrapper, method) { this.wrapper = $(wrapper); this.page = wrapper.page; + this.method = method; this.page.main.css({ 'min-height': '300px', @@ -123,8 +129,6 @@ erpnext.HierarchyChartMobile = class { } render_root_node() { - this.method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; - let me = this; frappe.call({ @@ -137,12 +141,12 @@ erpnext.HierarchyChartMobile = class { let data = r.message[0]; let root_node = new me.Node({ - id: data.name, + id: data.id, parent: me.$hierarchy.find('.root-level'), parent_id: undefined, image: data.image, - name: data.employee_name, - title: data.designation, + name: data.name, + title: data.title, expandable: true, connections: data.connections, is_root: true, @@ -249,10 +253,10 @@ erpnext.HierarchyChartMobile = class { if (child_nodes) { $.each(child_nodes, (_i, data) => { this.add_node(node, data); - $(`#${data.name}`).addClass('active-child'); + $(`#${data.id}`).addClass('active-child'); setTimeout(() => { - this.add_connector(node.id, data.name); + this.add_connector(node.id, data.id); }, 250); }); } @@ -266,12 +270,12 @@ erpnext.HierarchyChartMobile = class { var $li = $('
          • '); return new this.Node({ - id: data.name, + id: data.id, parent: $li.appendTo(node.$children), parent_id: node.id, image: data.image, - name: data.employee_name, - title: data.designation, + name: data.name, + title: data.title, expandable: data.expandable, connections: data.connections, children: undefined @@ -418,7 +422,7 @@ erpnext.HierarchyChartMobile = class { ${html}
            + title="${extra_nodes.map(node => node.name).join(', ')}"> +${extra_nodes.length}
            @@ -443,7 +447,7 @@ erpnext.HierarchyChartMobile = class { } get_avatar(node) { - return ` + return ` ` } From c40b9d276e04cb521dd33601a84acb53100f40c0 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 21:51:21 +0530 Subject: [PATCH 09/44] feat: setup node edit action --- .../organizational_chart/organizational_chart.js | 4 ++-- .../js/hierarchy_chart/hierarchy_chart_desktop.js | 14 +++++++++++++- .../js/hierarchy_chart/hierarchy_chart_mobile.js | 14 +++++++++++++- erpnext/public/js/templates/node_card.html | 4 ++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.js b/erpnext/hr/page/organizational_chart/organizational_chart.js index ca9855286c..a138886768 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.js +++ b/erpnext/hr/page/organizational_chart/organizational_chart.js @@ -11,9 +11,9 @@ frappe.pages['organizational-chart'].on_page_load = function(wrapper) { let method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children'; if (frappe.is_mobile()) { - organizational_chart = new erpnext.HierarchyChartMobile(wrapper, method); + organizational_chart = new erpnext.HierarchyChartMobile('Employee', wrapper, method); } else { - organizational_chart = new erpnext.HierarchyChart(wrapper, method); + organizational_chart = new erpnext.HierarchyChart('Employee', wrapper, method); } organizational_chart.show(); }); diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 052f140c13..0823ec77a8 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -1,14 +1,16 @@ erpnext.HierarchyChart = class { /* Options: + - doctype - wrapper: wrapper for the hierarchy view - method: - to get the data for each node - this method should return id, name, title, image, and connections for each node */ - constructor(wrapper, method) { + constructor(doctype, wrapper, method) { this.wrapper = $(wrapper); this.page = wrapper.page; this.method = method; + this.doctype = doctype; this.page.main.css({ 'min-height': '300px', @@ -36,6 +38,7 @@ erpnext.HierarchyChart = class { me.nodes[this.id] = this; me.make_node_element(this); me.setup_node_click_action(this); + me.setup_edit_node_action(this); } } } @@ -363,6 +366,15 @@ erpnext.HierarchyChart = class { }); } + setup_edit_node_action(node) { + let node_element = $(`#${node.id}`); + let me = this; + + node_element.find('.btn-edit-node').click(function() { + frappe.set_route('Form', me.doctype, node.id); + }); + } + remove_levels_after_node(node) { let level = $(`#${node.id}`).parent().parent().parent(); diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 1b8bc2e8e0..4b09714d0a 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -1,14 +1,16 @@ erpnext.HierarchyChartMobile = class { /* Options: + - doctype - wrapper: wrapper for the hierarchy view - method: - to get the data for each node - this method should return id, name, title, image, and connections for each node */ - constructor(wrapper, method) { + constructor(doctype, wrapper, method) { this.wrapper = $(wrapper); this.page = wrapper.page; this.method = method; + this.doctype = doctype this.page.main.css({ 'min-height': '300px', @@ -36,6 +38,7 @@ erpnext.HierarchyChartMobile = class { me.nodes[this.id] = this; me.make_node_element(this); me.setup_node_click_action(this); + me.setup_edit_node_action(this); } } } @@ -385,6 +388,15 @@ erpnext.HierarchyChartMobile = class { }); } + setup_edit_node_action(node) { + let node_element = $(`#${node.id}`); + let me = this; + + node_element.find('.btn-edit-node').click(function() { + frappe.set_route('Form', me.doctype, node.id); + }); + } + setup_node_group_action() { let me = this; diff --git a/erpnext/public/js/templates/node_card.html b/erpnext/public/js/templates/node_card.html index e42e54f690..30aedab4bb 100644 --- a/erpnext/public/js/templates/node_card.html +++ b/erpnext/public/js/templates/node_card.html @@ -17,9 +17,9 @@
            {{ title }}
            {% if connections == 1 %} -
            · {{ connections }}
            +
            · {{ connections }} Connection
            {% else %} -
            · {{ connections }}
            +
            · {{ connections }} Connections
            {% endif %} From 7558b28b794f09826bcf8f42385605dcdc819086 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Jun 2021 21:57:27 +0530 Subject: [PATCH 10/44] fix: revert changes in employee descendants query --- erpnext/hr/doctype/employee/employee.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index 7917e3abf5..ed7d588434 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -476,14 +476,13 @@ def get_employee_emails(employee_list): return employee_emails @frappe.whitelist() -def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False, fields=None): +def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False): filters = [['status', '!=', 'Left']] if company and company != 'All Companies': filters.append(['company', '=', company]) - if not fields: - fields = ['name as value', 'employee_name as title'] + fields = ['name as value', 'employee_name as title'] if is_root: parent = '' From a7507f7af63c259fd61164c39a5dbd5d6e3c2df1 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Jun 2021 01:46:10 +0530 Subject: [PATCH 11/44] refactor: use arcs instead of bezier curves for cleaner connectors --- .../hierarchy_chart_desktop.js | 54 +++++++++++++++---- erpnext/public/scss/hierarchy_chart.scss | 2 +- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 0823ec77a8..ba811be586 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -277,15 +277,53 @@ erpnext.HierarchyChart = class { y: child_node.offsetTop + child_node.offsetHeight / 2 }; - let connector = - "M" + - (pos_parent_right.x) + "," + (pos_parent_right.y) + " " + - "C" + - (pos_parent_right.x + 100) + "," + (pos_parent_right.y) + " " + - (pos_child_left.x - 100) + "," + (pos_child_left.y) + " " + - (pos_child_left.x) + "," + (pos_child_left.y); + let connector = this.get_connector(pos_parent_right, pos_child_left); path.setAttribute("d", connector); + this.set_path_attributes(path, parent_id, child_id); + + $('#connectors').append(path); + } + + get_connector(pos_parent_right, pos_child_left) { + if (pos_parent_right.y === pos_child_left.y) { + // don't add arcs if it's a straight line + return "M" + + (pos_parent_right.x) + "," + (pos_parent_right.y) + " " + + "L"+ + (pos_child_left.x) + "," + (pos_child_left.y); + } else { + let arc_1 = ""; + let arc_2 = ""; + let offset = 0; + + if (pos_parent_right.y > pos_child_left.y) { + // if child is above parent on Y axis 1st arc is anticlocwise + // second arc is clockwise + arc_1 = "a10,10 1 0 0 10,-10 "; + arc_2 = "a10,10 0 0 1 10,-10 "; + offset = 10; + } else { + // if child is below parent on Y axis 1st arc is clockwise + // second arc is anticlockwise + arc_1 = "a10,10 0 0 1 10,10 "; + arc_2 = "a10,10 1 0 0 10,10 "; + offset = -10; + } + + return "M" + (pos_parent_right.x) + "," + (pos_parent_right.y) + " " + + "L" + + (pos_parent_right.x + 40) + "," + (pos_parent_right.y) + " " + + arc_1 + + "L" + + (pos_parent_right.x + 50) + "," + (pos_child_left.y + offset) + " " + + arc_2 + + "L"+ + (pos_child_left.x) + "," + (pos_child_left.y); + } + } + + set_path_attributes(path, parent_id, child_id) { path.setAttribute("data-parent", parent_id); path.setAttribute("data-child", child_id); @@ -298,8 +336,6 @@ erpnext.HierarchyChart = class { path.setAttribute("marker-start", "url(#arrowstart-collapsed)"); path.setAttribute("marker-end", "url(#arrowhead-collapsed)"); } - - $('#connectors').append(path); } set_selected_node(node) { diff --git a/erpnext/public/scss/hierarchy_chart.scss b/erpnext/public/scss/hierarchy_chart.scss index 16b8792432..16137fdb5f 100644 --- a/erpnext/public/scss/hierarchy_chart.scss +++ b/erpnext/public/scss/hierarchy_chart.scss @@ -48,7 +48,6 @@ border-radius: 0.5rem; padding: 0.75rem; width: 18rem; - height: 5rem; .btn-edit-node { display: flex; @@ -195,6 +194,7 @@ .level { margin-right: 8px; + align-items: flex-start; } #arrows { From b8a18bfef1f3c86758d63bc3cde172ecc1758f43 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Jun 2021 01:57:43 +0530 Subject: [PATCH 12/44] feat: add arc to connectors in mobile view --- erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 4b09714d0a..b09b428b30 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -322,7 +322,8 @@ erpnext.HierarchyChartMobile = class { "M" + (pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " + "L" + - (pos_parent_bottom.x) + "," + (pos_child_left.y) + " " + + (pos_parent_bottom.x) + "," + (pos_child_left.y - 10) + " " + + "a10,10 1 0 0 10,10 " + "L" + (pos_child_left.x) + "," + (pos_child_left.y); From 77b0b8a877108062818b5e0f72995966a85f9af2 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Jun 2021 02:29:16 +0530 Subject: [PATCH 13/44] fix: edit node button overflowing --- erpnext/public/js/templates/node_card.html | 2 +- erpnext/public/scss/hierarchy_chart.scss | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/templates/node_card.html b/erpnext/public/js/templates/node_card.html index 30aedab4bb..c3d8e010b5 100644 --- a/erpnext/public/js/templates/node_card.html +++ b/erpnext/public/js/templates/node_card.html @@ -8,7 +8,7 @@
            {{ name }} -
            + diff --git a/erpnext/public/scss/hierarchy_chart.scss b/erpnext/public/scss/hierarchy_chart.scss index 16137fdb5f..eefc14d679 100644 --- a/erpnext/public/scss/hierarchy_chart.scss +++ b/erpnext/public/scss/hierarchy_chart.scss @@ -57,6 +57,7 @@ font-size: .75rem; justify-content: center; box-shadow: var(--shadow-sm); + margin-left: auto; } .edit-chart-node { @@ -79,6 +80,7 @@ align-items: center; justify-content: space-between; margin-bottom: 2px; + width: 12.2rem; } } From 2fcd05aa82042456b50c9dad42d1e97d57afb80f Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 2 Jul 2021 18:15:18 +0530 Subject: [PATCH 14/44] fix: sider --- .../hierarchy_chart_desktop.js | 25 +++++++++---------- .../hierarchy_chart/hierarchy_chart_mobile.js | 18 ++++++------- erpnext/public/scss/hierarchy_chart.scss | 5 +--- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index ba811be586..9e82fb2002 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -40,7 +40,7 @@ erpnext.HierarchyChart = class { me.setup_node_click_action(this); me.setup_edit_node_action(this); } - } + }; } make_node_element(node) { @@ -76,7 +76,7 @@ erpnext.HierarchyChart = class { me.company = company.get_value(); // svg for connectors - me.make_svg_markers() + me.make_svg_markers(); if (me.$hierarchy) me.$hierarchy.remove(); @@ -149,7 +149,7 @@ erpnext.HierarchyChart = class { me.expand_node(root_node); } } - }) + }); } expand_node(node) { @@ -166,7 +166,7 @@ erpnext.HierarchyChart = class { // rebuild incoming connections let grandparent = $(`#${node.parent_id}`).attr('data-parent'); - this.refresh_connectors(grandparent) + this.refresh_connectors(grandparent); } if (node.expandable && !node.expanded) { @@ -176,8 +176,8 @@ erpnext.HierarchyChart = class { collapse_node() { if (this.selected_node.expandable) { - this.selected_node.$children.hide(); - $(`path[data-parent="${this.selected_node.id}"]`).hide(); + this.selected_node.$children.hide('fast'); + $(`path[data-parent="${this.selected_node.id}"]`).hide('fast'); this.selected_node.expanded = false; } } @@ -222,15 +222,14 @@ erpnext.HierarchyChart = class { if (!node.$children) { node.$children = $('
              ') - .hide() - .appendTo(this.$hierarchy.find('.level:last')); + .hide() + .appendTo(this.$hierarchy.find('.level:last')); node.$children.empty(); if (child_nodes) { $.each(child_nodes, (_i, data) => { this.add_node(node, data); - setTimeout(() => { this.add_connector(node.id, data.id); }, 250); @@ -238,8 +237,8 @@ erpnext.HierarchyChart = class { } } - node.$children.show(); - $(`path[data-parent="${node.id}"]`).show(); + node.$children.show('fast'); + $(`path[data-parent="${node.id}"]`).show('fast'); node.expanded = true; } @@ -443,6 +442,6 @@ erpnext.HierarchyChart = class { return; $(path).remove(); - }) + }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index b09b428b30..2ff00baa7c 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -10,7 +10,7 @@ erpnext.HierarchyChartMobile = class { this.wrapper = $(wrapper); this.page = wrapper.page; this.method = method; - this.doctype = doctype + this.doctype = doctype; this.page.main.css({ 'min-height': '300px', @@ -40,7 +40,7 @@ erpnext.HierarchyChartMobile = class { me.setup_node_click_action(this); me.setup_edit_node_action(this); } - } + }; } make_node_element(node) { @@ -78,7 +78,7 @@ erpnext.HierarchyChartMobile = class { me.company = company.get_value(); // svg for connectors - me.make_svg_markers() + me.make_svg_markers(); if (me.$sibling_group) me.$sibling_group.remove(); @@ -158,7 +158,7 @@ erpnext.HierarchyChartMobile = class { me.expand_node(root_node); } } - }) + }); } expand_node(node) { @@ -248,8 +248,8 @@ erpnext.HierarchyChartMobile = class { render_child_nodes(node, child_nodes) { if (!node.$children) { node.$children = $('
                ') - .hide() - .appendTo(node.$link.parent()); + .hide() + .appendTo(node.$link.parent()); node.$children.empty(); @@ -462,7 +462,7 @@ erpnext.HierarchyChartMobile = class { get_avatar(node) { return ` - ` + `; } expand_sibling_group_node(parent) { @@ -518,7 +518,7 @@ erpnext.HierarchyChartMobile = class { return; $(path).remove(); - }) + }); } refresh_connectors(node_parent, node_id) { @@ -527,4 +527,4 @@ erpnext.HierarchyChartMobile = class { $(`path[data-parent="${node_parent}"]`).remove(); this.add_connector(node_parent, node_id); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/erpnext/public/scss/hierarchy_chart.scss b/erpnext/public/scss/hierarchy_chart.scss index eefc14d679..a54bf6f332 100644 --- a/erpnext/public/scss/hierarchy_chart.scss +++ b/erpnext/public/scss/hierarchy_chart.scss @@ -62,16 +62,13 @@ .edit-chart-node { display: block; + margin-right: 0.25rem; } .node-edit-icon { display: block; } - .edit-chart-node { - margin-right: 0.25rem; - } - .node-edit-icon > .icon{ stroke: var(--blue-500); } From ad066033925ed8f8e5bd6c2e8b7f9c3cc54e5e27 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 6 Jul 2021 18:16:49 +0530 Subject: [PATCH 15/44] fix: removing orphaned connectors --- .../js/hierarchy_chart/hierarchy_chart_desktop.js | 14 ++++++-------- .../js/hierarchy_chart/hierarchy_chart_mobile.js | 6 ++---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 9e82fb2002..1896ac7c39 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -176,8 +176,8 @@ erpnext.HierarchyChart = class { collapse_node() { if (this.selected_node.expandable) { - this.selected_node.$children.hide('fast'); - $(`path[data-parent="${this.selected_node.id}"]`).hide('fast'); + this.selected_node.$children.hide(); + $(`path[data-parent="${this.selected_node.id}"]`).hide(); this.selected_node.expanded = false; } } @@ -237,8 +237,8 @@ erpnext.HierarchyChart = class { } } - node.$children.show('fast'); - $(`path[data-parent="${node.id}"]`).show('fast'); + node.$children.show(); + $(`path[data-parent="${node.id}"]`).show(); node.expanded = true; } @@ -262,9 +262,7 @@ erpnext.HierarchyChart = class { let parent_node = document.querySelector(`#${parent_id}`); let child_node = document.querySelector(`#${child_id}`); - // variable for the namespace - const svgns = 'http://www.w3.org/2000/svg'; - let path = document.createElementNS(svgns, 'path'); + let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // we need to connect right side of the parent to the left side of the child node let pos_parent_right = { @@ -438,7 +436,7 @@ erpnext.HierarchyChart = class { let parent = $(path).data('parent'); let child = $(path).data('child'); - if ($(parent).length || $(child).length) + if ($(`#${parent}`).length && $(`#${child}`).length) return; $(path).remove(); diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 2ff00baa7c..102cbb03b0 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -289,9 +289,7 @@ erpnext.HierarchyChartMobile = class { let parent_node = document.querySelector(`#${parent_id}`); let child_node = document.querySelector(`#${child_id}`); - // variable for the namespace - const svgns = 'http://www.w3.org/2000/svg'; - let path = document.createElementNS(svgns, 'path'); + let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); let connector = undefined; @@ -514,7 +512,7 @@ erpnext.HierarchyChartMobile = class { let parent = $(path).data('parent'); let child = $(path).data('child'); - if ($(parent).length || $(child).length) + if ($(`#${parent}`).length && $(`#${child}`).length) return; $(path).remove(); From 6d5ee25bba0222b122d873340784cc82ee8309fc Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 7 Jul 2021 09:43:28 +0530 Subject: [PATCH 16/44] fix: unnecessary variables --- .../js/hierarchy_chart/hierarchy_chart_desktop.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 1896ac7c39..8d0685f80d 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -153,7 +153,7 @@ erpnext.HierarchyChart = class { } expand_node(node) { - let is_sibling = this.selected_node && this.selected_node.parent_id === node.parent_id; + const is_sibling = this.selected_node && this.selected_node.parent_id === node.parent_id; this.set_selected_node(node); this.show_active_path(node); this.collapse_previous_level_nodes(node); @@ -243,11 +243,9 @@ erpnext.HierarchyChart = class { } add_node(node, data) { - var $li = $('
              • '); - return new this.Node({ id: data.id, - parent: $li.appendTo(node.$children), + parent: $('
              • ').appendTo(node.$children), parent_id: node.id, image: data.image, name: data.name, @@ -259,8 +257,8 @@ erpnext.HierarchyChart = class { } add_connector(parent_id, child_id) { - let parent_node = document.querySelector(`#${parent_id}`); - let child_node = document.querySelector(`#${child_id}`); + const parent_node = document.querySelector(`#${parent_id}`); + const child_node = document.querySelector(`#${child_id}`); let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); @@ -276,7 +274,7 @@ erpnext.HierarchyChart = class { let connector = this.get_connector(pos_parent_right, pos_child_left); - path.setAttribute("d", connector); + path.setAttribute('d', connector); this.set_path_attributes(path, parent_id, child_id); $('#connectors').append(path); @@ -385,7 +383,7 @@ erpnext.HierarchyChart = class { let node_element = $(`#${node.id}`); node_element.click(function() { - let is_sibling = me.selected_node.parent_id === node.parent_id; + const is_sibling = me.selected_node.parent_id === node.parent_id; if (is_sibling) { me.collapse_node(); From 6eec25127369e2b3af3658d11a80132facfee29e Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 7 Jul 2021 12:05:50 +0530 Subject: [PATCH 17/44] feat: handle multiple root / orphan nodes --- .../organizational_chart.py | 3 +- .../hierarchy_chart_desktop.js | 48 +++++++++---------- .../hierarchy_chart/hierarchy_chart_mobile.js | 35 +++++++------- erpnext/public/scss/hierarchy_chart.scss | 4 ++ 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py index f3aa13897d..77b8df7520 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.py +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -17,7 +17,7 @@ def get_children(parent=None, company=None, exclude_node=None, is_root=False, is if exclude_node: filters.append(['name', '!=', exclude_node]) - if parent and company and parent!=company: + if parent and company and parent != company: filters.append(['reports_to', '=', parent]) else: filters.append(['reports_to', '=', '']) @@ -32,6 +32,7 @@ def get_children(parent=None, company=None, exclude_node=None, is_root=False, is employee.connections = get_connections(employee.id) employee.expandable = 1 if is_expandable else 0 + employees.sort(key=lambda x: x['connections'], reverse=True) return employees diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 8d0685f80d..e89a98ac4f 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -84,11 +84,13 @@ erpnext.HierarchyChart = class { // setup hierarchy me.$hierarchy = $( `
                  -
                • +
                • +
                    +
                  `); me.page.main.append(me.$hierarchy); - me.render_root_node(); + me.render_root_nodes(); } } }); @@ -122,7 +124,7 @@ erpnext.HierarchyChart = class { `); } - render_root_node() { + render_root_nodes() { let me = this; frappe.call({ @@ -132,21 +134,28 @@ erpnext.HierarchyChart = class { }, callback: function(r) { if (r.message.length) { - let data = r.message[0]; + let nodes = r.message; + let node = undefined; + let first_root = undefined; - let root_node = new me.Node({ - id: data.id, - parent: me.$hierarchy.find('.root-level'), - parent_id: undefined, - image: data.image, - name: data.name, - title: data.title, - expandable: true, - connections: data.connections, - is_root: true, + $.each(nodes, (i, data) => { + node = new me.Node({ + id: data.id, + parent: $('
                • ').appendTo(me.$hierarchy.find('.node-children')), + parent_id: undefined, + image: data.image, + name: data.name, + title: data.title, + expandable: true, + connections: data.connections, + is_root: true + }); + + if (i == 0) + first_root = node; }); - me.expand_node(root_node); + me.expand_node(first_root); } } }); @@ -344,12 +353,7 @@ erpnext.HierarchyChart = class { collapse_previous_level_nodes(node) { let node_parent = $(`#${node.parent_id}`); - let previous_level_nodes = node_parent.parent().parent().children('li'); - if (node_parent.parent().hasClass('root-level')) { - previous_level_nodes = node_parent.parent().children('li'); - } - let node_card = undefined; previous_level_nodes.each(function() { @@ -409,10 +413,6 @@ erpnext.HierarchyChart = class { remove_levels_after_node(node) { let level = $(`#${node.id}`).parent().parent().parent(); - if ($(`#${node.id}`).parent().hasClass('root-level')) { - level = $(`#${node.id}`).parent(); - } - level = $('.hierarchy > li:eq('+ level.index() + ')'); level.nextAll('li').remove(); diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 102cbb03b0..5eee27b5fc 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -97,7 +97,7 @@ erpnext.HierarchyChartMobile = class { `); me.page.main.append(me.$hierarchy); - me.render_root_node(); + me.render_root_nodes(); } } }); @@ -131,7 +131,7 @@ erpnext.HierarchyChartMobile = class { `); } - render_root_node() { + render_root_nodes() { let me = this; frappe.call({ @@ -141,21 +141,21 @@ erpnext.HierarchyChartMobile = class { }, callback: function(r) { if (r.message.length) { - let data = r.message[0]; + let nodes = r.message; - let root_node = new me.Node({ - id: data.id, - parent: me.$hierarchy.find('.root-level'), - parent_id: undefined, - image: data.image, - name: data.name, - title: data.title, - expandable: true, - connections: data.connections, - is_root: true, + $.each(nodes, (_i, data) => { + return new me.Node({ + id: data.id, + parent: me.$hierarchy.find('.root-level'), + parent_id: undefined, + image: data.image, + name: data.name, + title: data.title, + expandable: true, + connections: data.connections, + is_root: true + }); }); - - me.expand_node(root_node); } } }); @@ -375,7 +375,10 @@ erpnext.HierarchyChartMobile = class { let node_element = $(`#${node.id}`); node_element.click(function() { - if (node_element.is(':visible') && node_element.hasClass('active-path')) { + if (node.is_root) { + me.$hierarchy.empty(); + me.add_node_to_hierarchy(node, true); + } else if (node_element.is(':visible') && node_element.hasClass('active-path')) { me.remove_levels_after_node(node); me.remove_orphaned_connectors(); } else { diff --git a/erpnext/public/scss/hierarchy_chart.scss b/erpnext/public/scss/hierarchy_chart.scss index a54bf6f332..dd523c3443 100644 --- a/erpnext/public/scss/hierarchy_chart.scss +++ b/erpnext/public/scss/hierarchy_chart.scss @@ -246,6 +246,10 @@ margin-top: 16px; } +.root-level .node-card { + margin: 0 0 16px; +} + // node group .collapsed-level { From df3bb9ea8caffe47e3696c10f711a393d78e6630 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 8 Jul 2021 09:53:31 +0530 Subject: [PATCH 18/44] perf: Optimise Rendering - optimise get_children function - use promises instead of callbacks - optimise selectors - use const wherever possible - use pure js instead of jquery for connectors for faster rendering --- .../organizational_chart.py | 22 ++--- .../hierarchy_chart_desktop.js | 82 +++++++++---------- .../hierarchy_chart/hierarchy_chart_mobile.js | 65 +++++++-------- 3 files changed, 78 insertions(+), 91 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py index 77b8df7520..46578f3aaf 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.py +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -2,33 +2,25 @@ from __future__ import unicode_literals import frappe @frappe.whitelist() -def get_children(parent=None, company=None, exclude_node=None, is_root=False, is_tree=False, fields=None): +def get_children(parent=None, company=None): filters = [['status', '!=', 'Left']] if company and company != 'All Companies': filters.append(['company', '=', company]) - if not fields: - fields = ['employee_name as name', 'name as id', 'reports_to', 'image', 'designation as title'] - - if is_root: - parent = '' - - if exclude_node: - filters.append(['name', '!=', exclude_node]) - if parent and company and parent != company: filters.append(['reports_to', '=', parent]) else: filters.append(['reports_to', '=', '']) - employees = frappe.get_list('Employee', fields=fields, - filters=filters, order_by='name') + employees = frappe.get_list('Employee', + fields=['employee_name as name', 'name as id', 'reports_to', 'image', 'designation as title'], + filters=filters, + order_by='name' + ) for employee in employees: - is_expandable = frappe.get_all('Employee', filters=[ - ['reports_to', '=', employee.get('id')] - ]) + is_expandable = frappe.db.count('Employee', filters={'reports_to': employee.get('id')}) employee.connections = get_connections(employee.id) employee.expandable = 1 if is_expandable else 0 diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index e89a98ac4f..bf366792a9 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -7,7 +7,6 @@ erpnext.HierarchyChart = class { - this method should return id, name, title, image, and connections for each node */ constructor(doctype, wrapper, method) { - this.wrapper = $(wrapper); this.page = wrapper.page; this.method = method; this.doctype = doctype; @@ -61,6 +60,8 @@ erpnext.HierarchyChart = class { frappe.breadcrumbs.add('HR'); let me = this; + if ($(`[data-fieldname="company"]`).length) return; + let company = this.page.add_field({ fieldtype: 'Link', options: 'Company', @@ -131,32 +132,30 @@ erpnext.HierarchyChart = class { method: me.method, args: { company: me.company - }, - callback: function(r) { - if (r.message.length) { - let nodes = r.message; - let node = undefined; - let first_root = undefined; + } + }).then(r => { + if (r.message.length) { + let node = undefined; + let first_root = undefined; - $.each(nodes, (i, data) => { - node = new me.Node({ - id: data.id, - parent: $('
                • ').appendTo(me.$hierarchy.find('.node-children')), - parent_id: undefined, - image: data.image, - name: data.name, - title: data.title, - expandable: true, - connections: data.connections, - is_root: true - }); - - if (i == 0) - first_root = node; + $.each(r.message, (i, data) => { + node = new me.Node({ + id: data.id, + parent: $('
                • ').appendTo(me.$hierarchy.find('.node-children')), + parent_id: undefined, + image: data.image, + name: data.name, + title: data.title, + expandable: true, + connections: data.connections, + is_root: true }); - me.expand_node(first_root); - } + if (i == 0) + first_root = node; + }); + + me.expand_node(first_root); } }); } @@ -204,18 +203,14 @@ erpnext.HierarchyChart = class { } get_child_nodes(node_id) { - let me = this; return new Promise(resolve => { frappe.call({ method: this.method, args: { parent: node_id, - company: me.company - }, - callback: (r) => { - resolve(r.message); + company: this.company } - }); + }).then(r => resolve(r.message)); }); } @@ -266,27 +261,28 @@ erpnext.HierarchyChart = class { } add_connector(parent_id, child_id) { + // using pure javascript for better performance const parent_node = document.querySelector(`#${parent_id}`); const child_node = document.querySelector(`#${child_id}`); let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // we need to connect right side of the parent to the left side of the child node - let pos_parent_right = { + const pos_parent_right = { x: parent_node.offsetLeft + parent_node.offsetWidth, y: parent_node.offsetTop + parent_node.offsetHeight / 2 }; - let pos_child_left = { + const pos_child_left = { x: child_node.offsetLeft - 5, y: child_node.offsetTop + child_node.offsetHeight / 2 }; - let connector = this.get_connector(pos_parent_right, pos_child_left); + const connector = this.get_connector(pos_parent_right, pos_child_left); path.setAttribute('d', connector); this.set_path_attributes(path, parent_id, child_id); - $('#connectors').append(path); + document.getElementById('connectors').appendChild(path); } get_connector(pos_parent_right, pos_child_left) { @@ -330,12 +326,13 @@ erpnext.HierarchyChart = class { set_path_attributes(path, parent_id, child_id) { path.setAttribute("data-parent", parent_id); path.setAttribute("data-child", child_id); + const parent = $(`#${parent_id}`); - if ($(`#${parent_id}`).hasClass('active')) { + if (parent.hasClass('active')) { path.setAttribute("class", "active-connector"); path.setAttribute("marker-start", "url(#arrowstart-active)"); path.setAttribute("marker-end", "url(#arrowhead-active)"); - } else if ($(`#${parent_id}`).hasClass('active-path')) { + } else if (parent.hasClass('active-path')) { path.setAttribute("class", "collapsed-connector"); path.setAttribute("marker-start", "url(#arrowstart-collapsed)"); path.setAttribute("marker-end", "url(#arrowhead-collapsed)"); @@ -343,8 +340,9 @@ erpnext.HierarchyChart = class { } set_selected_node(node) { - // remove .active class from the current node - $('.active').removeClass('active'); + // remove active class from the current node + if (this.selected_node) + this.selected_node.$link.removeClass('active'); // add active class to the newly selected node this.selected_node = node; @@ -411,9 +409,9 @@ erpnext.HierarchyChart = class { } remove_levels_after_node(node) { - let level = $(`#${node.id}`).parent().parent().parent(); + let level = $(`#${node.id}`).parent().parent().parent().index(); - level = $('.hierarchy > li:eq('+ level.index() + ')'); + level = $('.hierarchy > li:eq('+ level + ')'); level.nextAll('li').remove(); let nodes = level.find('.node-card'); @@ -431,8 +429,8 @@ erpnext.HierarchyChart = class { remove_orphaned_connectors() { let paths = $('#connectors > path'); $.each(paths, (_i, path) => { - let parent = $(path).data('parent'); - let child = $(path).data('child'); + const parent = $(path).data('parent'); + const child = $(path).data('child'); if ($(`#${parent}`).length && $(`#${child}`).length) return; diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 5eee27b5fc..17062e2585 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -7,7 +7,6 @@ erpnext.HierarchyChartMobile = class { - this method should return id, name, title, image, and connections for each node */ constructor(doctype, wrapper, method) { - this.wrapper = $(wrapper); this.page = wrapper.page; this.method = method; this.doctype = doctype; @@ -63,6 +62,8 @@ erpnext.HierarchyChartMobile = class { frappe.breadcrumbs.add('HR'); let me = this; + if ($(`[data-fieldname="company"]`).length) return; + let company = this.page.add_field({ fieldtype: 'Link', options: 'Company', @@ -139,24 +140,21 @@ erpnext.HierarchyChartMobile = class { args: { company: me.company }, - callback: function(r) { - if (r.message.length) { - let nodes = r.message; - - $.each(nodes, (_i, data) => { - return new me.Node({ - id: data.id, - parent: me.$hierarchy.find('.root-level'), - parent_id: undefined, - image: data.image, - name: data.name, - title: data.title, - expandable: true, - connections: data.connections, - is_root: true - }); + }).then(r => { + if (r.message.length) { + $.each(r.message, (_i, data) => { + return new me.Node({ + id: data.id, + parent: me.$hierarchy.find('.root-level'), + parent_id: undefined, + image: data.image, + name: data.name, + title: data.title, + expandable: true, + connections: data.connections, + is_root: true }); - } + }); } }); } @@ -237,11 +235,8 @@ erpnext.HierarchyChartMobile = class { parent: node_id, company: me.company, exclude_node: exclude_node - }, - callback: (r) => { - resolve(r.message); } - }); + }).then(r => resolve(r.message)); }); } @@ -286,10 +281,10 @@ erpnext.HierarchyChartMobile = class { } add_connector(parent_id, child_id) { - let parent_node = document.querySelector(`#${parent_id}`); - let child_node = document.querySelector(`#${child_id}`); + const parent_node = document.querySelector(`#${parent_id}`); + const child_node = document.querySelector(`#${child_id}`); - let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); let connector = undefined; @@ -299,10 +294,10 @@ erpnext.HierarchyChartMobile = class { connector = this.get_connector_for_collapsed_node(parent_node, child_node); } - path.setAttribute("d", connector); + path.setAttribute('d', connector); this.set_path_attributes(path, parent_id, child_id); - $('#connectors').append(path); + document.getElementById('connectors').appendChild(path); } get_connector_for_active_node(parent_node, child_node) { @@ -351,19 +346,21 @@ erpnext.HierarchyChartMobile = class { set_path_attributes(path, parent_id, child_id) { path.setAttribute("data-parent", parent_id); path.setAttribute("data-child", child_id); + const parent = $(`#${parent_id}`); - if ($(`#${parent_id}`).hasClass('active')) { + if (parent.hasClass('active')) { path.setAttribute("class", "active-connector"); path.setAttribute("marker-start", "url(#arrowstart-active)"); path.setAttribute("marker-end", "url(#arrowhead-active)"); - } else if ($(`#${parent_id}`).hasClass('active-path')) { + } else if (parent.hasClass('active-path')) { path.setAttribute("class", "collapsed-connector"); } } set_selected_node(node) { // remove .active class from the current node - $('.active').removeClass('active'); + if (this.selected_node) + this.selected_node.$link.removeClass('active'); // add active class to the newly selected node this.selected_node = node; @@ -494,9 +491,9 @@ erpnext.HierarchyChartMobile = class { } remove_levels_after_node(node) { - let level = $(`#${node.id}`).parent().parent(); + let level = $(`#${node.id}`).parent().parent().index(); - level = $('.hierarchy-mobile > li:eq('+ (level.index()) + ')'); + level = $('.hierarchy-mobile > li:eq('+ level + ')'); level.nextAll('li').remove(); let current_node = level.find(`#${node.id}`); @@ -512,8 +509,8 @@ erpnext.HierarchyChartMobile = class { remove_orphaned_connectors() { let paths = $('#connectors > path'); $.each(paths, (_i, path) => { - let parent = $(path).data('parent'); - let child = $(path).data('child'); + const parent = $(path).data('parent'); + const child = $(path).data('child'); if ($(`#${parent}`).length && $(`#${child}`).length) return; From 48018b8d8c50caee0e8b6ff6bd5a81a35806dcdb Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 8 Jul 2021 11:23:50 +0530 Subject: [PATCH 19/44] fix: do not sort by number of connections --- .../hr/page/organizational_chart/organizational_chart.py | 1 - .../public/js/hierarchy_chart/hierarchy_chart_desktop.js | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py index 46578f3aaf..ce84b3c744 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.py +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -24,7 +24,6 @@ def get_children(parent=None, company=None): employee.connections = get_connections(employee.id) employee.expandable = 1 if is_expandable else 0 - employees.sort(key=lambda x: x['connections'], reverse=True) return employees diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index bf366792a9..374787c6ef 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -135,8 +135,8 @@ erpnext.HierarchyChart = class { } }).then(r => { if (r.message.length) { + let expand_node = undefined; let node = undefined; - let first_root = undefined; $.each(r.message, (i, data) => { node = new me.Node({ @@ -151,11 +151,11 @@ erpnext.HierarchyChart = class { is_root: true }); - if (i == 0) - first_root = node; + if (!expand_node && data.connections) + expand_node = node; }); - me.expand_node(first_root); + me.expand_node(expand_node); } }); } From 05ffc0d3e0fc061639494df2978557c63f7d2d25 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 8 Jul 2021 16:55:42 +0530 Subject: [PATCH 20/44] feat: use icon for connections on mobile view --- .../js/hierarchy_chart/hierarchy_chart_mobile.js | 2 +- erpnext/public/js/templates/node_card.html | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 17062e2585..1985299378 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -50,7 +50,7 @@ erpnext.HierarchyChartMobile = class { image: node.image, parent: node.parent_id, connections: node.connections, - is_mobile: 1 + is_mobile: true }); node.parent.append(node_card); diff --git a/erpnext/public/js/templates/node_card.html b/erpnext/public/js/templates/node_card.html index c3d8e010b5..fb94df85ed 100644 --- a/erpnext/public/js/templates/node_card.html +++ b/erpnext/public/js/templates/node_card.html @@ -16,10 +16,16 @@
                  {{ title }}
                  - {% if connections == 1 %} -
                  · {{ connections }} Connection
                  + {% if is_mobile %} +
                  + · {{ connections }} +
                  {% else %} -
                  · {{ connections }} Connections
                  + {% if connections == 1 %} +
                  · {{ connections }} Connection
                  + {% else %} +
                  · {{ connections }} Connections
                  + {% endif %} {% endif %}
                  From 09c24c79496e942d749ff8e5a4ef502453064557 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 8 Jul 2021 17:05:40 +0530 Subject: [PATCH 21/44] fix: exclude active node while fetching sibling group --- .../hr/page/organizational_chart/organizational_chart.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/page/organizational_chart/organizational_chart.py b/erpnext/hr/page/organizational_chart/organizational_chart.py index ce84b3c744..1e03e3d06a 100644 --- a/erpnext/hr/page/organizational_chart/organizational_chart.py +++ b/erpnext/hr/page/organizational_chart/organizational_chart.py @@ -2,8 +2,7 @@ from __future__ import unicode_literals import frappe @frappe.whitelist() -def get_children(parent=None, company=None): - +def get_children(parent=None, company=None, exclude_node=None): filters = [['status', '!=', 'Left']] if company and company != 'All Companies': filters.append(['company', '=', company]) @@ -13,6 +12,9 @@ def get_children(parent=None, company=None): else: filters.append(['reports_to', '=', '']) + if exclude_node: + filters.append(['name', '!=', exclude_node]) + employees = frappe.get_list('Employee', fields=['employee_name as name', 'name as id', 'reports_to', 'image', 'designation as title'], filters=filters, From 06fc9e7847622cf0443c166d3514d6a2288e4902 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 8 Jul 2021 18:44:53 +0530 Subject: [PATCH 22/44] fix: sibling group expansion not working for root nodes --- .../hierarchy_chart/hierarchy_chart_mobile.js | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 1985299378..d48b4c8f36 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -88,16 +88,7 @@ erpnext.HierarchyChartMobile = class { me.$sibling_group = $(`
                  `); me.page.main.append(me.$sibling_group); - if (me.$hierarchy) - me.$hierarchy.remove(); - - // setup hierarchy - me.$hierarchy = $( - `
                    -
                  • -
                  `); - - me.page.main.append(me.$hierarchy); + me.setup_hierarchy() me.render_root_nodes(); } } @@ -132,6 +123,19 @@ erpnext.HierarchyChartMobile = class { `); } + setup_hierarchy() { + $(`#connectors`).empty(); + if (this.$hierarchy) + this.$hierarchy.remove(); + + this.$hierarchy = $( + `
                    +
                  • +
                  `); + + this.page.main.append(this.$hierarchy); + } + render_root_nodes() { let me = this; @@ -142,10 +146,13 @@ erpnext.HierarchyChartMobile = class { }, }).then(r => { if (r.message.length) { + let root_level = me.$hierarchy.find('.root-level'); + root_level.empty(); + $.each(r.message, (_i, data) => { return new me.Node({ id: data.id, - parent: me.$hierarchy.find('.root-level'), + parent: root_level, parent_id: undefined, image: data.image, name: data.name, @@ -401,7 +408,12 @@ erpnext.HierarchyChartMobile = class { $('.node-group').on('click', function() { let parent = $(this).attr('data-parent'); - me.expand_sibling_group_node(parent); + if (parent === 'undefined') { + me.setup_hierarchy(); + me.render_root_nodes(); + } else { + me.expand_sibling_group_node(parent); + } }); } From 24b31c0bf93a53c4f98fe5020b9575288b2a9aab Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 9 Jul 2021 01:03:02 +0530 Subject: [PATCH 23/44] fix(mobile): collapsed nodes not expanding --- .../hierarchy_chart/hierarchy_chart_mobile.js | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index d48b4c8f36..58530eaaf9 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -88,7 +88,7 @@ erpnext.HierarchyChartMobile = class { me.$sibling_group = $(`
                  `); me.page.main.append(me.$sibling_group); - me.setup_hierarchy() + me.setup_hierarchy(); me.render_root_nodes(); } } @@ -194,9 +194,9 @@ erpnext.HierarchyChartMobile = class { collapse_node() { let node = this.selected_node; - if (node.expandable) { + if (node.expandable && node.$children) { node.$children.hide(); - node.expanded = false; + node.expanded = 0; // add a collapsed level to show the collapsed parent // and a button beside it to move to that level @@ -212,10 +212,7 @@ erpnext.HierarchyChartMobile = class { frappe.run_serially([ () => this.get_child_nodes(node.parent_id, node.id), (child_nodes) => this.get_node_group(child_nodes, node.parent_id), - (node_group) => { - node_parent.find('.collapsed-level') - .append(node_group); - }, + (node_group) => node_parent.find('.collapsed-level').append(node_group), () => this.setup_node_group_action() ]); } @@ -268,7 +265,7 @@ erpnext.HierarchyChartMobile = class { } node.$children.show(); - node.expanded = true; + node.expanded = 1; } add_node(node, data) { @@ -380,13 +377,16 @@ erpnext.HierarchyChartMobile = class { node_element.click(function() { if (node.is_root) { + var el = $(this).detach(); me.$hierarchy.empty(); - me.add_node_to_hierarchy(node, true); + $(`#connectors`).empty(); + me.add_node_to_hierarchy(el, node); } else if (node_element.is(':visible') && node_element.hasClass('active-path')) { me.remove_levels_after_node(node); me.remove_orphaned_connectors(); } else { - me.add_node_to_hierarchy(node, true); + var el = $(this).detach(); + me.add_node_to_hierarchy(el, node); me.collapse_node(); } @@ -417,15 +417,15 @@ erpnext.HierarchyChartMobile = class { }); } - add_node_to_hierarchy(node) { - this.$hierarchy.append(` -
                • -
                  -
                  -
                • - `); + add_node_to_hierarchy(node_element, node) { + this.$hierarchy.append(`
                • `); + node_element.removeClass('active-child active-path'); + this.$hierarchy.find('.level:last').append(node_element); - node.$link.appendTo(this.$hierarchy.find('.level:last')); + let node_object = this.nodes[node.id]; + node_object.expanded = 0; + node_object.$children = undefined; + this.nodes[node.id] = node_object; } get_node_group(nodes, parent, collapsed=true) { @@ -478,9 +478,11 @@ erpnext.HierarchyChartMobile = class { expand_sibling_group_node(parent) { let node_object = this.nodes[parent]; let node = node_object.$link; + node.removeClass('active-child active-path'); node_object.expanded = 0; node_object.$children = undefined; + this.nodes[node.id] = node_object; // show parent's siblings and expand parent node frappe.run_serially([ @@ -491,17 +493,21 @@ erpnext.HierarchyChartMobile = class { this.$sibling_group.empty().append(node_group); }, () => this.setup_node_group_action(), - () => { - this.$hierarchy.empty().append(` -
                • - `); - this.$hierarchy.find('.level').append(node); - $(`#connectors`).empty(); - this.expand_node(node_object); - } + () => this.reattach_and_expand_node(node, node_object) ]); } + reattach_and_expand_node(node, node_object) { + var el = node.detach(); + + this.$hierarchy.empty().append(` +
                • + `); + this.$hierarchy.find('.level').append(el); + $(`#connectors`).empty(); + this.expand_node(node_object); + } + remove_levels_after_node(node) { let level = $(`#${node.id}`).parent().parent().index(); From 4582f28d0d072feddc7cbfcab4aac063fdfaad36 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 9 Jul 2021 01:25:26 +0530 Subject: [PATCH 24/44] fix: sider --- erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 58530eaaf9..5a6f168876 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -376,8 +376,9 @@ erpnext.HierarchyChartMobile = class { let node_element = $(`#${node.id}`); node_element.click(function() { + let el = $(this).detach(); + if (node.is_root) { - var el = $(this).detach(); me.$hierarchy.empty(); $(`#connectors`).empty(); me.add_node_to_hierarchy(el, node); @@ -385,7 +386,6 @@ erpnext.HierarchyChartMobile = class { me.remove_levels_after_node(node); me.remove_orphaned_connectors(); } else { - var el = $(this).detach(); me.add_node_to_hierarchy(el, node); me.collapse_node(); } From 40793f4a18c4a23d1414654116657323d7ea800c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 13 Jul 2021 13:17:19 +0530 Subject: [PATCH 25/44] test: introduce cypress tests Co-authored-by: Ankush Co-authored-by: Nabin Hait --- .eslintrc | 6 +- .github/helper/install.sh | 2 +- .github/workflows/ui-tests.yml | 108 +++++++++++++++++++++++++++ cypress.json | 11 +++ cypress/fixtures/example.json | 5 ++ cypress/integration/test_customer.js | 13 ++++ cypress/plugins/index.js | 17 +++++ cypress/support/commands.js | 31 ++++++++ cypress/support/index.js | 26 +++++++ cypress/tsconfig.json | 12 +++ 10 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ui-tests.yml create mode 100644 cypress.json create mode 100644 cypress/fixtures/example.json create mode 100644 cypress/integration/test_customer.js create mode 100644 cypress/plugins/index.js create mode 100644 cypress/support/commands.js create mode 100644 cypress/support/index.js create mode 100644 cypress/tsconfig.json diff --git a/.eslintrc b/.eslintrc index 3b6ab7498d..ecfaab23ee 100644 --- a/.eslintrc +++ b/.eslintrc @@ -147,10 +147,14 @@ "Chart": true, "Cypress": true, "cy": true, + "describe": true, + "expect": true, "it": true, "context": true, "before": true, "beforeEach": true, - "onScan": true + "onScan": true, + "extend_cscript": true, + "localforage": true } } diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 7b0f944c66..a6a6069d35 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -42,5 +42,5 @@ sed -i 's/socketio:/# socketio:/g' Procfile sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile bench get-app erpnext "${GITHUB_WORKSPACE}" -bench start & +bench start &> bench_run_logs.txt & bench --site test_site reinstall --yes diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000000..412a05b0a1 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,108 @@ +name: UI + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-18.04 + + strategy: + fail-fast: false + + name: UI Tests (Cypress) + + services: + mysql: + image: mariadb:10.3 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: YES + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.7 + + - uses: actions/setup-node@v2 + with: + node-version: 14 + check-latest: true + + - name: Add to Hosts + run: | + echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Cache node modules + uses: actions/cache@v2 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + + - uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache cypress binary + uses: actions/cache@v2 + with: + path: ~/.cache + key: ${{ runner.os }}-cypress- + restore-keys: | + ${{ runner.os }}-cypress- + ${{ runner.os }}- + + - name: Install + run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh + env: + DB: mariadb + TYPE: ui + + - name: Site Setup + run: cd ~/frappe-bench/ && bench --site test_site execute erpnext.setup.utils.before_tests + + - name: cypress pre-requisites + run: cd ~/frappe-bench/apps/frappe && yarn add cypress-file-upload@^5 --no-lockfile + + + - name: Build Assets + run: cd ~/frappe-bench/ && bench build + + - name: UI Tests + run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + + - name: Show bench console if tests failed + if: ${{ failure() }} + run: cat ~/frappe-bench/bench_run_logs.txt diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..2f5026f62c --- /dev/null +++ b/cypress.json @@ -0,0 +1,11 @@ +{ + "baseUrl": "http://test_site:8000/", + "projectId": "da59y9", + "adminPassword": "admin", + "defaultCommandTimeout": 20000, + "pageLoadTimeout": 15000, + "retries": { + "runMode": 2, + "openMode": 2 + } +} \ No newline at end of file diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 0000000000..da18d9352a --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} \ No newline at end of file diff --git a/cypress/integration/test_customer.js b/cypress/integration/test_customer.js new file mode 100644 index 0000000000..3d6ed5d0d8 --- /dev/null +++ b/cypress/integration/test_customer.js @@ -0,0 +1,13 @@ + +context('Customer', () => { + before(() => { + cy.login(); + }); + it('Check Customer Group', () => { + cy.visit(`app/customer/`); + cy.get('.primary-action').click(); + cy.wait(500); + cy.get('.custom-actions > .btn').click(); + cy.get_field('customer_group', 'Link').should('have.value', 'All Customer Groups'); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000000..07d9804a73 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,17 @@ +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +module.exports = () => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +}; diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000000..7ddc80ab8d --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,31 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }); +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }); +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }); +// +// +// -- This is will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }); + +const slug = (name) => name.toLowerCase().replace(" ", "-"); + +Cypress.Commands.add("go_to_doc", (doctype, name) => { + cy.visit(`/app/${slug(doctype)}/${encodeURIComponent(name)}`); +}); diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000000..72070cc81c --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,26 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; +import '../../../frappe/cypress/support/commands' // eslint-disable-line + + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +Cypress.Cookies.defaults({ + preserve: 'sid' +}); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000000..d90ebf6856 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowJs": true, + "baseUrl": "../node_modules", + "types": [ + "cypress" + ] + }, + "include": [ + "**/*.*" + ] +} \ No newline at end of file From f004b404d1ed344790600d3a1d2e038a53370031 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 14 Jul 2021 23:50:54 +0530 Subject: [PATCH 26/44] test: UI tests for org chart desktop --- .../test_organizational_chart_desktop.js | 102 ++++++++++++++++++ .../hierarchy_chart_desktop.js | 3 +- erpnext/tests/ui_test_helpers.py | 53 +++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 cypress/integration/test_organizational_chart_desktop.js create mode 100644 erpnext/tests/ui_test_helpers.py diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js new file mode 100644 index 0000000000..d50d551330 --- /dev/null +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -0,0 +1,102 @@ +context('Organizational Chart', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + + cy.visit(`app/organizational-chart`); + cy.fill_field('company', 'Test Org Chart'); + cy.get('body').click(); + cy.wait(500); + }); + + beforeEach(() => { + cy.window().its('frappe').then(frappe => { + return frappe.call('erpnext.tests.ui_test_helpers.create_employee_records'); + }).as('employee_records'); + }); + + it('renders root nodes and loads children for the first expandable node', () => { + // check rendered root nodes and the node name, title, connections + cy.get('.hierarchy').find('.root-level ul.node-children').children() + .should('have.length', 2) + .first() + .as('first-child'); + + cy.get('@first-child').get('.node-name').contains('Test Employee 1'); + cy.get('@first-child').get('.node-info').find('.node-title').contains('CEO'); + cy.get('@first-child').get('.node-info').find('.node-connections').contains('· 2 Connections'); + + // check children of first node + cy.get('@employee_records').then(employee_records => { + // children of 1st root visible + cy.get(`[data-parent="${employee_records.message[0]}"]`).as('child-node') + cy.get('@child-node') + .should('have.length', 1) + .should('be.visible'); + cy.get('@child-node').get('.node-name').contains('Test Employee 3'); + + // connectors between first root node and immediate child + cy.get(`path[data-parent="${employee_records.message[0]}"]`) + .should('be.visible') + .invoke('attr', 'data-child') + .should('equal', employee_records.message[2]); + }); + }); + + it('hides active nodes children and connectors on expanding sibling node', () => { + cy.get('@employee_records').then(employee_records => { + // click sibling + cy.get(`#${employee_records.message[1]}`) + .click() + .should('have.class', 'active'); + + // child nodes and connectors hidden + cy.get(`[data-parent="${employee_records.message[0]}"]`).should('not.be.visible'); + cy.get(`path[data-parent="${employee_records.message[0]}"]`).should('not.be.visible'); + }); + }); + + it('collapses previous level nodes and refreshes connectors on expanding child node', () => { + cy.get('@employee_records').then(employee_records => { + // click child node + cy.get(`#${employee_records.message[3]}`) + .click() + .should('have.class', 'active'); + + // previous level nodes: parent should be on active-path; other nodes should be collapsed + cy.get(`#${employee_records.message[0]}`).should('have.class', 'collapsed'); + cy.get(`#${employee_records.message[1]}`).should('have.class', 'active-path'); + + // previous level connectors refreshed + cy.get(`path[data-parent="${employee_records.message[1]}"]`) + .should('have.class', 'collapsed-connector'); + + // child node's children and connectors rendered + cy.get(`[data-parent="${employee_records.message[3]}"]`).should('be.visible'); + cy.get(`path[data-parent="${employee_records.message[3]}"]`).should('be.visible'); + }); + }); + + it('expands previous level nodes', () => { + cy.get('@employee_records').then(employee_records => { + cy.get(`#${employee_records.message[0]}`) + .click() + .should('have.class', 'active'); + + cy.get(`[data-parent="${employee_records.message[0]}"]`) + .should('be.visible'); + + cy.get('ul.hierarchy').children().should('have.length', 2); + cy.get(`#connectors`).children().should('have.length', 1); + }); + }); + + it('edit node navigates to employee master', () => { + cy.get('@employee_records').then(employee_records => { + cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node') + .click(); + + cy.url().should('include', `/employee/${employee_records.message[0]}`); + }); + }); +}); diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 374787c6ef..fe4d17c210 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -49,7 +49,8 @@ erpnext.HierarchyChart = class { title: node.title, image: node.image, parent: node.parent_id, - connections: node.connections + connections: node.connections, + is_mobile: false }); node.parent.append(node_card); diff --git a/erpnext/tests/ui_test_helpers.py b/erpnext/tests/ui_test_helpers.py new file mode 100644 index 0000000000..8e67b1cd34 --- /dev/null +++ b/erpnext/tests/ui_test_helpers.py @@ -0,0 +1,53 @@ +import frappe +from frappe import _ +from frappe.utils import getdate + +@frappe.whitelist() +def create_employee_records(): + company = create_company() + create_missing_designation() + + emp1 = create_employee('Test Employee 1', 'CEO') + emp2 = create_employee('Test Employee 2', 'CTO') + emp3 = create_employee('Test Employee 3', 'Head of Marketing and Sales', emp1) + emp4 = create_employee('Test Employee 4', 'Project Manager', emp2) + emp5 = create_employee('Test Employee 5', 'Analyst', emp3) + emp6 = create_employee('Test Employee 6', 'Software Developer', emp4) + + employees = [emp1, emp2, emp3, emp4, emp5, emp6] + return employees + +def create_company(): + company = frappe.db.exists('Company', 'Test Org Chart') + if not company: + company = frappe.get_doc({ + 'doctype': 'Company', + 'company_name': 'Test Org Chart', + 'country': 'India', + 'default_currency': 'INR' + }).insert().name + + return company + +def create_employee(first_name, designation, reports_to=None): + employee = frappe.db.exists('Employee', {'first_name': first_name, 'designation': designation}) + if not employee: + employee = frappe.get_doc({ + 'doctype': 'Employee', + 'first_name': first_name, + 'company': 'Test Org Chart', + 'gender': 'Female', + 'date_of_birth': getdate('08-12-1998'), + 'date_of_joining': getdate('01-01-2021'), + 'designation': designation, + 'reports_to': reports_to + }).insert().name + + return employee + +def create_missing_designation(): + if not frappe.db.exists('Designation', 'CTO'): + frappe.get_doc({ + 'doctype': 'Designation', + 'designation_name': 'CTO' + }).insert() \ No newline at end of file From ee7eaf9c70484dec3db6f282ca2f74a4a4ac2a2d Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 15 Jul 2021 19:19:09 +0530 Subject: [PATCH 27/44] test: UI tests for org chart mobile fix(mobile): detach node before emptying hierarchy fix(mobile): sibling group not rendering for first level --- .../test_organizational_chart_mobile.js | 182 ++++++++++++++++++ .../hierarchy_chart/hierarchy_chart_mobile.js | 13 +- erpnext/tests/ui_test_helpers.py | 7 +- 3 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 cypress/integration/test_organizational_chart_mobile.js diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js new file mode 100644 index 0000000000..656051289f --- /dev/null +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -0,0 +1,182 @@ +context('Organizational Chart Mobile', () => { + before(() => { + cy.login(); + cy.viewport(375, 667); + cy.visit('/app/website'); + + cy.visit(`app/organizational-chart`); + cy.wait(500); + cy.fill_field('company', 'Test Org Chart'); + cy.get('body').click(); + cy.wait(500); + }); + + beforeEach(() => { + cy.viewport(375, 667); + cy.wait(500); + + cy.window().its('frappe').then(frappe => { + return frappe.call('erpnext.tests.ui_test_helpers.create_employee_records'); + }).as('employee_records'); + }); + + it('renders root nodes', () => { + // check rendered root nodes and the node name, title, connections + cy.get('.hierarchy-mobile').find('.root-level').children() + .should('have.length', 2) + .first() + .as('first-child'); + + cy.get('@first-child').get('.node-name').contains('Test Employee 1'); + cy.get('@first-child').get('.node-info').find('.node-title').contains('CEO'); + cy.get('@first-child').get('.node-info').find('.node-connections').contains('· 2'); + }); + + it('expands root node', () => { + cy.get('@employee_records').then(employee_records => { + cy.get(`#${employee_records.message[1]}`) + .click() + .should('have.class', 'active'); + + // other root node removed + cy.get(`#${employee_records.message[0]}`).should('not.exist'); + + // children of active root node + cy.get('.hierarchy-mobile').find('.level').first().find('ul.node-children').children() + .should('have.length', 2) + + cy.get(`[data-parent="${employee_records.message[1]}"]`).first().as('child-node'); + cy.get('@child-node').should('be.visible'); + + cy.get('@child-node') + .get('.node-name') + .contains('Test Employee 4'); + + // connectors between root node and immediate children + cy.get(`path[data-parent="${employee_records.message[1]}"]`).as('connectors'); + cy.get('@connectors') + .should('have.length', 2) + .should('be.visible') + + cy.get('@connectors') + .first() + .invoke('attr', 'data-child') + .should('eq', employee_records.message[3]); + }); + }); + + it('expands child node', () => { + cy.get('@employee_records').then(employee_records => { + cy.get(`#${employee_records.message[3]}`) + .click() + .should('have.class', 'active') + .as('expanded_node'); + + // 2 levels on screen; 1 on active path; 1 collapsed + cy.get('.hierarchy-mobile').children().should('have.length', 2); + cy.get(`#${employee_records.message[1]}`).should('have.class', 'active-path'); + + // children of expanded node visible + cy.get('@expanded_node') + .next() + .should('have.class', 'node-children') + .as('node-children'); + + cy.get('@node-children').children().should('have.length', 1); + cy.get('@node-children') + .first() + .get('.node-card') + .should('have.class', 'active-child') + .contains('Test Employee 7'); + + // orphan connectors removed + cy.get(`#connectors`).children().should('have.length', 2); + }); + }); + + it('renders sibling group', () => { + cy.get('@employee_records').then(employee_records => { + // sibling group visible for parent + cy.get(`#${employee_records.message[1]}`) + .next() + .as('sibling_group'); + + cy.get('@sibling_group') + .should('have.attr', 'data-parent', 'undefined') + .should('have.class', 'node-group') + .and('have.class', 'collapsed') + + cy.get('@sibling_group').get('.avatar-group').children().as('siblings'); + cy.get('@siblings').should('have.length', 1); + cy.get('@siblings') + .first() + .should('have.attr', 'title', 'Test Employee 1'); + + }); + }); + + it('expands previous level nodes', () => { + cy.get('@employee_records').then(employee_records => { + cy.get(`#${employee_records.message[6]}`) + .click() + .should('have.class', 'active'); + + // clicking on previous level node should remove all the nodes ahead + // and expand that node + cy.get(`#${employee_records.message[3]}`).click(); + cy.get(`#${employee_records.message[3]}`) + .should('have.class', 'active') + .should('not.have.class', 'active-path'); + + cy.get(`#${employee_records.message[6]}`).should('have.class', 'active-child'); + cy.get('.hierarchy-mobile').children().should('have.length', 2); + cy.get(`#connectors`).children().should('have.length', 2); + }); + }); + + it('expands sibling group', () => { + cy.get('@employee_records').then(employee_records => { + // sibling group visible for parent + cy.get(`#${employee_records.message[6]}`).click() + + cy.get(`#${employee_records.message[3]}`) + .next() + .click(); + + // siblings of parent should be visible + cy.get('.hierarchy-mobile').prev().as('sibling_group'); + cy.get('@sibling_group') + .should('exist') + .should('have.class', 'sibling-group') + .should('not.have.class', 'collapsed'); + + cy.get(`#${employee_records.message[1]}`) + .should('be.visible') + .should('have.class', 'active'); + + cy.get(`[data-parent="${employee_records.message[1]}"]`) + .should('be.visible') + .should('have.length', 2) + .should('have.class', 'active-child'); + }); + }); + + it('goes to the respective level after clicking on non-collapsed sibling group', () => { + // click on non-collapsed sibling group + cy.get('.hierarchy-mobile') + .prev() + .click(); + + // should take you to that level + cy.get('.hierarchy-mobile').find('li.level .node-card').should('have.length', 2); + }); + + it('edit node navigates to employee master', () => { + cy.get('@employee_records').then(employee_records => { + cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node') + .click(); + + cy.url().should('include', `/employee/${employee_records.message[0]}`); + }); + }); +}); diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 5a6f168876..bd7946a1e1 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -128,6 +128,9 @@ erpnext.HierarchyChartMobile = class { if (this.$hierarchy) this.$hierarchy.remove(); + if (this.$sibling_group) + this.$sibling_group.empty(); + this.$hierarchy = $( `
                  • @@ -173,7 +176,7 @@ erpnext.HierarchyChartMobile = class { if (this.$sibling_group) { const sibling_parent = this.$sibling_group.find('.node-group').attr('data-parent'); - if (node.parent_id !== sibling_parent) + if (node.parent_id !== undefined && node.parent_id != sibling_parent) this.$sibling_group.empty(); } @@ -376,9 +379,10 @@ erpnext.HierarchyChartMobile = class { let node_element = $(`#${node.id}`); node_element.click(function() { - let el = $(this).detach(); + let el = undefined; if (node.is_root) { + el = $(this).detach(); me.$hierarchy.empty(); $(`#connectors`).empty(); me.add_node_to_hierarchy(el, node); @@ -386,6 +390,7 @@ erpnext.HierarchyChartMobile = class { me.remove_levels_after_node(node); me.remove_orphaned_connectors(); } else { + el = $(this).detach(); me.add_node_to_hierarchy(el, node); me.collapse_node(); } @@ -514,10 +519,10 @@ erpnext.HierarchyChartMobile = class { level = $('.hierarchy-mobile > li:eq('+ level + ')'); level.nextAll('li').remove(); - let current_node = level.find(`#${node.id}`); let node_object = this.nodes[node.id]; - + let current_node = level.find(`#${node.id}`).detach(); current_node.removeClass('active-child active-path'); + node_object.expanded = 0; node_object.$children = undefined; diff --git a/erpnext/tests/ui_test_helpers.py b/erpnext/tests/ui_test_helpers.py index 8e67b1cd34..99748dca02 100644 --- a/erpnext/tests/ui_test_helpers.py +++ b/erpnext/tests/ui_test_helpers.py @@ -11,10 +11,11 @@ def create_employee_records(): emp2 = create_employee('Test Employee 2', 'CTO') emp3 = create_employee('Test Employee 3', 'Head of Marketing and Sales', emp1) emp4 = create_employee('Test Employee 4', 'Project Manager', emp2) - emp5 = create_employee('Test Employee 5', 'Analyst', emp3) - emp6 = create_employee('Test Employee 6', 'Software Developer', emp4) + emp5 = create_employee('Test Employee 5', 'Engineer', emp2) + emp6 = create_employee('Test Employee 6', 'Analyst', emp3) + emp7 = create_employee('Test Employee 7', 'Software Developer', emp4) - employees = [emp1, emp2, emp3, emp4, emp5, emp6] + employees = [emp1, emp2, emp3, emp4, emp5, emp6, emp7] return employees def create_company(): From 8961a267f649f487eb9f9e3fc63aa6d40765e193 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 15 Jul 2021 19:32:15 +0530 Subject: [PATCH 28/44] fix: sider --- cypress/integration/test_organizational_chart_desktop.js | 4 ++-- cypress/integration/test_organizational_chart_mobile.js | 8 ++++---- erpnext/tests/ui_test_helpers.py | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index d50d551330..807ef5731a 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -29,7 +29,7 @@ context('Organizational Chart', () => { // check children of first node cy.get('@employee_records').then(employee_records => { // children of 1st root visible - cy.get(`[data-parent="${employee_records.message[0]}"]`).as('child-node') + cy.get(`[data-parent="${employee_records.message[0]}"]`).as('child-node'); cy.get('@child-node') .should('have.length', 1) .should('be.visible'); @@ -39,7 +39,7 @@ context('Organizational Chart', () => { cy.get(`path[data-parent="${employee_records.message[0]}"]`) .should('be.visible') .invoke('attr', 'data-child') - .should('equal', employee_records.message[2]); + .should('equal', employee_records.message[2]); }); }); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 656051289f..f48972bdc6 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -43,7 +43,7 @@ context('Organizational Chart Mobile', () => { // children of active root node cy.get('.hierarchy-mobile').find('.level').first().find('ul.node-children').children() - .should('have.length', 2) + .should('have.length', 2); cy.get(`[data-parent="${employee_records.message[1]}"]`).first().as('child-node'); cy.get('@child-node').should('be.visible'); @@ -56,7 +56,7 @@ context('Organizational Chart Mobile', () => { cy.get(`path[data-parent="${employee_records.message[1]}"]`).as('connectors'); cy.get('@connectors') .should('have.length', 2) - .should('be.visible') + .should('be.visible'); cy.get('@connectors') .first() @@ -104,7 +104,7 @@ context('Organizational Chart Mobile', () => { cy.get('@sibling_group') .should('have.attr', 'data-parent', 'undefined') .should('have.class', 'node-group') - .and('have.class', 'collapsed') + .and('have.class', 'collapsed'); cy.get('@sibling_group').get('.avatar-group').children().as('siblings'); cy.get('@siblings').should('have.length', 1); @@ -137,7 +137,7 @@ context('Organizational Chart Mobile', () => { it('expands sibling group', () => { cy.get('@employee_records').then(employee_records => { // sibling group visible for parent - cy.get(`#${employee_records.message[6]}`).click() + cy.get(`#${employee_records.message[6]}`).click(); cy.get(`#${employee_records.message[3]}`) .next() diff --git a/erpnext/tests/ui_test_helpers.py b/erpnext/tests/ui_test_helpers.py index 99748dca02..f66d69ba23 100644 --- a/erpnext/tests/ui_test_helpers.py +++ b/erpnext/tests/ui_test_helpers.py @@ -1,10 +1,9 @@ import frappe -from frappe import _ from frappe.utils import getdate @frappe.whitelist() def create_employee_records(): - company = create_company() + create_company() create_missing_designation() emp1 = create_employee('Test Employee 1', 'CEO') From 2c3866a53ea29c20029b5dba75f5184a490b36ff Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 16 Jul 2021 10:32:38 +0530 Subject: [PATCH 29/44] ci(cypress): use env variable for key documentation ref: https://docs.cypress.io/guides/guides/command-line\#cypress-run --- .github/workflows/ui-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 412a05b0a1..0f13e653ec 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -101,7 +101,7 @@ jobs: - name: UI Tests run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless env: - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + CYPRESS_RECORD_KEY: 60a8e3bf-08f5-45b1-9269-2b207d7d30cd - name: Show bench console if tests failed if: ${{ failure() }} From 9d89b2afcf2cb2cffa8857dc66b0289a165b711b Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 19 Jul 2021 15:47:31 +0530 Subject: [PATCH 30/44] fix: UI tests --- cypress.json | 2 +- cypress/integration/test_organizational_chart_desktop.js | 6 ++++-- cypress/integration/test_organizational_chart_mobile.js | 7 ++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cypress.json b/cypress.json index 2f5026f62c..afcd657c53 100644 --- a/cypress.json +++ b/cypress.json @@ -1,5 +1,5 @@ { - "baseUrl": "http://test_site:8000/", + "baseUrl": "http://test_site:8000", "projectId": "da59y9", "adminPassword": "admin", "defaultCommandTimeout": 20000, diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 807ef5731a..b11b9ea6ab 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -2,9 +2,11 @@ context('Organizational Chart', () => { before(() => { cy.login(); cy.visit('/app/website'); + cy.awesomebar('Organizational Chart'); + + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input').type('Test Org Chart'); - cy.visit(`app/organizational-chart`); - cy.fill_field('company', 'Test Org Chart'); cy.get('body').click(); cy.wait(500); }); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index f48972bdc6..a42562ff2e 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -3,10 +3,11 @@ context('Organizational Chart Mobile', () => { cy.login(); cy.viewport(375, 667); cy.visit('/app/website'); + cy.awesomebar('Organizational Chart'); + + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input').type('Test Org Chart'); - cy.visit(`app/organizational-chart`); - cy.wait(500); - cy.fill_field('company', 'Test Org Chart'); cy.get('body').click(); cy.wait(500); }); From 7270ab5c2057ec21b2baf37daf937905f1886dc1 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 19 Jul 2021 16:26:17 +0530 Subject: [PATCH 31/44] fix(tests): clear filter before typing --- cypress/integration/test_organizational_chart_desktop.js | 2 +- cypress/integration/test_organizational_chart_mobile.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index b11b9ea6ab..516d254b81 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -5,7 +5,7 @@ context('Organizational Chart', () => { cy.awesomebar('Organizational Chart'); cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input').type('Test Org Chart'); + cy.get('@input').clear().type('Test Org Chart'); cy.get('body').click(); cy.wait(500); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index a42562ff2e..503db68c18 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -6,7 +6,7 @@ context('Organizational Chart Mobile', () => { cy.awesomebar('Organizational Chart'); cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input').type('Test Org Chart'); + cy.get('@input').clear().type('Test Org Chart'); cy.get('body').click(); cy.wait(500); From 6e46be5058be06ecbd180f507f8267c1bc44b07c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 19 Jul 2021 17:03:17 +0530 Subject: [PATCH 32/44] fix(tests): apply filters correctly --- .../test_organizational_chart_desktop.js | 7 ++++--- .../test_organizational_chart_mobile.js | 21 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 516d254b81..cb12eb5c0c 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -5,14 +5,15 @@ context('Organizational Chart', () => { cy.awesomebar('Organizational Chart'); cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input').clear().type('Test Org Chart'); + cy.get('@input').clear().wait(200).type('Test Org Chart'); + cy.get('@input').type('{enter}', { delay: 100 }); + cy.get('@input').blur(); - cy.get('body').click(); cy.wait(500); }); beforeEach(() => { - cy.window().its('frappe').then(frappe => { + return cy.window().its('frappe').then(frappe => { return frappe.call('erpnext.tests.ui_test_helpers.create_employee_records'); }).as('employee_records'); }); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 503db68c18..a1d3c0083c 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -6,9 +6,10 @@ context('Organizational Chart Mobile', () => { cy.awesomebar('Organizational Chart'); cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input').clear().type('Test Org Chart'); + cy.get('@input').clear().wait(200).type('Test Org Chart'); + cy.get('@input').type('{enter}', { delay: 100 }); + cy.get('@input').blur(); - cy.get('body').click(); cy.wait(500); }); @@ -16,7 +17,7 @@ context('Organizational Chart Mobile', () => { cy.viewport(375, 667); cy.wait(500); - cy.window().its('frappe').then(frappe => { + return cy.window().its('frappe').then(frappe => { return frappe.call('erpnext.tests.ui_test_helpers.create_employee_records'); }).as('employee_records'); }); @@ -163,13 +164,15 @@ context('Organizational Chart Mobile', () => { }); it('goes to the respective level after clicking on non-collapsed sibling group', () => { - // click on non-collapsed sibling group - cy.get('.hierarchy-mobile') - .prev() - .click(); + cy.get('@employee_records').then(() => { + // click on non-collapsed sibling group + cy.get('.hierarchy-mobile') + .prev() + .click(); - // should take you to that level - cy.get('.hierarchy-mobile').find('li.level .node-card').should('have.length', 2); + // should take you to that level + cy.get('.hierarchy-mobile').find('li.level .node-card').should('have.length', 2); + }); }); it('edit node navigates to employee master', () => { From e327148edf315dec5a284868a9013e2e8a1a04eb Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 19 Jul 2021 17:34:15 +0530 Subject: [PATCH 33/44] fix: tests --- cypress/integration/test_organizational_chart_desktop.js | 6 +++--- cypress/integration/test_organizational_chart_mobile.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index cb12eb5c0c..95592e2f6a 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -4,10 +4,10 @@ context('Organizational Chart', () => { cy.visit('/app/website'); cy.awesomebar('Organizational Chart'); - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input').clear().wait(200).type('Test Org Chart'); + cy.get('.frappe-control[data-fieldname=company] input').first().focus().as('input'); + cy.get('@input').clear().wait(200).type('Test Org Chart', { force: true }); cy.get('@input').type('{enter}', { delay: 100 }); - cy.get('@input').blur(); + cy.get('@input').blur({ force: true }); cy.wait(500); }); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index a1d3c0083c..632d15ba6c 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -5,10 +5,10 @@ context('Organizational Chart Mobile', () => { cy.visit('/app/website'); cy.awesomebar('Organizational Chart'); - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input').clear().wait(200).type('Test Org Chart'); + cy.get('.frappe-control[data-fieldname=company] input').first().focus().as('input'); + cy.get('@input').clear().wait(200).type('Test Org Chart', { force: true }); cy.get('@input').type('{enter}', { delay: 100 }); - cy.get('@input').blur(); + cy.get('@input').blur({ force: true }); cy.wait(500); }); From 89c5bb6066737418a01c18ec202c8fa78424a9b2 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 19 Jul 2021 22:19:28 +0530 Subject: [PATCH 34/44] fix: tests --- .../test_organizational_chart_desktop.js | 31 +++++++--------- .../test_organizational_chart_mobile.js | 37 ++++++++----------- erpnext/tests/ui_test_helpers.py | 6 +++ 3 files changed, 34 insertions(+), 40 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 95592e2f6a..0493732812 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -1,21 +1,17 @@ context('Organizational Chart', () => { before(() => { cy.login(); - cy.visit('/app/website'); + cy.visit('/app'); + + cy.call('erpnext.tests.ui_test_helpers.create_employee_records'); cy.awesomebar('Organizational Chart'); - cy.get('.frappe-control[data-fieldname=company] input').first().focus().as('input'); - cy.get('@input').clear().wait(200).type('Test Org Chart', { force: true }); - cy.get('@input').type('{enter}', { delay: 100 }); + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart', { force: true }); + cy.wait(200); cy.get('@input').blur({ force: true }); - - cy.wait(500); - }); - - beforeEach(() => { - return cy.window().its('frappe').then(frappe => { - return frappe.call('erpnext.tests.ui_test_helpers.create_employee_records'); - }).as('employee_records'); }); it('renders root nodes and loads children for the first expandable node', () => { @@ -29,8 +25,7 @@ context('Organizational Chart', () => { cy.get('@first-child').get('.node-info').find('.node-title').contains('CEO'); cy.get('@first-child').get('.node-info').find('.node-connections').contains('· 2 Connections'); - // check children of first node - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { // children of 1st root visible cy.get(`[data-parent="${employee_records.message[0]}"]`).as('child-node'); cy.get('@child-node') @@ -47,7 +42,7 @@ context('Organizational Chart', () => { }); it('hides active nodes children and connectors on expanding sibling node', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { // click sibling cy.get(`#${employee_records.message[1]}`) .click() @@ -60,7 +55,7 @@ context('Organizational Chart', () => { }); it('collapses previous level nodes and refreshes connectors on expanding child node', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { // click child node cy.get(`#${employee_records.message[3]}`) .click() @@ -81,7 +76,7 @@ context('Organizational Chart', () => { }); it('expands previous level nodes', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { cy.get(`#${employee_records.message[0]}`) .click() .should('have.class', 'active'); @@ -95,7 +90,7 @@ context('Organizational Chart', () => { }); it('edit node navigates to employee master', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node') .click(); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 632d15ba6c..1dcfbcfeb1 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -2,24 +2,17 @@ context('Organizational Chart Mobile', () => { before(() => { cy.login(); cy.viewport(375, 667); - cy.visit('/app/website'); + cy.visit('/app'); + + cy.call('erpnext.tests.ui_test_helpers.create_employee_records'); cy.awesomebar('Organizational Chart'); - cy.get('.frappe-control[data-fieldname=company] input').first().focus().as('input'); - cy.get('@input').clear().wait(200).type('Test Org Chart', { force: true }); - cy.get('@input').type('{enter}', { delay: 100 }); + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart', { force: true }); + cy.wait(200); cy.get('@input').blur({ force: true }); - - cy.wait(500); - }); - - beforeEach(() => { - cy.viewport(375, 667); - cy.wait(500); - - return cy.window().its('frappe').then(frappe => { - return frappe.call('erpnext.tests.ui_test_helpers.create_employee_records'); - }).as('employee_records'); }); it('renders root nodes', () => { @@ -35,7 +28,7 @@ context('Organizational Chart Mobile', () => { }); it('expands root node', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { cy.get(`#${employee_records.message[1]}`) .click() .should('have.class', 'active'); @@ -68,7 +61,7 @@ context('Organizational Chart Mobile', () => { }); it('expands child node', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { cy.get(`#${employee_records.message[3]}`) .click() .should('have.class', 'active') @@ -97,7 +90,7 @@ context('Organizational Chart Mobile', () => { }); it('renders sibling group', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { // sibling group visible for parent cy.get(`#${employee_records.message[1]}`) .next() @@ -118,7 +111,7 @@ context('Organizational Chart Mobile', () => { }); it('expands previous level nodes', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { cy.get(`#${employee_records.message[6]}`) .click() .should('have.class', 'active'); @@ -137,7 +130,7 @@ context('Organizational Chart Mobile', () => { }); it('expands sibling group', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { // sibling group visible for parent cy.get(`#${employee_records.message[6]}`).click(); @@ -164,7 +157,7 @@ context('Organizational Chart Mobile', () => { }); it('goes to the respective level after clicking on non-collapsed sibling group', () => { - cy.get('@employee_records').then(() => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(() => { // click on non-collapsed sibling group cy.get('.hierarchy-mobile') .prev() @@ -176,7 +169,7 @@ context('Organizational Chart Mobile', () => { }); it('edit node navigates to employee master', () => { - cy.get('@employee_records').then(employee_records => { + cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node') .click(); diff --git a/erpnext/tests/ui_test_helpers.py b/erpnext/tests/ui_test_helpers.py index f66d69ba23..fc3aa29824 100644 --- a/erpnext/tests/ui_test_helpers.py +++ b/erpnext/tests/ui_test_helpers.py @@ -17,6 +17,12 @@ def create_employee_records(): employees = [emp1, emp2, emp3, emp4, emp5, emp6, emp7] return employees +@frappe.whitelist() +def get_employee_records(): + return frappe.db.get_list('Employee', filters={ + 'company': 'Test Org Chart' + }, pluck='name', order_by='name') + def create_company(): company = frappe.db.exists('Company', 'Test Org Chart') if not company: From 7176c0847e6aeb10dd0f68e501707074cf3345e1 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 19 Jul 2021 23:34:02 +0530 Subject: [PATCH 35/44] fix: tests --- .../test_organizational_chart_desktop.js | 19 +++++++++---------- .../test_organizational_chart_mobile.js | 19 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 0493732812..52863f18e0 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -1,17 +1,16 @@ context('Organizational Chart', () => { before(() => { cy.login(); - cy.visit('/app'); - - cy.call('erpnext.tests.ui_test_helpers.create_employee_records'); + cy.visit('/app/website'); cy.awesomebar('Organizational Chart'); - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input') - .clear({ force: true }) - .type('Test Org Chart', { force: true }); - cy.wait(200); - cy.get('@input').blur({ force: true }); + cy.call('erpnext.tests.ui_test_helpers.create_employee_records').then(() => { + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart{enter}', { force: true }) + .blur({ force: true }); + }); }); it('renders root nodes and loads children for the first expandable node', () => { @@ -27,7 +26,7 @@ context('Organizational Chart', () => { cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => { // children of 1st root visible - cy.get(`[data-parent="${employee_records.message[0]}"]`).as('child-node'); + cy.get(`div[data-parent="${employee_records.message[0]}"]`).as('child-node'); cy.get('@child-node') .should('have.length', 1) .should('be.visible'); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 1dcfbcfeb1..2272a31046 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -2,17 +2,16 @@ context('Organizational Chart Mobile', () => { before(() => { cy.login(); cy.viewport(375, 667); - cy.visit('/app'); - - cy.call('erpnext.tests.ui_test_helpers.create_employee_records'); + cy.visit('/app/website'); cy.awesomebar('Organizational Chart'); - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input') - .clear({ force: true }) - .type('Test Org Chart', { force: true }); - cy.wait(200); - cy.get('@input').blur({ force: true }); + cy.call('erpnext.tests.ui_test_helpers.create_employee_records').then(() => { + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart{enter}', { force: true }) + .blur({ force: true }); + }); }); it('renders root nodes', () => { @@ -40,7 +39,7 @@ context('Organizational Chart Mobile', () => { cy.get('.hierarchy-mobile').find('.level').first().find('ul.node-children').children() .should('have.length', 2); - cy.get(`[data-parent="${employee_records.message[1]}"]`).first().as('child-node'); + cy.get(`div[data-parent="${employee_records.message[1]}"]`).first().as('child-node'); cy.get('@child-node').should('be.visible'); cy.get('@child-node') From eb65ce662a5bdde4bd8a54a8268363d2651a3c09 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 20 Jul 2021 10:23:52 +0530 Subject: [PATCH 36/44] fix(test): increase timeout for record creation --- .../test_organizational_chart_desktop.js | 27 ++++++++++++++----- .../test_organizational_chart_mobile.js | 27 ++++++++++++++----- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 52863f18e0..0da4e560a7 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -4,12 +4,27 @@ context('Organizational Chart', () => { cy.visit('/app/website'); cy.awesomebar('Organizational Chart'); - cy.call('erpnext.tests.ui_test_helpers.create_employee_records').then(() => { - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input') - .clear({ force: true }) - .type('Test Org Chart{enter}', { force: true }) - .blur({ force: true }); + cy.window().its('frappe.csrf_token').then(csrf_token => { + return cy.request({ + url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Frappe-CSRF-Token': csrf_token + }, + timeout: 60000 + }) + .then(res => { + expect(res.status).eq(200); + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart{enter}', { force: true }) + .blur({ force: true }); + + cy.get('body').click(); + }); }); }); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 2272a31046..0374678a1a 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -5,12 +5,27 @@ context('Organizational Chart Mobile', () => { cy.visit('/app/website'); cy.awesomebar('Organizational Chart'); - cy.call('erpnext.tests.ui_test_helpers.create_employee_records').then(() => { - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input') - .clear({ force: true }) - .type('Test Org Chart{enter}', { force: true }) - .blur({ force: true }); + cy.window().its('frappe.csrf_token').then(csrf_token => { + return cy.request({ + url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Frappe-CSRF-Token': csrf_token + }, + timeout: 60000 + }) + .then(res => { + expect(res.status).eq(200); + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart{enter}', { force: true }) + .blur({ force: true }); + + cy.get('body').click(); + }); }); }); From 41dd0c5a8a75d674f151f09fe9412fdd42347d82 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 20 Jul 2021 10:55:05 +0530 Subject: [PATCH 37/44] fix: sider --- .../test_organizational_chart_desktop.js | 3 +- .../test_organizational_chart_mobile.js | 36 +++++++++---------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 0da4e560a7..57b7f7dced 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -14,8 +14,7 @@ context('Organizational Chart', () => { 'X-Frappe-CSRF-Token': csrf_token }, timeout: 60000 - }) - .then(res => { + }).then(res => { expect(res.status).eq(200); cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); cy.get('@input') diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 0374678a1a..214229f6f6 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -7,25 +7,25 @@ context('Organizational Chart Mobile', () => { cy.window().its('frappe.csrf_token').then(csrf_token => { return cy.request({ - url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`, - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Frappe-CSRF-Token': csrf_token - }, - timeout: 60000 - }) - .then(res => { - expect(res.status).eq(200); - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input') - .clear({ force: true }) - .type('Test Org Chart{enter}', { force: true }) - .blur({ force: true }); + url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Frappe-CSRF-Token': csrf_token + }, + timeout: 60000 + }) + .then(res => { + expect(res.status).eq(200); + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart{enter}', { force: true }) + .blur({ force: true }); - cy.get('body').click(); - }); + cy.get('body').click(); + }); }); }); From 0222ee03580a37cc304b70a7e698d0fcc416c686 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 20 Jul 2021 12:19:44 +0530 Subject: [PATCH 38/44] fix: sider --- .../test_organizational_chart_desktop.js | 2 -- .../test_organizational_chart_mobile.js | 35 +++++++++---------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 57b7f7dced..fb46bbb433 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -21,8 +21,6 @@ context('Organizational Chart', () => { .clear({ force: true }) .type('Test Org Chart{enter}', { force: true }) .blur({ force: true }); - - cy.get('body').click(); }); }); }); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 214229f6f6..df90dbfa22 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -7,25 +7,22 @@ context('Organizational Chart Mobile', () => { cy.window().its('frappe.csrf_token').then(csrf_token => { return cy.request({ - url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`, - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Frappe-CSRF-Token': csrf_token - }, - timeout: 60000 - }) - .then(res => { - expect(res.status).eq(200); - cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); - cy.get('@input') - .clear({ force: true }) - .type('Test Org Chart{enter}', { force: true }) - .blur({ force: true }); - - cy.get('body').click(); - }); + url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Frappe-CSRF-Token': csrf_token + }, + timeout: 60000 + }).then(res => { + expect(res.status).eq(200); + cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); + cy.get('@input') + .clear({ force: true }) + .type('Test Org Chart{enter}', { force: true }) + .blur({ force: true }); + }); }); }); From 117676175728e3f34edbfea9b989da26ba9a6e84 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 21 Jul 2021 23:19:47 +0530 Subject: [PATCH 39/44] feat: Expand All nodes option in Desktop view --- .../hierarchy_chart_desktop.js | 152 +++++++++++++++--- erpnext/public/scss/hierarchy_chart.scss | 1 + erpnext/utilities/hierarchy_chart.py | 29 ++++ 3 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 erpnext/utilities/hierarchy_chart.py diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index fe4d17c210..694c26567a 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -36,7 +36,11 @@ erpnext.HierarchyChart = class { me.nodes[this.id] = this; me.make_node_element(this); - me.setup_node_click_action(this); + + if (!me.all_nodes_expanded) { + me.setup_node_click_action(this); + } + me.setup_edit_node_action(this); } }; @@ -60,8 +64,9 @@ erpnext.HierarchyChart = class { show() { frappe.breadcrumbs.add('HR'); - let me = this; + this.setup_actions(); if ($(`[data-fieldname="company"]`).length) return; + let me = this; let company = this.page.add_field({ fieldtype: 'Link', @@ -79,20 +84,9 @@ erpnext.HierarchyChart = class { // svg for connectors me.make_svg_markers(); - - if (me.$hierarchy) - me.$hierarchy.remove(); - - // setup hierarchy - me.$hierarchy = $( - `
                      -
                    • -
                        -
                      • -
                      `); - - me.page.main.append(me.$hierarchy); + me.setup_hierarchy() me.render_root_nodes(); + me.all_nodes_expanded = false; } } }); @@ -101,6 +95,42 @@ erpnext.HierarchyChart = class { $(`[data-fieldname="company"]`).trigger('change'); } + setup_actions() { + let me = this; + this.page.add_inner_button(__('Expand All'), function() { + me.load_children(me.root_node, true); + me.all_nodes_expanded = true; + + me.page.remove_inner_button(__('Expand All')); + me.page.add_inner_button(__('Collapse All'), function() { + me.setup_hierarchy(); + me.render_root_nodes(); + me.all_nodes_expanded = false; + + me.page.remove_inner_button(__('Collapse All')); + me.setup_actions(); + }); + }); + } + + setup_hierarchy() { + if (this.$hierarchy) + this.$hierarchy.remove(); + + $(`#connectors`).empty(); + + // setup hierarchy + this.$hierarchy = $( + `
                        +
                      • +
                          +
                        • +
                        `); + + this.page.main.append(this.$hierarchy); + this.nodes = {}; + } + make_svg_markers() { $('#arrows').remove(); @@ -126,7 +156,7 @@ erpnext.HierarchyChart = class { `); } - render_root_nodes() { + render_root_nodes(expanded_view=false) { let me = this; frappe.call({ @@ -156,7 +186,10 @@ erpnext.HierarchyChart = class { expand_node = node; }); - me.expand_node(expand_node); + if (!expanded_view) { + me.root_node = expand_node; + me.expand_node(expand_node); + } } }); } @@ -196,11 +229,20 @@ erpnext.HierarchyChart = class { $(`#${node.parent_id}`).addClass('active-path'); } - load_children(node) { - frappe.run_serially([ - () => this.get_child_nodes(node.id), - (child_nodes) => this.render_child_nodes(node, child_nodes) - ]); + load_children(node, deep=false) { + if (!deep) { + frappe.run_serially([ + () => this.get_child_nodes(node.id), + (child_nodes) => this.render_child_nodes(node, child_nodes) + ]); + } else { + frappe.run_serially([ + () => this.setup_hierarchy(), + () => this.render_root_nodes(true), + () => this.get_all_nodes(node.id, node.name), + (data_list) => this.render_children_of_all_nodes(data_list) + ]); + } } get_child_nodes(node_id) { @@ -247,6 +289,70 @@ erpnext.HierarchyChart = class { node.expanded = true; } + get_all_nodes(node_id, node_name) { + return new Promise(resolve => { + frappe.call({ + method: 'erpnext.utilities.hierarchy_chart.get_all_nodes', + args: { + method: this.method, + company: this.company, + parent: node_id, + parent_name: node_name + }, + callback: (r) => { + resolve(r.message); + } + }); + }); + } + + render_children_of_all_nodes(data_list) { + let entry = undefined; + let node = undefined; + + while(data_list.length) { + // to avoid overlapping connectors + entry = data_list.shift(); + node = this.nodes[entry.parent]; + if (node) { + this.render_child_nodes_for_expanded_view(node, entry.data); + } else { + data_list.push(entry); + } + } + } + + render_child_nodes_for_expanded_view(node, child_nodes) { + node.$children = $('
                          ') + + const last_level = this.$hierarchy.find('.level:last').index(); + const node_level = $(`#${node.id}`).parent().parent().parent().index(); + + if (last_level === node_level) { + this.$hierarchy.append(` +
                        • + `); + node.$children.appendTo(this.$hierarchy.find('.level:last')); + } else { + node.$children.appendTo(this.$hierarchy.find('.level:eq(' + (node_level + 1) + ')')); + } + + node.$children.hide().empty(); + + if (child_nodes) { + $.each(child_nodes, (_i, data) => { + this.add_node(node, data); + setTimeout(() => { + this.add_connector(node.id, data.id); + }, 250); + }); + } + + node.$children.show(); + $(`path[data-parent="${node.id}"]`).show(); + node.expanded = true; + } + add_node(node, data) { return new this.Node({ id: data.id, @@ -333,7 +439,7 @@ erpnext.HierarchyChart = class { path.setAttribute("class", "active-connector"); path.setAttribute("marker-start", "url(#arrowstart-active)"); path.setAttribute("marker-end", "url(#arrowhead-active)"); - } else if (parent.hasClass('active-path')) { + } else { path.setAttribute("class", "collapsed-connector"); path.setAttribute("marker-start", "url(#arrowstart-collapsed)"); path.setAttribute("marker-end", "url(#arrowhead-collapsed)"); diff --git a/erpnext/public/scss/hierarchy_chart.scss b/erpnext/public/scss/hierarchy_chart.scss index dd523c3443..1c2f9421fa 100644 --- a/erpnext/public/scss/hierarchy_chart.scss +++ b/erpnext/public/scss/hierarchy_chart.scss @@ -194,6 +194,7 @@ .level { margin-right: 8px; align-items: flex-start; + flex-direction: column; } #arrows { diff --git a/erpnext/utilities/hierarchy_chart.py b/erpnext/utilities/hierarchy_chart.py new file mode 100644 index 0000000000..9b0279351f --- /dev/null +++ b/erpnext/utilities/hierarchy_chart.py @@ -0,0 +1,29 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ + +@frappe.whitelist() +def get_all_nodes(parent, parent_name, method, company): + '''Recursively gets all data from nodes''' + method = frappe.get_attr(method) + + if not method in frappe.whitelisted: + frappe.throw(_('Not Permitted'), frappe.PermissionError) + + data = method(parent, company) + result = [dict(parent=parent, parent_name=parent_name, data=data)] + + nodes_to_expand = [{'id': d.get('id'), 'name': d.get('name')} for d in data if d.get('expandable')] + + while nodes_to_expand: + parent = nodes_to_expand.pop(0) + data = method(parent.get('id'), company) + result.append(dict(parent=parent.get('id'), parent_name=parent.get('name'), data=data)) + for d in data: + if d.get('expandable'): + nodes_to_expand.append({'id': d.get('id'), 'name': d.get('name')}) + + return result \ No newline at end of file From 57cb3ac023544c618b2c7c9fa5927a81cd31a848 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 25 Jul 2021 20:23:20 +0530 Subject: [PATCH 40/44] feat: add html2canvas for easily exporting html to images using canvas --- .eslintrc | 1 + package.json | 3 ++- yarn.lock | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index ecfaab23ee..46fb354c11 100644 --- a/.eslintrc +++ b/.eslintrc @@ -154,6 +154,7 @@ "before": true, "beforeEach": true, "onScan": true, + "html2canvas": true, "extend_cscript": true, "localforage": true } diff --git a/package.json b/package.json index c9ee7a622c..5bc1e56a21 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "snyk": "^1.518.0" }, "dependencies": { - "onscan.js": "^1.5.2" + "onscan.js": "^1.5.2", + "html2canvas": "^1.1.4" }, "scripts": { "snyk-protect": "snyk protect", diff --git a/yarn.lock b/yarn.lock index 0a2ac1affc..cc01d89344 100644 --- a/yarn.lock +++ b/yarn.lock @@ -688,6 +688,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-arraybuffer@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45" + integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -997,6 +1002,13 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +css-line-break@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-1.1.1.tgz#d5e9bdd297840099eb0503c7310fd34927a026ef" + integrity sha512-1feNVaM4Fyzdj4mKPIQNL2n70MmuYzAXZ1aytlROFX1JsOo070OsugwGjj7nl6jnDJWHDM8zRZswkmeYVWZJQA== + dependencies: + base64-arraybuffer "^0.2.0" + debug@^3.1.0, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" @@ -1472,6 +1484,13 @@ hosted-git-info@^3.0.4, hosted-git-info@^3.0.7: dependencies: lru-cache "^6.0.0" +html2canvas@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.1.4.tgz#53ae91cd26e9e9e623c56533cccb2e3f57c8124c" + integrity sha512-uHgQDwrXsRmFdnlOVFvHin9R7mdjjZvoBoXxicPR+NnucngkaLa5zIDW9fzMkiip0jSffyTyWedE8iVogYOeWg== + dependencies: + css-line-break "1.1.1" + http-cache-semantics@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" From 37198159aaaecb86b5bbcb4528b935922eb11f3c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 25 Jul 2021 20:28:01 +0530 Subject: [PATCH 41/44] feat: Export chart option in desktop view --- .../hierarchy_chart_desktop.js | 97 +++++++++++++------ erpnext/public/scss/hierarchy_chart.scss | 10 +- erpnext/utilities/hierarchy_chart.py | 4 +- 3 files changed, 82 insertions(+), 29 deletions(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 694c26567a..57d34d8225 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -1,3 +1,4 @@ +import html2canvas from 'html2canvas'; erpnext.HierarchyChart = class { /* Options: - doctype @@ -11,16 +12,20 @@ erpnext.HierarchyChart = class { this.method = method; this.doctype = doctype; + this.setup_page_style(); + this.page.main.addClass('frappe-card'); + + this.nodes = {}; + this.setup_node_class(); + } + + setup_page_style() { this.page.main.css({ 'min-height': '300px', 'max-height': '600px', 'overflow': 'auto', 'position': 'relative' }); - this.page.main.addClass('frappe-card'); - - this.nodes = {}; - this.setup_node_class(); } setup_node_class() { @@ -84,7 +89,7 @@ erpnext.HierarchyChart = class { // svg for connectors me.make_svg_markers(); - me.setup_hierarchy() + me.setup_hierarchy(); me.render_root_nodes(); me.all_nodes_expanded = false; } @@ -97,6 +102,10 @@ erpnext.HierarchyChart = class { setup_actions() { let me = this; + this.page.add_inner_button(__('Export'), function() { + me.export_chart(); + }); + this.page.add_inner_button(__('Expand All'), function() { me.load_children(me.root_node, true); me.all_nodes_expanded = true; @@ -113,6 +122,36 @@ erpnext.HierarchyChart = class { }); } + export_chart() { + this.page.main.css({ + 'min-height': '', + 'max-height': '', + 'overflow': 'visible', + 'position': 'fixed', + 'left': '0', + 'top': '0' + }); + + $('.node-card').addClass('exported'); + + html2canvas(document.querySelector('#hierarchy-chart-wrapper'), { + scrollY: -window.scrollY, + scrollX: 0 + }).then(function(canvas) { + // Export the canvas to its data URI representation + let dataURL = canvas.toDataURL('image/png'); + + // download the image + let a = document.createElement('a'); + a.href = dataURL; + a.download = 'hierarchy_chart'; + a.click(); + }); + + this.setup_page_style(); + $('.node-card').removeClass('exported'); + } + setup_hierarchy() { if (this.$hierarchy) this.$hierarchy.remove(); @@ -127,33 +166,37 @@ erpnext.HierarchyChart = class {
                        `); - this.page.main.append(this.$hierarchy); + this.page.main + .find('#hierarchy-chart-wrapper') + .append(this.$hierarchy); this.nodes = {}; } make_svg_markers() { $('#arrows').remove(); - this.page.main.prepend(` - - - - - - - - + this.page.main.append(` +
                        + + + + + + + + - - - - - - - - - - `); + + + + + + + + + + +
                        `); } render_root_nodes(expanded_view=false) { @@ -310,7 +353,7 @@ erpnext.HierarchyChart = class { let entry = undefined; let node = undefined; - while(data_list.length) { + while (data_list.length) { // to avoid overlapping connectors entry = data_list.shift(); node = this.nodes[entry.parent]; @@ -323,7 +366,7 @@ erpnext.HierarchyChart = class { } render_child_nodes_for_expanded_view(node, child_nodes) { - node.$children = $('
                          ') + node.$children = $('
                            '); const last_level = this.$hierarchy.find('.level:last').index(); const node_level = $(`#${node.id}`).parent().parent().parent().index(); diff --git a/erpnext/public/scss/hierarchy_chart.scss b/erpnext/public/scss/hierarchy_chart.scss index 1c2f9421fa..44288fe155 100644 --- a/erpnext/public/scss/hierarchy_chart.scss +++ b/erpnext/public/scss/hierarchy_chart.scss @@ -21,6 +21,10 @@ } } +.node-card.exported { + box-shadow: none +} + .node-image { width: 3.0rem; height: 3.0rem; @@ -178,9 +182,12 @@ } // horizontal hierarchy tree view +#hierarchy-chart-wrapper { + padding-top: 30px; +} + .hierarchy { display: flex; - padding-top: 30px; } .hierarchy li { @@ -200,6 +207,7 @@ #arrows { position: absolute; overflow: visible; + margin-top: -80px; } .active-connector { diff --git a/erpnext/utilities/hierarchy_chart.py b/erpnext/utilities/hierarchy_chart.py index 9b0279351f..22d3f28faa 100644 --- a/erpnext/utilities/hierarchy_chart.py +++ b/erpnext/utilities/hierarchy_chart.py @@ -3,14 +3,16 @@ from __future__ import unicode_literals import frappe +import os from frappe import _ +from frappe.utils.pdf import get_pdf @frappe.whitelist() def get_all_nodes(parent, parent_name, method, company): '''Recursively gets all data from nodes''' method = frappe.get_attr(method) - if not method in frappe.whitelisted: + if method not in frappe.whitelisted: frappe.throw(_('Not Permitted'), frappe.PermissionError) data = method(parent, company) From 475d856d6681bebd2586754b0c081735952e841e Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 25 Jul 2021 20:39:51 +0530 Subject: [PATCH 42/44] fix(style): longer titles overflowing --- erpnext/public/scss/hierarchy_chart.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/public/scss/hierarchy_chart.scss b/erpnext/public/scss/hierarchy_chart.scss index 44288fe155..7f1077dbbd 100644 --- a/erpnext/public/scss/hierarchy_chart.scss +++ b/erpnext/public/scss/hierarchy_chart.scss @@ -40,6 +40,10 @@ line-height: 1.35; } +.node-info { + width: 12.7rem; +} + .node-connections { font-size: 0.75rem; line-height: 1.35; From 6bca87ddb9c39cd18abb30bc1b9f4cb5f07b451c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 25 Jul 2021 21:34:51 +0530 Subject: [PATCH 43/44] fix: remove unnecessary imports --- erpnext/utilities/hierarchy_chart.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/erpnext/utilities/hierarchy_chart.py b/erpnext/utilities/hierarchy_chart.py index 22d3f28faa..fb58a5d586 100644 --- a/erpnext/utilities/hierarchy_chart.py +++ b/erpnext/utilities/hierarchy_chart.py @@ -3,9 +3,7 @@ from __future__ import unicode_literals import frappe -import os from frappe import _ -from frappe.utils.pdf import get_pdf @frappe.whitelist() def get_all_nodes(parent, parent_name, method, company): From c676eaae57d353ff9ecd34fe28158e8216e94c9f Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 25 Jul 2021 23:11:18 +0530 Subject: [PATCH 44/44] fix: test --- erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 57d34d8225..89fb8d5792 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -173,7 +173,7 @@ erpnext.HierarchyChart = class { } make_svg_markers() { - $('#arrows').remove(); + $('#hierarchy-chart-wrapper').remove(); this.page.main.append(`