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 @@ + + + + + + + + + + + + + + + + + + + + Unfold + + + + + Fold + + + + + + + + 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 @@ + + + + + + + New + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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);