Skip to content

Commit

Permalink
[ADD] hr_org_chart: add hierarchy view
Browse files Browse the repository at this point in the history
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
  • Loading branch information
xavierbol committed Oct 16, 2023
1 parent 6ff5f9e commit 16c4a54
Show file tree
Hide file tree
Showing 16 changed files with 1,446 additions and 0 deletions.
1 change: 1 addition & 0 deletions addons/hr_org_chart/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
],
'web.assets_backend': [
'hr_org_chart/static/src/fields/*',
'hr_org_chart/static/src/views/**/*',
],
'web.qunit_suite_tests': [
'hr_org_chart/static/tests/**/*',
Expand Down
3 changes: 3 additions & 0 deletions addons/hr_org_chart/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions addons/hr_org_chart/models/ir_actions.py
Original file line number Diff line number Diff line change
@@ -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'})
49 changes: 49 additions & 0 deletions addons/hr_org_chart/models/ir_ui_view.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions addons/hr_org_chart/models/models.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
}
}
67 changes: 67 additions & 0 deletions addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
69 changes: 69 additions & 0 deletions addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
59 changes: 59 additions & 0 deletions addons/hr_org_chart/static/src/views/hierarchy/hierarchy_card.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>

<t t-name="hr_org_chart.HierarchyCard">
<div class="o_hierarchy_node_container mb-4 d-flex flex-column" t-att-class="props.classNames">
<div class="o_hierarchy_node_button_container w-100 d-flex justify-content-end">
<button t-if="props.node.parentResId !== false and !props.node.parentNode"
name="hierarchy_search_parent_node"
class="btn p-0"
t-on-click.synthetic="onClickArrowUp"
>
<i class="fa fa-chevron-up"/>
</button>
</div>
<div class="o_hierarchy_node w-100 h-100 d-flex flex-column justify-content-between"
t-att-class="{
o_hierarchy_node_highlighted: props.node.isFocused,
border: !props.node.isFocused,
'border-bottom-0': !props.node.isFocused and (props.node.nodes.length or props.node.canShowChildNodes),
}"
t-att-data-node-id="props.node.id"
t-on-click.synthetic="onGlobalClick"
>
<div class="o_hierarchy_node_content">
<Record resModel="props.node.model.resModel"
resId="props.node.resId"
fields="props.node.model.fields"
activeFields="props.node.model.activeFields"
values="props.node.data"
t-slot-scope="data"
>
<t t-call="{{ templates['hierarchy-box'] }}" t-call-context="getRenderingContext(data)"/>
</Record>
</div>
<button t-if="props.node.nodes.length or props.node.canShowChildNodes"
name="hierarchy_search_subsidiaries"
t-att-class="{
'o_hierarchy_node_button w-100 btn pt-1': true,
'btn-primary': !props.node.nodes.length,
'btn-secondary': props.node.nodes.length > 0,
}"
t-on-click.synthetic="onClickArrowDown"
>
<t t-if="!props.node.nodes.length">
<span style="grid-column: 2;">
Unfold
</span>
</t>
<t t-else="">
<span style="grid-column: 2;">
Fold
</span>
</t>
</button>
</div>
</div>
</t>

</templates>
Loading

0 comments on commit 16c4a54

Please sign in to comment.