From 16c4a54d423131da741fede168cecb4f9d9181fc Mon Sep 17 00:00:00 2001 From: "Xavier Bol (xbo)" Date: Mon, 28 Aug 2023 15:33:29 +0200 Subject: [PATCH] [ADD] hr_org_chart: add hierarchy view This commit adds a new view called hierarchy view, that view allows the user to visualize a hierarchy in a model. For instance, that view could be used for `hr.employee` to easily visualize the hierarchy inside the company (the manager and his employees) as an Organization Chart. That commit introduces a new method for the hierarchy view, called `hierarchy_read`, that method will be when the view will be loaded to get the initial data to display. Some details for the definition of that new view: ------------------------------------------------ Some attributes can be defined on the `hierarchy` tag: - `parent_field` (default='parent_id') contains the parent field name to use to find the parent record of the current to build the hierarchy. - `child_field` (optional attribute) contains the child field name to use to find the child records (it is in fact the one2many field). If that field is defined then some queries can be avoided since we directly fetch the children when a read/search_read is done. Inside the `hierarchy` tag, the fields to read can be added and then a template as to be defined as we do when we define the kanban view. The template to create is called `hierarchy-box` and that template will be used to display the card inside a node in the hierarchy view. task-3376776 --- addons/hr_org_chart/__manifest__.py | 1 + addons/hr_org_chart/models/__init__.py | 3 + addons/hr_org_chart/models/ir_actions.py | 9 + addons/hr_org_chart/models/ir_ui_view.py | 49 ++ addons/hr_org_chart/models/models.py | 44 + .../views/hierarchy/hierarchy_arch_parser.js | 66 ++ .../src/views/hierarchy/hierarchy_card.js | 67 ++ .../src/views/hierarchy/hierarchy_card.scss | 69 ++ .../src/views/hierarchy/hierarchy_card.xml | 59 ++ .../views/hierarchy/hierarchy_controller.js | 64 ++ .../views/hierarchy/hierarchy_controller.xml | 45 + .../src/views/hierarchy/hierarchy_model.js | 792 ++++++++++++++++++ .../src/views/hierarchy/hierarchy_renderer.js | 41 + .../views/hierarchy/hierarchy_renderer.scss | 64 ++ .../views/hierarchy/hierarchy_renderer.xml | 37 + .../src/views/hierarchy/hierarchy_view.js | 36 + 16 files changed, 1446 insertions(+) create mode 100644 addons/hr_org_chart/models/ir_actions.py create mode 100644 addons/hr_org_chart/models/ir_ui_view.py create mode 100644 addons/hr_org_chart/models/models.py create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_arch_parser.js create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.js create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.scss create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.xml create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.js create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.xml create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_model.js create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.js create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.scss create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.xml create mode 100644 addons/hr_org_chart/static/src/views/hierarchy/hierarchy_view.js diff --git a/addons/hr_org_chart/__manifest__.py b/addons/hr_org_chart/__manifest__.py index ba4038c1888..da05f79ac54 100644 --- a/addons/hr_org_chart/__manifest__.py +++ b/addons/hr_org_chart/__manifest__.py @@ -24,6 +24,7 @@ This module extend the employee form with a organizational chart. ], 'web.assets_backend': [ 'hr_org_chart/static/src/fields/*', + 'hr_org_chart/static/src/views/**/*', ], 'web.qunit_suite_tests': [ 'hr_org_chart/static/tests/**/*', diff --git a/addons/hr_org_chart/models/__init__.py b/addons/hr_org_chart/models/__init__.py index 27f11d1b306..b51ad54cfff 100644 --- a/addons/hr_org_chart/models/__init__.py +++ b/addons/hr_org_chart/models/__init__.py @@ -3,3 +3,6 @@ from . import hr_org_chart_mixin from . import hr_employee +from . import ir_actions +from . import ir_ui_view +from . import models diff --git a/addons/hr_org_chart/models/ir_actions.py b/addons/hr_org_chart/models/ir_actions.py new file mode 100644 index 00000000000..74ae4fa9844 --- /dev/null +++ b/addons/hr_org_chart/models/ir_actions.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ActWindowView(models.Model): + _inherit = 'ir.actions.act_window.view' + + view_mode = fields.Selection(selection_add=[('hierarchy', 'Hierarchy')], ondelete={'hierarchy': 'cascade'}) diff --git a/addons/hr_org_chart/models/ir_ui_view.py b/addons/hr_org_chart/models/ir_ui_view.py new file mode 100644 index 00000000000..052866066e9 --- /dev/null +++ b/addons/hr_org_chart/models/ir_ui_view.py @@ -0,0 +1,49 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from lxml import etree + +from odoo import fields, models, _ + +HIERARCHY_VALID_ATTRIBUTES = { + '__validate__', # ir.ui.view implementation detail + 'class', + 'js_class', + 'string', + 'create', + 'edit', + 'delete', + 'parent_field', + 'child_field', +} + +class View(models.Model): + _inherit = 'ir.ui.view' + + type = fields.Selection(selection_add=[('hierarchy', "Hierarchy")]) + + def _is_qweb_based_view(self, view_type): + return super()._is_qweb_based_view(view_type) or view_type == "hierarchy" + + def _validate_tag_hierarchy(self, node, name_manager, node_info): + if not node_info['validate']: + return + + templates_count = 0 + for child in node.iterchildren(tag=etree.Element): + if child.tag == 'templates': + if not templates_count: + templates_count += 1 + else: + msg = _('Hierarchy view can contain only one templates tag') + self._raise_view_error(msg, child) + elif child.tag != 'field': + msg = _('Hierarchy child can only be field or template, got %s', child.tag) + self._raise_view_error(msg, child) + + remaining = set(node.attrib) - HIERARCHY_VALID_ATTRIBUTES + if remaining: + msg = _( + "Invalid attributes (%s) in hierarchy view. Attributes must be in (%s)", + ','.join(remaining), ','.join(HIERARCHY_VALID_ATTRIBUTES), + ) + self._raise_view_error(msg, node) diff --git a/addons/hr_org_chart/models/models.py b/addons/hr_org_chart/models/models.py new file mode 100644 index 00000000000..e4260f83fac --- /dev/null +++ b/addons/hr_org_chart/models/models.py @@ -0,0 +1,44 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class Base(models.AbstractModel): + _inherit = 'base' + + @api.model + def hierarchy_read(self, domain, fields, parent_field, child_field=None): + if parent_field not in fields: + fields.append(parent_field) + records = self.search(domain) + focus_record = self.env[self._name] + fetch_child_ids_for_all_records = False + if len(records) == 1: + domain = [(parent_field, '=', records.id), ('id', '!=', records.id)] + if records[parent_field]: + focus_record = records + records += focus_record[parent_field] + domain = [('id', 'not in', records.ids), (parent_field, 'in', records.ids)] + records += self.search(domain) + elif not records: + records = self.search([(parent_field, '=', False)]) + else: + fetch_child_ids_for_all_records = True + children_ids_per_record_id = {} + if not child_field: + children_ids_per_record_id = { + record.id: child_ids + for record, child_ids in self._read_group( + [(parent_field, 'in', records.ids if fetch_child_ids_for_all_records else (records - records[parent_field]).ids)], + (parent_field,), + ('id:array_agg',), + ) + } + result = records.read(fields) + if children_ids_per_record_id or focus_record: + for record_data in result: + if record_data['id'] in children_ids_per_record_id: + record_data['__child_ids__'] = children_ids_per_record_id[record_data['id']] + if record_data['id'] == focus_record.id: + record_data['__focus__'] = True + return result diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_arch_parser.js b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_arch_parser.js new file mode 100644 index 00000000000..2e21af269b9 --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_arch_parser.js @@ -0,0 +1,66 @@ +/** @odoo-module */ + +import { visitXML } from "@web/core/utils/xml"; +import { Field } from "@web/views/fields/field"; +import { getActiveActions } from "@web/views/utils"; + +export class HierarchyArchParser { + parse(xmlDoc, models, modelName) { + const archInfo = { + activeActions: getActiveActions(xmlDoc), + parentFieldName: "parent_id", + fieldNodes: {}, + templateDocs: {}, + xmlDoc, + }; + const fieldNextIds = {}; + const fields = models[modelName]; + + visitXML(xmlDoc, (node) => { + if (node.hasAttribute("t-name")) { + archInfo.templateDocs[node.getAttribute("t-name")] = node; + return; + } + if (node.tagName === "hierarchy") { + if (node.hasAttribute("parent_field")) { + const parentFieldName = node.getAttribute("parent_field"); + if (!(parentFieldName in fields)) { + throw new Error(`The parent field set (${parentFieldName}) is not defined in the model (${modelName}).`); + } else if (fields[parentFieldName].type !== "many2one") { + throw new Error(`Invalid parent field, it should be a Many2One field.`); + } else if (fields[parentFieldName].relation !== modelName) { + throw new Error(`Invalid parent field, the co-model should be same model than the current one (expected: ${modelName}).`); + } + archInfo.parentFieldName = parentFieldName; + } + if (node.hasAttribute("child_field")) { + const childFieldName = node.getAttribute("child_field"); + if (!(childFieldName in fields)) { + throw new Error(`The child field set (${childFieldName}) is not defined in the model (${modelName}).`); + } else if (fields[childFieldName].type !== "one2many") { + throw new Error(`Invalid child field, it should be a One2Many field.`); + } else if (fields[childFieldName].relation !== modelName) { + throw new Error(`Invalid child field, the co-model should be same model than the current one (expected: ${modelName}).`); + } + archInfo.childFieldName = childFieldName; + } + } else if (node.tagName === "field") { + const fieldInfo = Field.parseFieldNode(node, models, modelName, "hierarchy"); + const name = fieldInfo.name; + if (!(name in fieldNextIds)) { + fieldNextIds[name] = 0; + } + const fieldId = `${name}_${fieldNextIds[name]++}`; + archInfo.fieldNodes[fieldId] = fieldInfo; + node.setAttribute("field_id", fieldId); + } + }); + + const cardDoc = archInfo.templateDocs["hierarchy-box"]; + if (!cardDoc) { + throw new Error("Missing 'hierarchy-box' template."); + } + + return archInfo; + } +} diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.js b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.js new file mode 100644 index 00000000000..38a48a24018 --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.js @@ -0,0 +1,67 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; + +import { Field } from "@web/views/fields/field"; +import { Record } from "@web/views/record"; +import { ViewButton } from "@web/views/view_button/view_button"; +import { useViewCompiler } from "@web/views/view_compiler"; + +import { OrgChartCompiler } from "./hierarchy_compiler"; +import { getFormattedRecord } from "@web/views/kanban/kanban_record"; + +export class HierarchyCard extends Component { + static components = { + Record, + Field, + ViewButton, + }; + static props = { + node: Object, + openRecord: Function, + archInfo: Object, + templates: Object, + classNames: { type: String, optional: true }, + }; + static defaultProps = { + classNames: "", + }; + static template = "hr_org_chart.HierarchyCard"; + static Compiler = OrgChartCompiler; + + setup() { + const { templates } = this.props; + this.templates = useViewCompiler(this.constructor.Compiler, templates); + } + + getRenderingContext(data) { + const record = getFormattedRecord(data.record); + return { + context: this.props.node.context, + JSON, + luxon, + record, + __comp__: Object.assign(Object.create(this), { this: this }), + __record__: data.record, + }; + } + + onGlobalClick(ev) { + if (ev.target.closest("button")) { + return; + } + this.props.openRecord(this.props.node); + } + + onClickArrowUp(ev) { + this.props.node.fetchParentNode(); + } + + onClickArrowDown(ev) { + if (this.props.node.nodes.length) { + this.props.node.collapseChildNodes(); + } else { + this.props.node.showChildNodes(); + } + } +} diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.scss b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.scss new file mode 100644 index 00000000000..79d8128538b --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.scss @@ -0,0 +1,69 @@ +@mixin o-hierarchy-node-color { + @for $size from 2 through length($o-colors) { + // Note: the first color is not defined as it is the 'no color' for org chart node + .o_hierarchy_node_color_#{$size - 1} { + background-color: nth($o-colors, $size); + } + } +} + +.o_hierarchy_node_container { + width: 250px; + min-height: 130px; + + .o_hierarchy_node_button_container { + height: 30px; + } + + .o_hierarchy_node { + &:hover { + cursor: pointer; + } + + &.o_hierarchy_node_highlighted { + border: 3px solid green; + } + + .o_hierarchy_node_header { + height: 30px; + + div.o_field_background_image { + width: 48px; + height: 48px; + margin-bottom: -24px; + transform: translateY(-24px); + z-index: 1; + + > img { + border-radius: 50%; + } + } + } + + .o_hierarchy_node_body { + height: 65px; + + .o_employee_availability { + position: absolute; + top: 4px; + right: 2px; + } + } + + .o_hierarchy_node_footer { + height: 30px; + } + + .o_hierarchy_node_color_0 { + background-color: $gray-200; + } + + @include o-hierarchy-node-color; + } + + .o_hierarchy_node_button { + display: grid; + grid-template-columns: 50px 1fr 50px; + border-radius: 0; + } +} diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.xml b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.xml new file mode 100644 index 00000000000..9e211062b88 --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.xml @@ -0,0 +1,59 @@ + + + + +
+
+ +
+
+
+ + + +
+ +
+
+
+ +
diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.js b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.js new file mode 100644 index 00000000000..4e83a538fbe --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.js @@ -0,0 +1,64 @@ +/** @odoo-module */ + +import { Component, useRef } from "@odoo/owl"; + +import { useBus } from "@web/core/utils/hooks"; +import { useModel } from "@web/model/model"; +import { addFieldDependencies, extractFieldsFromArchInfo } from "@web/model/relational_model/utils"; +import { CogMenu } from "@web/search/cog_menu/cog_menu"; +import { Layout } from "@web/search/layout"; +import { SearchBar } from "@web/search/search_bar/search_bar"; +import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler"; +import { standardViewProps } from "@web/views/standard_view_props"; +import { useViewButtons } from "@web/views/view_button/view_button_hook"; + +export class HierarchyController extends Component { + static components = { + Layout, + CogMenu, + SearchBar, + }; + static props = { + ...standardViewProps, + Model: Function, + Renderer: Function, + buttonTemplate: String, + archInfo: Object, + }; + static template = "hr_org_chart.HierarchyView"; + + setup() { + this.rootRef = useRef("root"); + const { parentFieldName, childFieldName } = this.props.archInfo; + const { activeFields, fields } = extractFieldsFromArchInfo(this.props.archInfo, this.props.fields); + addFieldDependencies(activeFields, fields, [{ name: parentFieldName }]); + this.model = useModel(this.props.Model, { + resModel: this.props.resModel, + activeFields, + fields, + parentFieldName, + childFieldName, + }); + useBus( + this.model.bus, + "update", + () => { + this.render(true); + } + ); + useViewButtons(this.model, this.rootRef, { + beforeExecuteAction: this.beforeExecuteActionButton.bind(this), + afterExecuteAction: this.afterExecuteActionButton.bind(this), + }); + this.searchBarToggler = useSearchBarToggler(); + } + + async openRecord(node, mode) { + const activeIds = this.model.root.resIds; + this.props.selectRecord(node.resId, { activeIds, mode }); + } + + async beforeExecuteActionButton(clickParams) {} + + async afterExecuteActionButton(clickParams) {} +} diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.xml b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.xml new file mode 100644 index 00000000000..f014ffb3f51 --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_controller.xml @@ -0,0 +1,45 @@ + + + +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_model.js b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_model.js new file mode 100644 index 00000000000..f9d4787ddd9 --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_model.js @@ -0,0 +1,792 @@ +/** @odoo-module */ + +import { Domain } from "@web/core/domain"; +import { KeepLast, Mutex } from "@web/core/utils/concurrency"; +import { Model } from "@web/model/model"; + +let nodeId = 0; +let forestId = 0; +let treeId = 0; + +/** + * Get the id of the given many2one field value + * + * @param {false | [Number, string]} value many2one value + * @returns {false | Number} id of the many2one + */ +function getIdOfMany2oneField(value) { + return value && value[0]; +} + +export class HierarchyNode { + /** + * Constructor of hierarchy node stored in hierarchy tree + * + * @param {HierarchyModel} model + * @param {Object} config + * @param {Object} data + * @param {HierarchyTree} tree + * @param {HierarchyNode} parentNode + * @param {Boolean} populateChildNodes + */ + constructor(model, config, data, tree, parentNode = null, populateChildNodes = true) { + this.id = nodeId++; + this.data = data; + this.parentNode = parentNode; + this.tree = tree; + this.model = model; + this._config = config; + tree.addNode(this); + if (populateChildNodes) { + this.populateChildNodes(); + } + } + + /** + * Get ancestor node + * + * @returns {HierarchyNode} ancestor node + */ + get ancestorNode() { + return this.parentNode ? this.ancestorNode : this; + } + + /** + * Is leaf? + * + * @returns {Boolean} False if the current node has node as child nodes, otherwise True. + */ + get isLeaf() { + return !this.nodes.length; + } + + /** + * Get forest of the current node + * + * @returns {HierarchyForest} + */ + get forest() { + return this.tree.forest; + } + + /** + * Get the resId of current node + * + * @returns {Number} + */ + get resId() { + return this.data.id; + } + + /** + * Is the current node focused? + * + * @returns {Boolean} + */ + get isFocused() { + return Boolean(this.data.__focus__); + } + + /** + * Get parent field name + * + * @returns {String} + */ + get parentFieldName() { + return this.model.parentFieldName; + } + + /** + * Get parent res id + * + * @returns {Number} + */ + get parentResId() { + return this.parentNode?.resId || getIdOfMany2oneField(this.data[this.parentFieldName]); + } + + /** + * Get child node res ids + * + * @returns {Number[]} + */ + get childResIds() { + return this.nodes.length ? this.nodes.map((node) => node.resId) : this.data[this.childFieldName]?.map((d) => typeof d === "number" ? d : d.id) || []; + } + + /** + * Get child field name + * + * @returns {String} + */ + get childFieldName() { + return this.model.childFieldName || this.model.defaultChildFieldName; + } + + /** + * Has child nodes? + * + * @returns {Boolean} + */ + get hasChildren() { + return this.nodes.length > 0 || this.data[this.childFieldName]?.length > 0; + } + + /** + * Can show parent node + * + * Knows if the parent node can be fetched and displayed inside the view + * + * @returns {Boolean} True if the current node has a parent node but it is not yet displayed and the data of the + * current node is not already displayed in another node. + */ + get canShowParentNode() { + return Boolean(this.parentResId) + && !this.parentNode + && this.tree.forest.resIds.filter((resId) => resId === this.resId).length === 1; + } + + + /** + * Can show child nodes + * + * Knows if the child nodes can be fetched and displayed inside the view + * + * @returns {Boolean} True if the current node has child nodes but they are not yet displayed and the data of the + * current node is not already displayed in another node. + */ + get canShowChildNodes() { + return this.hasChildren + && this.nodes.length === 0 + && this.tree.forest.resIds.filter((resId) => resId === this.resId).length === 1; + } + + get descendantNodes() { + const subNodes = []; + if (!this.isLeaf) { + subNodes.push(...this.nodes); + for (const node of this.nodes) { + if (node.descendantNodes.length) { + subNodes.push(...node.descendantNodes); + } + } + } + return subNodes; + } + + /** + * Get all descendants nodes resIds + * + * @returns {Number[]} + */ + get allSubsidiaryResIds() { + return this.descendantNodes.map((n) => n.resId); + } + + /** + * Populate child nodes + * + * Uses to create child nodes of the current one according to its data. + */ + populateChildNodes() { + this.nodes = []; + const children = this.data[this.childFieldName] || []; + if ( + children.length + && children[0] instanceof Object + && this.tree.forest.resIds.filter((resId) => resId === this.resId).length === 1 + ) { + this.createChildNodes(children); + } + } + + /** + * create child nodes + * + * @param {Object[]} childNodesData data of child nodes to generate + */ + createChildNodes(childNodesData) { + this.nodes = (childNodesData || this.data[this.childFieldName]).map( + (childData) => + new HierarchyNode( + this.model, + this._config, + childData, + this.tree, + this + ) + ); + } + + /** + * Fetch parent node + */ + async fetchParentNode() { + await this.model.fetchManager(this); + } + + /** + * Fetch child nodes + */ + async showChildNodes() { + await this.model.fetchSubordinates(this); + } + + /** + * Collapse child nodes + * + * Removes the descendant nodes of the current one and stores + * the resIds of the child nodes in the data of the current one + * to know it has child nodes to be able to show them again + * when it is needed. + */ + collapseChildNodes() { + const childrenData = []; + for (const childNode of this.nodes) { + childNode.data[this.childFieldName] = childNode.childResIds; + childrenData.push(childNode.data); + } + this.data[this.childFieldName] = childrenData; + this.removeChildNodes(); + this.model.notify(); + } + + /** + * Remove descendant nodes of the current one + */ + removeChildNodes() { + for (const childNode of this.nodes) { + if (!childNode.isLeaf) { + childNode.removeChildNodes(); + } + } + this.tree.removeNodes(this.nodes); + this.nodes = []; + } + + /** + * Set parent node to the current node + * + * @param {HierarchyNode} node parent node to set + */ + setParentNode(node) { + this.parentNode = node; + node.addChildNode(this); + const tree = node.tree; + if (tree.root === this) { + tree.root = node; + } else if (this.tree.root === this) { + this.tree.removeRoot(); + this.setTree(node.tree); + } + } + + setTree(tree) { + this.tree = tree; + for (const childNode of this.nodes) { + childNode.setTree(tree); + } + } + + /** + * Adds child node to the current node + * + * @param {HierarchyNode} node child node to add + */ + addChildNode(node) { + this.nodes.push(node); + this.data[this.childFieldName].push(node.data); + this.tree.addNode(node); + } +} + +export class HierarchyTree { + /** + * Constructor + * + * @param {HierarchyModel} model + * @param {Object} config config of the model + * @param {Object} data root node data of the tree to create + * @param {HierarchyForest} forest hierarchy forest containing the tree to create + */ + constructor(model, config, data, forest) { + this.id = treeId++; + this.nodePerNodeId = {}; + this.forest = forest; + this.root = new HierarchyNode(model, config, data, this); + this.forest.nodePerNodeId = { + ...this.forest.nodePerNodeId, + ...this.nodePerNodeId, + }; + this.model = model; + this._config = config; + } + + /** + * Get node res ids inside the current tree + * + * @returns {Number} + */ + get resIds() { + return Object.values(this.nodePerNodeId).map((node) => node.resId); + } + + /** + * Add node inside the current tree + * + * @param {HierarchyNode} node node to add inside the current tree + */ + addNode(node) { + this.nodePerNodeId[node.id] = node; + this.forest.addNode(node); + } + + /** + * Remove nodes inside the current tree + * + * @param {HierarchyNode} nodes nodes to remove + */ + removeNodes(nodes) { + const nodeIds = nodes.map((node) => node.id); + this.nodePerNodeId = Object.fromEntries( + Object.entries(this.nodePerNodeId) + .filter( + ([nodeId,]) => !nodeIds.includes(Number(nodeId)) + ) + ); + this.forest.removeNodes(nodes); + } + + removeRoot() { + this.forest.removeTree(this); + } +} + +export class HierarchyForest { + /** + * + * @param {HierarchyModel} model + * @param {Object} config model config + * @param {Object[]} data list of tree root nodes data + */ + constructor(model, config, data) { + this.id = forestId++; + this.nodePerNodeId = {}; + this.trees = data.map((d) => new HierarchyTree(model, config, d, this)); + this.model = model; + this._config = config; + } + + /** + * Get node res ids containing inside the current forest + * + * @returns {Number} + */ + get resIds() { + return Object.values(this.nodePerNodeId).map((node) => node.resId); + } + + /** + * Get root node of all trees inside the current forest + * + * @returns {HierarchyNode[]} root nodes + */ + get rootNodes() { + return this.trees.map((t) => t.root); + } + + /** + * Add a node inside the current forest + * + * @param {HierarchyNode} node node to add inside the current forest + */ + addNode(node) { + this.nodePerNodeId[node.id] = node; + } + + /** + * Removes nodes inside the current forest + * + * @param {HierarchyNode} nodes nodes to remove inside the current forest + */ + removeNodes(nodes) { + const nodeIds = nodes.map((node) => node.id); + this.nodePerNodeId = Object.fromEntries( + Object.entries(this.nodePerNodeId) + .filter( + ([nodeId,]) => !nodeIds.includes(Number(nodeId)) + ) + ); + } + + removeTree(tree) { + this.nodePerNodeId = Object.fromEntries( + Object.entries(this.nodePerNodeId) + .filter( + ([nodeId, ]) => !(nodeId in tree.nodePerNodeId) + ) + ); + this.trees = this.trees.filter((t) => t.id !== tree.id); + } +} + +export class HierarchyModel extends Model { + setup(params) { + this.keepLast = new KeepLast(); + this.mutex = new Mutex(); + this.resModel = params.resModel; + this.fields = params.fields; + this.parentFieldName = params.parentFieldName; + this.childFieldName = params.childFieldName; + this.activeFields = params.activeFields; + this.config = { + domain: this.defaultDomain, + isRoot: true, + }; + } + + /** + * Get parent field info + * + * @returns {Object} parent field info + */ + get parentField() { + return this.fields[this.parentFieldName]; + } + + /** + * Get res ids of all nodes displayed in the view + * + * @returns {Number[]} resIds of all nodes displayed in the view + */ + get resIds() { + return this.root?.resIds || []; + } + + /** + * Get default child field name when no child field name is given to the view + * + * @returns {String} default child field name to use + */ + get defaultChildFieldName() { + return "__child_ids__"; + } + + /** + * Get active fields name + * + * @returns {String[]} active fields name + */ + get activeFieldNames() { + return Object.keys(this.activeFields); + } + + /** + * Get default domain to use, when no domain is given in the config + * + * @returns {import("@web/src/core/domain").DomainListRepr} default domain + */ + get defaultDomain() { + return [[this.parentFieldName, '=', false]]; + } + + /** + * Get fields to fetch + * @returns {String[]} fields to fetch + */ + get fieldsToFetch() { + const fieldsToFetch = [ + ...this.activeFieldNames, + ]; + if (this.childFieldName) { + fieldsToFetch.push(this.childFieldName); + } + return fieldsToFetch; + } + + /** + * Load the config and data for hierarchy view + * + * @param {Object} params params to use to load data of hierarchy view + */ + async load(params = {}) { + nodeId = forestId = treeId = 0; + const config = this._getNextConfig(this.config, params); + const data = await this.keepLast.add(this._loadData(config)); + this.root = this._createRoot(config, data); + this.config = config; + this.notify(); + } + + /** + * Fetch parent node of given node + * @param {HierarchyNode} node node to fetch its parent node + */ + async fetchManager(node) { + if (this.root.trees.length > 1) { // reset the hierarchy + const treeExpanded = this._findTreeExpanded(); + const resIdsToFetch = [node.parentResId, node.resId, ...node.allSubsidiaryResIds]; + if (treeExpanded && treeExpanded.root.id !== node.id && treeExpanded.root.parentResId === node.parentResId) { + resIdsToFetch.push(...treeExpanded.root.allSubsidiaryResIds); + } + const config = { + ...this.config, + domain: ["|", [this.parentFieldName, "=", node.parentResId], ["id", "in", resIdsToFetch]], + } + const data = await this._loadData(config); + this.root = this._createRoot(config, data); + this.notify(); + return; + } + const managerData = await this.keepLast.add(this._fetchManager(node)); + if (managerData) { + const parentNode = new HierarchyNode(this, this.config, managerData, node.tree, null, false); + parentNode.createChildNodes(); + node.setParentNode(parentNode); + this.notify(); + } + } + + /** + * Fetch child nodes of given node + * + * @param {HierarchyNode} node node to fetch its child nodes + */ + async fetchSubordinates(node) { + const childFieldName = this.childFieldName || this.defaultChildFieldName; + const children = node.data[childFieldName]; + if (children.length) { + const nodesToUpdate = []; + if (!(children[0] instanceof Object)) { + const allNodeResIds = this.root.resIds; + const existingChildResIds = children.filter((childResId) => allNodeResIds.includes(childResId)) + if (existingChildResIds.length) { // special case with result found with the search view + for (const tree of this.root.trees) { + if (existingChildResIds.includes(tree.root.resId)) { + nodesToUpdate.push(tree.root); + } + } + } + const data = await this.keepLast.add(this._fetchSubordinates(node, existingChildResIds)); + if (data && data.length) { + node.data[childFieldName] = data; + } + } + const nodeToCollapse = this._searchNodeToCollapse(node); + if (nodeToCollapse && !nodesToUpdate.includes(nodeToCollapse)) { + nodeToCollapse.collapseChildNodes(); + } + node.populateChildNodes(); + for (const n of nodesToUpdate) { + n.setParentNode(node); + } + this.notify(); + } + } + + /** + * Search node to collapse to be able to show the child nodes of node given in parameter + * + * @param {HierarchyNode} node node to show its child nodes. + * @returns {HierarchyNode | null} node found to collapse + */ + _searchNodeToCollapse(node) { + const parentNode = node.parentNode; + let nodeToCollapse = null; + if (parentNode) { + nodeToCollapse = parentNode.nodes.find((n) => n.nodes.length); + } else { + const treeExpanded = this._findTreeExpanded(); + if (treeExpanded) { + nodeToCollapse = treeExpanded.root; + } + } + return nodeToCollapse; + } + + _findTreeExpanded() { + return this.root.trees.find((t) => t.root.nodes.length); + } + + /** + * Get the next model config to use + * + * @param {Object} currentConfig current model config used + * @param {Object} params new params + * @returns {Object} new model config to use + */ + _getNextConfig(currentConfig, params) { + const config = Object.assign({}, currentConfig); + config.context = "context" in params ? params.context : config.context; + if ("domain" in params) { + config.domain = params.domain; + if (!params.domain.length) { + config.domain = this.defaultDomain; + } + } + return config; + } + + /** + * Load data for hierarchy view + * + * @param {Object} config model config + * @returns {Object[]} main data for hierarchy view + */ + async _loadData(config) { + const result = await this.orm.call( + this.resModel, + "hierarchy_read", + [config.domain, this.fieldsToFetch, this.parentFieldName, this.childFieldName], + { + context: config.context, + }, + ); + const resultStringified = JSON.stringify(result); + const recordsPerParentId = {}; + const recordPerId = {}; + let focusedRecord = null; + for (const record of result) { + if (record.__focus__) { + focusedRecord = record; + } + recordPerId[record.id] = record; + const parentId = getIdOfMany2oneField(record[this.parentFieldName]); + if (!(parentId.toString() in recordsPerParentId)) { + recordsPerParentId[parentId] = []; + } + recordsPerParentId[parentId].push(record); + } + const data = []; + const parentId = focusedRecord ? getIdOfMany2oneField(focusedRecord[this.parentFieldName]) : false; + const parentFocusedRecord = parentId && parentId in recordPerId ? recordPerId[parentId.toString()] : { id: false }; + const recordIds = []; // to check if we have only one arborescence to display otherwise we display the result as the kanban view + for (const [parentId, records] of Object.entries(recordsPerParentId)) { + if (!parentId || !(parentId in recordPerId)) { + data.push(...records); + } else { + const parentRecord = recordPerId[parentId]; + if (recordIds.includes(parentRecord.id)) { + return JSON.parse(resultStringified); + } + const ancestorId = getIdOfMany2oneField(parentRecord[this.parentFieldName]); + if (ancestorId in recordsPerParentId) { + recordIds.push(...recordsPerParentId[ancestorId].map((r) => r.id)); + } + parentRecord[this.childFieldName || this.defaultChildFieldName] = records; + } + } + if (!data.length && result?.length) { + if (focusedRecord) { + let record = parentFocusedRecord || focusedRecord; + data.push(record); + } else { + data.push(recordPerId[Object.keys(recordsPerParentId)[0]]) + } + } + return data; + } + + /** + * Create forest + * + * @param {Object} config model config to use + * @param {Object[]} data root data + * @returns {HierarchyForest} forest hierarchy + */ + _createRoot(config, data) { + return new HierarchyForest(this, config, data); + } + + /** + * Fetch parent node and its children nodes data + * + * @param {HierarchyNode} node node to fetch its parent node + * @returns {Object} the parent node data with children data inside childFieldName + */ + async _fetchManager(node, exclude_node=true) { + let domain = new Domain([ + "|", + ["id", "=", node.parentResId], + [this.parentFieldName, "=", node.parentResId], + ]); + if (exclude_node) { + domain = Domain.and([ + domain, + [["id", "!=", node.resId]], + ]) + } + const result = await this.orm.searchRead( + this.resModel, + domain.toList({}), + this.fieldsToFetch, + { context: this.config.context }, + ); + let managerData = {}; + const children = []; + for (const data of result) { + if (data.id === node.parentResId) { + managerData = data; + } else { + children.push(data); + } + } + if (!this.childFieldName) { + if (children.length) { + await this._fetchDescendants(children); + } + } + managerData[this.childFieldName || this.defaultChildFieldName] = children; + return managerData; + } + + /** + * Fetch children nodes data for a given node + * + * @param {HierarchyNode} node node to fetch its children nodes + * @param {Array | null} excludeResIds list of ids to exclude (because the nodes already exist) + * @returns {Object[]} list of child node data + */ + async _fetchSubordinates(node, excludeResIds = null) { + let childrenResIds = node.data[this.childFieldName || this.defaultChildFieldName]; + if (excludeResIds) { + childrenResIds = childrenResIds.filter((childResId) => !excludeResIds.includes(childResId)); + } + const data = await this.orm.read( + this.resModel, + childrenResIds, + this.fieldsToFetch, + { context: this.config.context }, + ) + if (!this.childFieldName) { + await this._fetchDescendants(data); + } + return data; + } + + /** + * fetch descendants nodes resIds to know if the child nodes have descendants + * + * @param {Object[]} childrenData child nodes data to fetch its descendants + */ + async _fetchDescendants(childrenData) { + const resIds = childrenData.map((d) => d.id); + if (resIds.length) { + const fetchChildren = await this.orm.readGroup( + this.resModel, + [[this.parentFieldName, "in", resIds]], + ['id:array_agg'], + [this.parentFieldName], + { context: this.config.context || {} }, + ); + const childIdsPerId = Object.fromEntries( + fetchChildren.map((r) => [r[this.parentFieldName][0], r.id]) + ); + for (const d of childrenData) { + if (d.id.toString() in childIdsPerId) { + d[this.defaultChildFieldName] = childIdsPerId[d.id.toString()]; + } + } + } + } +} diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.js b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.js new file mode 100644 index 00000000000..c064f13658a --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.js @@ -0,0 +1,41 @@ +/** @odoo-module */ + +import { Component, useRef } from "@odoo/owl"; + +import { HierarchyCard } from "./hierarchy_card"; + +export class HierarchyRenderer extends Component { + static components = { + HierarchyCard, + }; + static props = { + model: Object, + openRecord: Function, + archInfo: Object, + templates: Object, + }; + static template = "hr_org_chart.HierarchyRenderer"; + + setup() { + this.rendererRef = useRef("renderer"); + } + + get rows() { + const rootNodes = this.props.model.root.rootNodes; + const rows = [{ nodes: rootNodes }]; + const processNode = (node) => { + if (!node.isLeaf) { + rows.push({ parentNode: node, nodes: node.nodes}); + for (const subNode of node.nodes) { + processNode(subNode); + } + } + }; + + for (const node of this.props.model.root.rootNodes) { + processNode(node); + } + + return rows; + } +} diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.scss b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.scss new file mode 100644 index 00000000000..3bd5851c90c --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.scss @@ -0,0 +1,64 @@ +$o-chart-line-color: #ababb1; + +.o_hierarchy_renderer { + .o_hierarchy_container { + gap: 25px; + + .o_hierarchy_separator { + position: relative; + margin-top: 10px; + margin-bottom: 10px; + width: 100%; + + .o_hierarchy_line_left { + &:before { + border-top: 2px solid $o-chart-line-color; + border-left: 2px solid $o-chart-line-color; + border-top-left-radius: 8px; + margin-left: -10%; + margin-top: -2px; + } + + &:after { + border-bottom: 2px solid $o-chart-line-color; + border-right: 2px solid $o-chart-line-color; + border-bottom-right-radius: 6px; + margin-left: 30%; + margin-top: -12px; + } + } + + .o_hierarchy_line_right { + &:before { + border-bottom: 2px solid $o-chart-line-color; + border-left: 2px solid $o-chart-line-color; + border-bottom-left-radius: 6px; + margin-left: calc(-10% - 1px); + margin-top: -12px; + } + + &:after { + border-top: 2px solid $o-chart-line-color; + border-right: 2px solid $o-chart-line-color; + border-top-right-radius: 8px; + margin-left: 30%; + margin-top: -2px; + } + } + + .o_hierarchy_line_part { + border-top: 2px solid $o-chart-line-color; + margin: 0 10%; + width: 30%; + } + + .o_hierarchy_line_part:before, .o_hierarchy_line_part:after { + content: ""; + width: calc(10% + 1px); + height: 12px; + position: absolute; + display: block; + } + } + } +} diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.xml b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.xml new file mode 100644 index 00000000000..f46286f3e0a --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_renderer.xml @@ -0,0 +1,37 @@ + + + + +
+
+ + + +
+ +
+
+
+
+
+
+
+ + + +
+
+
+
+
+ +
diff --git a/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_view.js b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_view.js new file mode 100644 index 00000000000..4bec22bdcbf --- /dev/null +++ b/addons/hr_org_chart/static/src/views/hierarchy/hierarchy_view.js @@ -0,0 +1,36 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { HierarchyArchParser } from "./hierarchy_arch_parser"; +import { HierarchyController } from "./hierarchy_controller"; +import { HierarchyModel } from "./hierarchy_model"; +import { HierarchyRenderer } from "./hierarchy_renderer"; + +export const hierarchyView = { + type: "hierarchy", + display_name: _t("Hierarchy"), + icon: "fa fa-share-alt", + isMobileFriendly: false, + multiRecord: true, + ArchParser: HierarchyArchParser, + Controller: HierarchyController, + Model: HierarchyModel, + Renderer: HierarchyRenderer, + buttonTemplate: "hr_org_chart.HierarchyButtons", + searchMenuTypes: [], + + props: (genericProps, view) => { + const { ArchParser, Model, Renderer, buttonTemplate: viewButtonTemplate } = view; + const { arch, relatedModels, resModel, buttonTemplate } = genericProps; + return { + ...genericProps, + archInfo: new ArchParser().parse(arch, relatedModels, resModel), + buttonTemplate: buttonTemplate || viewButtonTemplate, + Model, + Renderer, + }; + } +} + +registry.category("views").add("hierarchy", hierarchyView);