[MOV] hr_org_chart,web_hierarchy: move hierarchy view in its own module

This commit moves the generic code of hierarchy view into new module
called `web_hierarchy` to be able to use it outside the hr scope.

task-3376776
This commit is contained in:
Xavier Bol (xbo)
2023-10-16 08:17:31 +02:00
parent 55571bc1ac
commit a055b9c20e
29 changed files with 51 additions and 21 deletions
+2 -6
View File
@@ -13,8 +13,8 @@ Org Chart Widget for HR
This module extend the employee form with a organizational chart.
(N+1, N+2, direct subordinates)
""",
'depends': ['hr'],
'auto_install': True,
'depends': ['hr', 'web_hierarchy'],
'auto_install': ['hr'],
'data': [
'views/hr_department_views.xml',
'views/hr_views.xml',
@@ -26,13 +26,9 @@ This module extend the employee form with a organizational chart.
'hr_org_chart/static/src/scss/variables.scss',
],
'web.assets_backend': [
'hr_org_chart/static/src/scss/hierarchy.variables.scss',
'hr_org_chart/static/src/fields/*',
'hr_org_chart/static/src/views/**/*',
],
'web.assets_web_dark': [
('before', 'hr_org_chart/static/src/scss/hierarchy.variables.scss', 'hr_org_chart/static/src/scss/**/*.variables.dark.scss'),
],
'web.qunit_suite_tests': [
'hr_org_chart/static/tests/**/*',
],
-3
View File
@@ -3,6 +3,3 @@
from . import hr_org_chart_mixin
from . import hr_employee
from . import ir_actions
from . import ir_ui_view
from . import models
-9
View File
@@ -1,9 +0,0 @@
# 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'})
-51
View File
@@ -1,51 +0,0 @@
# 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',
'icon',
'draggable',
}
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
View File
@@ -1,44 +0,0 @@
# 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
@@ -1,102 +0,0 @@
/** @odoo-module */
import { onWillUnmount, reactive, useEffect, useExternalListener } from "@odoo/owl";
import { useThrottleForAnimation } from "@web/core/utils/timing";
import { pick } from "@web/core/utils/objects";
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
const hookParams = {
name: "useHierarchyNodeDraggable",
acceptedParams: {
rows: [String],
},
defaultParams: {
rows: null,
},
onComputeParams({ ctx, params }) {
// Row selector
ctx.rowSelector = params.rows || null;
if (ctx.rowSelector) {
ctx.fullSelector = `${ctx.rowSelector} ${ctx.fullSelector}`;
}
},
onDragStart(params) {
const { ctx, addListener, callHandler } = params;
const onElementPointerEnter = (ev) => {
const element = ev.currentTarget;
current.hierarchyElement = element;
callHandler("onElementEnter", { element });
};
const onElementPointerLeave = (ev) => {
const element = ev.currentTarget;
current.hierarchyElement = null;
callHandler("onElementLeave", { element });
};
const onRowPointerEnter = (ev) => {
const row = ev.currentTarget;
current.hierarchyRow = row;
callHandler("onRowEnter", { row });
};
const onRowPointerLeave = (ev) => {
const row = ev.currentTarget;
current.hierarchyRow = null;
callHandler("onRowLeave", { row });
};
const { ref, current, elementSelector, rowSelector } = ctx;
for (const rowEl of ref.el.querySelectorAll(rowSelector)) {
addListener(rowEl, "pointerenter", onRowPointerEnter);
addListener(rowEl, "pointerleave", onRowPointerLeave);
}
for (const siblingEl of ref.el.querySelectorAll(elementSelector)) {
if (siblingEl !== current.element) {
addListener(siblingEl, "pointerenter", onElementPointerEnter);
addListener(siblingEl, "pointerleave", onElementPointerLeave);
}
}
return pick(current, "element", "row");
},
onDragEnd({ ctx }) {
return pick(ctx.current, "element", "row", "hierarchyRow");
},
onDrop({ ctx }) {
const { current } = ctx;
const rowElement = current.hierarchyRow;
const element = current.hierarchyElement;
if ((rowElement && rowElement !== current.row) || element) {
return {
element: current.element,
row: current.row,
nextRow: rowElement && current.row !== rowElement ? rowElement : null,
newParentNode: element,
};
}
},
onWillStartDrag({ ctx }) {
const { current, rowSelector } = ctx;
if (rowSelector) {
current.row = current.element.closest(rowSelector);
}
return pick(current, "element", "row");
},
};
export function useHierarchyNodeDraggable(params) {
const setupHooks = {
addListener: useExternalListener,
setup: useEffect,
teardown: onWillUnmount,
throttle: useThrottleForAnimation,
wrapState: reactive,
}
return makeDraggableHook({ ...hookParams, setupHooks })(params);
}
@@ -1,3 +0,0 @@
$hierarchy_element_dragged-background: $gray-100 !default;
$hierarchy_element_hover-background: #{darken($gray-100, 5%)} !default;
$hierarchy_element_hover-border: #{$gray-100} !default;
@@ -1,3 +0,0 @@
$hierarchy_element_dragged-background: white !default;
$hierarchy_element_hover-background: #e6f2f3 !default;
$hierarchy_element_hover-border: #017e84 !default;
@@ -1,74 +0,0 @@
/** @odoo-module */
import { visitXML } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
import { archParseBoolean, getActiveActions } from "@web/views/utils";
export class HierarchyArchParser {
parse(xmlDoc, models, modelName) {
const archInfo = {
activeActions: getActiveActions(xmlDoc),
draggable: false,
icon: "fa-share-alt o_hierarchy_icon",
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;
}
if (node.hasAttribute("draggable")) {
archInfo.draggable = archParseBoolean(node.getAttribute("draggable"));
}
if (node.hasAttribute("icon")) {
archInfo.icon = node.getAttribute("icon");
}
} 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;
}
}
@@ -1,75 +0,0 @@
/** @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 { HierarchyCompiler } 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 = HierarchyCompiler;
setup() {
const { templates } = this.props;
this.templates = useViewCompiler(this.constructor.Compiler, templates);
}
get classNames() {
const classNames = [this.props.classNames];
if (this.props.node.nodes.length) {
classNames.push("o_hierarchy_node_unfolded");
}
return classNames.join(" ");
}
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();
}
}
}
@@ -1,86 +0,0 @@
@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_dragged {
.o_hierarchy_node {
background-color: $hierarchy_element_dragged-background;
}
}
&.o_hierarchy_hover {
.o_hierarchy_node {
background-color: $hierarchy_element_hover-background;
border: 3px solid green !important;
}
}
.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;
.o_hierarchy_icon {
vertical-align: text-top;
}
}
}
@@ -1,63 +0,0 @@
<?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="classNames" t-att-data-node-id="props.node.id">
<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>
<span class="text-end" style="grid-column: 3;">
<t t-out="props.node.childResIds.length"/>
<i class="fa ps-1" t-att-class="props.archInfo.icon"/>
</span>
</t>
<t t-else="">
<span style="grid-column: 2;">
Fold
</span>
</t>
</button>
</div>
</div>
</t>
</templates>
@@ -1,28 +0,0 @@
/** @odoo-module **/
import { KanbanCompiler } from "@web/views/kanban/kanban_compiler";
export class HierarchyCompiler extends KanbanCompiler {
/**
* @override
* @param {Element} el
* @param {Object} params
* @returns {Element}
*/
compileField(el, params) {
const fieldName = el.getAttribute("name");
return super.compileField(el, {
...(params || {}),
recordExpr: "__record__",
dataPointId: "__comp__.props.node.id",
formattedValueExpr: `record['${fieldName}'].value`,
});
}
compileButton(el, params) {
return super.compileButton(el, {
...(params || {}),
recordExpr: "__record__",
});
}
}
@@ -1,64 +0,0 @@
/** @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) {}
}
@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="hr_org_chart.HierarchyButtons" owl="1">
<div class="d-flex o_grid_buttons">
<div class="me-2" t-if="props.archInfo.activeActions.create and (!props.archInfo.createInline or displayNoContent)">
<button class="btn btn-primary o_hierarchy_button_add"
type="button"
t-on-click="props.createRecord">
New
</button>
</div>
</div>
</t>
<t t-name="hr_org_chart.HierarchyView" owl="1">
<div t-attf-class="o_hierarchy_view {{ isMobile ? 'o_action_delegate_scroll' : '' }} {{ props.className }}" t-ref="root">
<Layout className="(model.useSampleModel ? 'o_view_sample_data' : '') + ' d-flex'" display="props.display">
<t t-set-slot="control-panel-additional-actions">
<CogMenu/>
</t>
<t t-set-slot="layout-buttons">
<t t-call="{{ props.buttonTemplate }}"/>
</t>
<t t-set-slot="layout-actions">
<SearchBar t-if="searchBarToggler.state.showSearchBar"/>
</t>
<t t-set-slot="control-panel-navigation-additional">
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
</t>
<t t-set-slot="default" t-slot-scope="layout">
<t t-if="displayNoContent">
<t t-call="web.ActionHelper">
<t t-set="noContentHelp" t-value="props.info.noContentHelp"/>
</t>
</t>
<t t-component="props.Renderer"
model="model"
openRecord.bind="openRecord"
archInfo="props.archInfo"
templates="props.archInfo.templateDocs"
/>
</t>
</Layout>
</div>
</t>
</templates>
@@ -1,929 +0,0 @@
/** @odoo-module */
import { Domain } from "@web/core/domain";
import { _t } from "@web/core/l10n/translation";
import { KeepLast, Mutex } from "@web/core/utils/concurrency";
import { pick } from "@web/core/utils/objects";
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
)
);
}
removeParentNode() {
this.parentNode?.removeChildNode(this);
this.parentNode = null;
this.data[this.parentFieldName] = false;
}
/**
* 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();
}
removeChildNode(node) {
this.tree.removeNodes([node, ...node.descendantNodes]);
this.nodes = this.nodes.filter((n) => n.id !== node.id);
this.data[this.childFieldName] = this.nodes.map((n) => n.data);
}
/**
* 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;
if (data) {
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))
)
);
}
addNewRootNode(node) {
const tree = new HierarchyTree(this.model, this._config, null, this);
tree.root = node;
node.tree = tree;
tree.addNode(node);
for (const subNode of node.descendantNodes) {
tree.addNode(subNode);
}
this.trees.push(tree);
}
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 {
static services = ["notification"];
setup(params, { notification }) {
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.notification = notification;
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) {
if (config.context.hierarchy_res_id) {
config.domain = [["id", "=", config.context.hierarchy_res_id]];
delete config.context.hierarchy_res_id; // just needed for the first load.
} else {
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<number> | 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()];
}
}
}
}
async updateParentNode(nodeId, { parentNodeId, parentResId }) {
const node = this.root.nodePerNodeId[nodeId];
const parentNode = parentNodeId ? this.root.nodePerNodeId[parentNodeId] : null;
if (node) {
const oldParentNode = node.parentNode;
let fetchParentChildren = false;
let domain = new Domain([]);
if (oldParentNode) {
domain = new Domain([["id", "=", oldParentNode.resId]]);
}
if (parentNode) {
if (parentNode.resId === node.resId) {
this.notification.add(
_t("The parent record cannot be the record dragged."),
{
type: "danger",
}
);
return;
}
domain = Domain.or([
domain,
[["id", "in", [parentNode.resId, node.resId]]],
]);
if (parentNode.nodes.length === 0 && parentNode.childResIds.length > 0) {
fetchParentChildren = true;
domain = Domain.or([
domain,
[[this.parentFieldName, "=", parentNode.resId], ["id", "!=", node.resId]],
]);
}
if (node.id === node.tree.root.id) {
this.root.removeTree(node.tree);
node.tree = parentNode.tree;
} else {
node.removeParentNode();
}
} else {
node.removeParentNode();
this.root.addNewRootNode(node);
}
this.notify();
await this.mutex.exec(async () => {
await this.orm.write(
this.resModel,
[node.resId],
{ [this.parentFieldName]: parentResId || parentNode?.resId || false },
{ context: this.config.context }
);
});
domain = domain.toList({});
if (domain.length) {
const data = await this.orm.searchRead(
this.resModel,
domain,
this.fieldsToFetch,
{ context: this.config.context },
);
const children = [];
for (const d of data) {
if (d.id === node.resId) {
node.data = d;
} else if (d.id === oldParentNode?.resId) {
oldParentNode.data = d;
} else if (parentNode) {
if (parentNode.resId === d.id) {
const parentData = fetchParentChildren ? {} : pick(parentNode.data, this.childFieldName);
parentNode.data = {
...d,
...parentData,
};
} else if (fetchParentChildren) {
children.push(d);
}
}
}
if (children.length) {
parentNode.data[this.childFieldName || this.defaultChildFieldName] = children;
}
}
const treeExpanded = this._findTreeExpanded();
if (parentNode) {
if (treeExpanded && treeExpanded.id !== parentNode.tree.id) {
treeExpanded.root.nodes = [];
treeExpanded.nodePerNodeId = { [treeExpanded.root.id]: treeExpanded.root };
} else if (treeExpanded) {
const nodeToCollapse = this._searchNodeToCollapse(parentNode);
if (nodeToCollapse && nodeToCollapse.id !== parentNode.id) {
nodeToCollapse.collapseChildNodes();
}
}
if (fetchParentChildren) {
parentNode.populateChildNodes();
}
node.setParentNode(parentNode);
} else if (treeExpanded && node.nodes.length) {
treeExpanded.root.collapseChildNodes();
}
this.notify();
}
}
}
@@ -1,132 +0,0 @@
/** @odoo-module */
import { Component, useRef, onPatched } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { scrollTo } from "@web/core/utils/scrolling";
import { HierarchyCard } from "./hierarchy_card";
import { useHierarchyNodeDraggable } from "../../hooks/hierarchy_node_draggable";
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");
this.notification = useService("notification");
if (this.canDragAndDropRecord) {
useHierarchyNodeDraggable({
ref: this.rendererRef,
enable: this.draggable,
elements: ".o_hierarchy_node_container",
handle: ".o_hierarchy_node",
rows: ".o_hierarchy_row",
ignore: "button",
onDragStart: ({ addClass, element }) => {
addClass(element, "o_hierarchy_dragged");
addClass(element.querySelector(".o_hierarchy_node"), "shadow");
},
onDragEnd: ({ removeClass, element, row, hierarchyRow }) => {
removeClass(element, "o_hierarchy_dragged");
if (row) {
removeClass(row, "o_hierarchy_hover");
}
if (hierarchyRow) {
removeClass(hierarchyRow, "o_hierarchy_hover");
}
},
onDrop: (params) => {
this.nodeDrop(params);
},
onElementEnter: ({ addClass, element }) => {
addClass(element, "o_hierarchy_hover");
},
onElementLeave: ({ removeClass, element }) => {
removeClass(element, "o_hierarchy_hover");
},
onRowEnter: ({ addClass, row }) => {
addClass(row, "o_hierarchy_hover");
},
onRowLeave: ({ removeClass, row }) => {
removeClass(row, "o_hierarchy_hover");
},
});
}
onPatched(this.onPatched);
}
onPatched() {
const row = this.rendererRef.el.querySelector(":scope .o_hierarchy_row:last-child");
if (row) {
const scrollable = this.env.isSmall ? document.querySelector(".o_hierarchy_view") : this.rendererRef.el.parentNode;
scrollable.classList.add(".o_hierarchy_auto-scroll");
scrollTo(row, { scrollable, isAnchor: true });
scrollable.classList.remove(".o_hierarchy_auto-scroll");
}
}
get canDragAndDropRecord() {
return this.draggable && !this.env.isSmall;
}
get draggable() {
return this.props.archInfo.draggable;
}
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;
}
async nodeDrop({ element, row, nextRow, newParentNode }) {
let parentNodeId, parentResId;
if (newParentNode) {
parentNodeId = newParentNode.dataset.nodeId;
} else if (nextRow?.dataset.rowId !== row.dataset.rowId) {
parentNodeId = nextRow.dataset.parentNodeId;
if (!parentNodeId) {
const nodes = this.rows[nextRow.dataset.rowId].nodes || [];
if (nodes) {
parentNodeId = nodes[0].parentNode?.id;
if (!parentNodeId) {
parentResId = nodes[0].parentResId;
if (!nodes.every((node) => node.parentResId === parentResId)) {
this.notification.add(
_t("Impossible to update the parent node of the dragged node because no parent has been found."),
{
type: "danger",
}
);
return;
}
}
}
}
}
await this.props.model.updateParentNode(element.dataset.nodeId, { parentResId, parentNodeId });
}
}
@@ -1,76 +0,0 @@
$o-chart-line-color: #ababb1;
.o_hierarchy_renderer {
.o_hierarchy_container {
gap: 25px;
.o_hierarchy_row {
&.o_hierarchy_hover {
background-color: $hierarchy_element_hover-background;
box-shadow: -1px -1px 0px 0px $hierarchy_element_hover-border inset,
1px 1px 0px 0px $hierarchy_element_hover-border inset;
}
&:has(.o_hierarchy_node_unfolded) .o_hierarchy_node_container:not(.o_hierarchy_node_unfolded) {
opacity: 50%;
}
}
.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;
}
}
}
}
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="hr_org_chart.HierarchyRenderer" owl="1">
<div class="o_hierarchy_renderer w-100 d-flex justify-content-center" t-ref="renderer">
<div class="o_hierarchy_container d-flex flex-column w-100 w-lg-75 h-100 pt-5 px-2">
<t t-foreach="rows" t-as="row" t-key="row_index">
<t t-set="previousRow" t-value="!row_first ? rows[row_index - 1] : null"/>
<t t-if="!row_first">
<div t-if="row.parentNode and previousRow.nodes.length > 1"
class="o_hierarchy_parent_node_container d-flex justify-content-center"
>
<span t-esc="row.parentNode.data.name" />
</div>
<div class="o_hierarchy_separator d-flex pb-4">
<div class="o_hierarchy_line_part o_hierarchy_line_left"></div>
<div class="o_hierarchy_line_part o_hierarchy_line_right"></div>
</div>
</t>
<div class="o_hierarchy_row row justify-content-center flex-wrap row-cols-2 row-cols-lg-5 g-2 g-lg-3 pt-3"
t-att-data-parent-node-id="row.parentNode?.id" t-att-data-row-id="row_index"
>
<t t-foreach="row.nodes" t-as="node" t-key="node.id">
<HierarchyCard
node="node"
openRecord="props.openRecord"
archInfo="props.archInfo"
templates="props.templates"
/>
</t>
</div>
</t>
</div>
</div>
</t>
</templates>
@@ -1,36 +0,0 @@
/** @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 o_hierarchy_icon",
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);
@@ -1,11 +0,0 @@
.o_control_panel_navigation .o_switch_view .o_hierarchy_icon { // button in view switcher
rotate: 90deg;
}
.o_hierarchy_view { // uses for auto-scroll when drag&drop or unfold node
scroll-behavior: smooth;
.o_content {
scroll-behavior: smooth;
}
}
@@ -2,7 +2,7 @@
import { Avatar } from "@mail/views/web/fields/avatar/avatar";
import { HierarchyRenderer } from "../hierarchy/hierarchy_renderer";
import { HierarchyRenderer } from "@web_hierarchy/hierarchy_renderer";
export class HrEmployeeHierarchyRenderer extends HierarchyRenderer {
static template = "hr_org_chart.HrEmployeeHierarchyRenderer";
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="hr_org_chart.HrEmployeeHierarchyRenderer" t-inherit="hr_org_chart.HierarchyRenderer">
<t t-name="hr_org_chart.HrEmployeeHierarchyRenderer" t-inherit="web_hierarchy.HierarchyRenderer">
<xpath expr="//div[hasclass('o_hierarchy_parent_node_container')]/span" position="replace">
<Avatar
resModel="row.parentNode.model.resModel"
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { hierarchyView } from "../hierarchy/hierarchy_view";
import { hierarchyView } from "@web_hierarchy/hierarchy_view";
import { HrEmployeeHierarchyRenderer } from "./hr_employee_hierarchy_renderer";
export const hrEmployeeHierarchyView = {
@@ -1,689 +0,0 @@
/** @odoo-module **/
import { click, drag, dragAndDrop, getFixture, getNodesTextContent } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData, target;
QUnit.module("Views", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
"hr.employee": {
fields: {
parent_id: { string: "Manager", type: "many2one", relation: "hr.employee" },
name: { string: "Name" },
child_ids: { string: "Subordinates", type: "one2many", relation: "hr.employee", relation_field: "parent_id" },
},
records: [
{ id: 1, name: "Albert", parent_id: false, child_ids: [2, 3] },
{ id: 2, name: "Georges", parent_id: 1, child_ids: [] },
{ id: 3, name: "Josephine", parent_id: 1, child_ids: [4] },
{ id: 4, name: "Louis", parent_id: 3, child_ids: [] },
],
},
},
views: {
"hr.employee,false,hierarchy": `
<hierarchy>
<templates>
<t t-name="hierarchy-box">
<div class="o_hierarchy_node_header">
<field name="name"/>
</div>
<div class="o_hierarchy_node_body">
<field name="parent_id"/>
</div>
</t>
</templates>
</hierarchy>
`,
"hr.employee,1,hierarchy": `
<hierarchy child_field="child_ids">
<field name="child_ids" invisible="1"/>
<templates>
<t t-name="hierarchy-box">
<div class="o_hierarchy_node_header">
<field name="name"/>
</div>
<div class="o_hierarchy_node_body">
<field name="parent_id"/>
</div>
</t>
</templates>
</hierarchy>
`,
"hr.employee,false,form": `
<form>
<sheet>
<group>
<field name="name"/>
<field name="parent_id"/>
</group>
</sheet>
</form>
`,
},
};
setupViewRegistries();
target = getFixture();
});
QUnit.module("Hierarchy View");
QUnit.test("load hierarchy view", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsOnce(target, ".o_hierarchy_view");
assert.containsN(target, ".o_hierarchy_button_add", 2);
assert.containsOnce(target, ".o_hierarchy_view .o_hierarchy_renderer");
assert.containsOnce(target, ".o_hierarchy_view .o_hierarchy_renderer > .o_hierarchy_container");
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsOnce(target, ".o_hierarchy_separator");
assert.containsN(target, ".o_hierarchy_line_part", 2);
assert.containsOnce(target, ".o_hierarchy_line_left");
assert.containsOnce(target, ".o_hierarchy_line_right");
assert.containsN(target, ".o_hierarchy_node_container", 3);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.containsNone(target, ".o_hierarchy_node_highlighted");
assert.containsN(target, ".o_hierarchy_node_button", 2);
assert.containsOnce(target, ".o_hierarchy_node_button.btn-primary");
assert.strictEqual(target.querySelector(".o_hierarchy_node_button.btn-primary").textContent.trim(), "Unfold 1");
// check nodes in each row
const row = target.querySelector(".o_hierarchy_row");
assert.containsOnce(row, ".o_hierarchy_node");
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "Albert");
assert.containsOnce(target, ".o_hierarchy_node_button.btn-secondary");
assert.strictEqual(target.querySelector(".o_hierarchy_node_button.btn-secondary").textContent.trim(), "Fold");
});
QUnit.test("display child nodes", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
async mockRPC(route, args) {
if (args.method === "read") {
assert.step("get child data");
} else if (args.method === "read_group") {
assert.step("fetch descendants");
}
}
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node_button", 2);
assert.containsOnce(target, ".o_hierarchy_node_button.btn-secondary");
assert.containsOnce(target, ".o_hierarchy_node_button.btn-primary");
await click(target, ".o_hierarchy_node_button.btn-primary");
assert.containsN(target, ".o_hierarchy_row", 3);
assert.containsN(target, ".o_hierarchy_separator", 2);
assert.containsN(target, ".o_hierarchy_line_part", 4);
assert.containsN(target, ".o_hierarchy_line_left", 2);
assert.containsN(target, ".o_hierarchy_line_right", 2);
assert.containsN(target, ".o_hierarchy_node_container", 4);
assert.containsN(target, ".o_hierarchy_node", 4);
assert.containsN(target, ".o_hierarchy_node_button", 2);
assert.containsNone(target, ".o_hierarchy_node_button.btn-primary");
assert.containsN(target, ".o_hierarchy_node_button.btn-secondary", 2);
assert.strictEqual(target.querySelector(".o_hierarchy_node_button.btn-secondary").textContent.trim(), "Fold");
// check nodes in each row
const rows = target.querySelectorAll(".o_hierarchy_row");
let row = rows[0];
assert.containsOnce(row, ".o_hierarchy_node");
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "Albert");
row = rows[1];
assert.containsN(row, ".o_hierarchy_node", 2);
assert.deepEqual(
getNodesTextContent(row.querySelectorAll(".o_hierarchy_node_content")),
[ // Name + Parent name
"GeorgesAlbert",
"JosephineAlbert",
],
);
row = rows[2];
assert.containsOnce(row, ".o_hierarchy_node");
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "LouisJosephine");
assert.verifySteps([
"get child data",
"fetch descendants",
]);
});
QUnit.test("display child nodes with child_field set on the view", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
viewId: 1,
async mockRPC(route, args) {
if (args.method === "read") {
assert.step("get child data with descendants");
}
}
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node_button", 2);
assert.containsOnce(target, ".o_hierarchy_node_button.btn-secondary");
assert.containsOnce(target, ".o_hierarchy_node_button.btn-primary");
await click(target, ".o_hierarchy_node_button.btn-primary");
assert.containsN(target, ".o_hierarchy_row", 3);
assert.containsN(target, ".o_hierarchy_separator", 2);
assert.containsN(target, ".o_hierarchy_line_part", 4);
assert.containsN(target, ".o_hierarchy_line_left", 2);
assert.containsN(target, ".o_hierarchy_line_right", 2);
assert.containsN(target, ".o_hierarchy_node_container", 4);
assert.containsN(target, ".o_hierarchy_node", 4);
assert.containsN(target, ".o_hierarchy_node_button", 2);
assert.containsNone(target, ".o_hierarchy_node_button.btn-primary");
assert.containsN(target, ".o_hierarchy_node_button.btn-secondary", 2);
assert.verifySteps([
"get child data with descendants",
]);
});
QUnit.test("collapse child nodes", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsOnce(target, ".o_hierarchy_separator");
assert.containsN(target, ".o_hierarchy_line_part", 2);
assert.containsOnce(target, ".o_hierarchy_line_left");
assert.containsOnce(target, ".o_hierarchy_line_right");
assert.containsN(target, ".o_hierarchy_node_container", 3);
assert.containsN(target, ".o_hierarchy_node", 3);
await click(target, ".o_hierarchy_node_button.btn-secondary");
assert.containsOnce(target, ".o_hierarchy_row");
assert.containsNone(target, ".o_hierarchy_separator");
assert.containsNone(target, ".o_hierarchy_line_part", 2);
assert.containsNone(target, ".o_hierarchy_line_left");
assert.containsNone(target, ".o_hierarchy_line_right");
assert.containsOnce(target, ".o_hierarchy_node_container");
assert.containsOnce(target, ".o_hierarchy_node");
assert.containsNone(target, ".o_hierarchy_node_button.btn-secondary");
assert.containsOnce(target, ".o_hierarchy_node_button");
assert.containsOnce(target, ".o_hierarchy_node_container:not(.o_hierarchy_node_button)");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_hierarchy_row .o_hierarchy_node_content")), ["Albert"]);
});
QUnit.test("display the parent above the line when many records on the parent row", async function (assert) {
serverData.models["hr.employee"].records.push({
name: "Alfred",
parent_id: false,
child_ids: [],
})
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsOnce(target, ".o_hierarchy_row");
assert.containsNone(target, ".o_hierarchy_separator");
assert.containsN(target, ".o_hierarchy_node", 2);
assert.containsOnce(target, ".o_hierarchy_node_button.btn-primary");
await click(target, ".o_hierarchy_node_button.btn-primary");
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsOnce(target, ".o_hierarchy_separator");
assert.containsOnce(target, ".o_hierarchy_line_left");
assert.containsOnce(target, ".o_hierarchy_line_right");
assert.containsOnce(target, ".o_hierarchy_parent_node_container");
assert.strictEqual(target.querySelector(".o_hierarchy_parent_node_container").textContent, "Albert");
});
QUnit.test("search record in hierarchy view", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
domain: [["id", "=", 4]], // simulate a search
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 2);
assert.containsN(target, ".o_hierarchy_separator", 1);
assert.containsOnce(target, ".o_hierarchy_node_highlighted");
assert.strictEqual(target.querySelector(".o_hierarchy_node_highlighted").textContent.trim(), "LouisJosephine");
});
QUnit.test("search record in hierarchy view with child field name defined in the arch", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
viewId: 1,
serverData,
domain: [["id", "=", 4]], // simulate a search
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 2);
assert.containsN(target, ".o_hierarchy_separator", 1);
assert.containsOnce(target, ".o_hierarchy_node_highlighted");
assert.strictEqual(target.querySelector(".o_hierarchy_node_highlighted").textContent.trim(), "LouisJosephine");
});
QUnit.test("fetch parent record", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
domain: [["id", "=", 4]], // simulate a search
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 2);
assert.containsN(target, ".o_hierarchy_separator", 1);
assert.containsOnce(target, ".o_hierarchy_node_highlighted");
assert.strictEqual(target.querySelector(".o_hierarchy_node_highlighted").textContent.trim(), "LouisJosephine");
let rows = target.querySelectorAll(".o_hierarchy_row");
let row = rows[0];
assert.containsOnce(row, ".o_hierarchy_node");
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "JosephineAlbert");
row = rows[1];
assert.containsOnce(row, ".o_hierarchy_node");
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "LouisJosephine");
assert.containsOnce(
target,
".o_hierarchy_node_container button .fa-chevron-up",
"Button to fetch the parent node should be visible on the first node displayed in the view."
);
await click(target, ".o_hierarchy_node_container button .fa-chevron-up");
assert.containsN(target, ".o_hierarchy_row", 3);
assert.containsN(target, ".o_hierarchy_node", 4);
assert.containsN(target, ".o_hierarchy_separator", 2);
assert.containsOnce(target, ".o_hierarchy_node_highlighted");
assert.strictEqual(target.querySelector(".o_hierarchy_node_highlighted").textContent.trim(), "LouisJosephine");
rows = target.querySelectorAll(".o_hierarchy_row");
row = rows[0];
assert.containsOnce(row, ".o_hierarchy_node");
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "Albert");
row = rows[1];
assert.containsN(row, ".o_hierarchy_node", 2);
assert.deepEqual(
getNodesTextContent(row.querySelectorAll(".o_hierarchy_node_content")),
["GeorgesAlbert", "JosephineAlbert"],
);
row = rows[2];
assert.containsOnce(row, ".o_hierarchy_node");
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "LouisJosephine");
});
QUnit.test("fetch parent when there are many records without the same parent in the same row", async function (assert) {
serverData.models["hr.employee"].records.push(
{ id: 5, name: "Lisa", parent_id: 2, child_ids: []},
);
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
domain: [["name", "ilike", "l"]], // simulate a search
});
assert.containsOnce(target, ".o_hierarchy_row");
assert.containsN(target, ".o_hierarchy_node_container", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
[
"LisaGeorges", "LouisJosephine", "Albert",
],
);
assert.containsN(target, ".o_hierarchy_node_container button .fa-chevron-up", 2);
const firstNode = target.querySelector(".o_hierarchy_node_container");
await click(firstNode, ".o_hierarchy_node_container button .fa-chevron-up");
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 2);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
[
"GeorgesAlbert", "LisaGeorges",
],
);
assert.containsOnce(target, ".o_hierarchy_node_container button .fa-chevron-up");
await click(target, ".o_hierarchy_node_container button .fa-chevron-up");
assert.containsN(target, ".o_hierarchy_row", 3);
assert.containsN(target, ".o_hierarchy_node", 4);
});
QUnit.test("fetch parent when parent record is in the same row", async function (assert) {
serverData.models["hr.employee"].records.push(
{ id: 5, name: "Lisa", parent_id: 2, child_ids: []},
);
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
domain: [["id", "in", [1, 2, 3, 4, 5]]], // simulate a search
});
assert.containsOnce(target, ".o_hierarchy_row");
assert.containsN(target, ".o_hierarchy_node_container", 5);
assert.containsN(target, ".o_hierarchy_node_container button .fa-chevron-up", 4);
const firstNodeWithParentBtn = target.querySelector(".o_hierarchy_node_container:has(button .fa-chevron-up)");
await click(firstNodeWithParentBtn, ".o_hierarchy_node_container button .fa-chevron-up");
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
[
"Albert", "GeorgesAlbert", "JosephineAlbert",
],
);
});
QUnit.test("fetch parent of node with children displayed", async function (assert) {
serverData.models["hr.employee"].records.push(
{ id: 5, name: "Lisa", parent_id: 2, child_ids: []},
);
serverData.models["hr.employee"].records.find((rec) => rec.id === 2).child_ids.push(5);
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
domain: [["id", "in", [1, 2, 3, 4, 5]]], // simulate a search
});
assert.containsOnce(target, ".o_hierarchy_row");
assert.containsN(target, ".o_hierarchy_node_container", 5);
assert.containsN(target, ".o_hierarchy_node_container button .fa-chevron-up", 4);
const georgesNode = target.querySelector(".o_hierarchy_node_container:has(button[name=hierarchy_search_parent_node])");
assert.strictEqual(georgesNode.querySelector(".o_hierarchy_node_content").textContent, "GeorgesAlbert");
await click(georgesNode, "button[name=hierarchy_search_subsidiaries]");
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 5);
const rows = target.querySelectorAll(".o_hierarchy_row");
let row = rows[0];
assert.containsN(row, ".o_hierarchy_node", 4);
assert.deepEqual(
getNodesTextContent(row.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "GeorgesAlbert", "JosephineAlbert", "LouisJosephine"],
);
row = rows[1];
assert.containsN(row, ".o_hierarchy_node", 1);
assert.strictEqual(row.querySelector(".o_hierarchy_node_content").textContent, "LisaGeorges");
const firstNodeWithParentBtn = target.querySelector(".o_hierarchy_node_container:has(button .fa-chevron-up)");
await click(firstNodeWithParentBtn, ".o_hierarchy_node_container button .fa-chevron-up");
assert.containsN(target, ".o_hierarchy_row", 3);
assert.containsN(target, ".o_hierarchy_node", 4);
});
QUnit.test("drag and drop is disabled by default", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "GeorgesAlbert", "JosephineAlbert"],
);
const nodeContainers = target.querySelectorAll(".o_hierarchy_node_container");
const georgesNodeContainer = nodeContainers[1];
assert.strictEqual(georgesNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "GeorgesAlbert");
await dragAndDrop(
georgesNodeContainer.querySelector(".o_hierarchy_node"),
".o_hierarchy_row:first-child"
);
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "GeorgesAlbert", "JosephineAlbert"],
);
});
QUnit.test("drag and drop record on another row", async function (assert) {
serverData.views["hr.employee,false,hierarchy"] = serverData.views["hr.employee,false,hierarchy"].replace("<hierarchy>", "<hierarchy draggable='1'>");
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "GeorgesAlbert", "JosephineAlbert"],
);
const nodeContainers = target.querySelectorAll(".o_hierarchy_node_container");
const georgesNodeContainer = nodeContainers[1];
assert.strictEqual(georgesNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "GeorgesAlbert");
await dragAndDrop(
georgesNodeContainer.querySelector(".o_hierarchy_node"),
".o_hierarchy_row:first-child"
);
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "Georges", "JosephineAlbert"],
"Georges should no longer have a manager"
);
});
QUnit.test("drag and drop record on sibling node", async function (assert) {
serverData.views["hr.employee,false,hierarchy"] = serverData.views["hr.employee,false,hierarchy"].replace("<hierarchy>", "<hierarchy draggable='1'>");
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "GeorgesAlbert", "JosephineAlbert"],
);
const nodeContainers = target.querySelectorAll(".o_hierarchy_node_container");
const georgesNodeContainer = nodeContainers[1];
const josephineNodeContainer = nodeContainers[2];
assert.strictEqual(georgesNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "GeorgesAlbert");
assert.strictEqual(josephineNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "JosephineAlbert");
await dragAndDrop(
georgesNodeContainer.querySelector(".o_hierarchy_node"),
josephineNodeContainer,
);
assert.containsN(target, ".o_hierarchy_row", 3);
assert.containsN(target, ".o_hierarchy_node", 4);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "JosephineAlbert", "LouisJosephine", "GeorgesJosephine"],
"Georges should have Josephine as manager"
);
});
QUnit.test("drag and drop node unfolded on first row", async function (assert) {
serverData.views["hr.employee,false,hierarchy"] = serverData.views["hr.employee,false,hierarchy"].replace("<hierarchy>", "<hierarchy draggable='1'>");
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "GeorgesAlbert", "JosephineAlbert"],
);
const nodeContainers = target.querySelectorAll(".o_hierarchy_node_container");
const josephineNodeContainer = nodeContainers[2];
assert.strictEqual(josephineNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "JosephineAlbert");
await click(josephineNodeContainer, "button[name='hierarchy_search_subsidiaries']");
await dragAndDrop(
josephineNodeContainer.querySelector(".o_hierarchy_node"),
".o_hierarchy_row:first-child"
);
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "Josephine", "LouisJosephine"],
"Georges should have Josephine as manager"
);
});
QUnit.test("drag and drop node when other node is unfolded on first row", async function (assert) {
serverData.views["hr.employee,false,hierarchy"] = serverData.views["hr.employee,false,hierarchy"].replace("<hierarchy>", "<hierarchy draggable='1'>");
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "GeorgesAlbert", "JosephineAlbert"],
);
const nodeContainers = target.querySelectorAll(".o_hierarchy_node_container");
const georgesNodeContainer = nodeContainers[1];
const josephineNodeContainer = nodeContainers[2];
assert.strictEqual(georgesNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "GeorgesAlbert");
assert.strictEqual(josephineNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "JosephineAlbert");
await click(josephineNodeContainer, "button[name='hierarchy_search_subsidiaries']");
await dragAndDrop(
georgesNodeContainer.querySelector(".o_hierarchy_node"),
".o_hierarchy_row:first-child"
);
assert.containsN(target, ".o_hierarchy_row", 3);
assert.containsN(target, ".o_hierarchy_node", 4);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["Albert", "Georges", "JosephineAlbert", "LouisJosephine"],
"Georges should no longer have a manager"
);
});
QUnit.test("drag node and move it on a row", async function (assert) {
serverData.views["hr.employee,false,hierarchy"] = serverData.views["hr.employee,false,hierarchy"].replace("<hierarchy>", "<hierarchy draggable='1'>");
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_node", 3);
const nodeContainers = target.querySelectorAll(".o_hierarchy_node_container");
const georgesNodeContainer = nodeContainers[1];
assert.strictEqual(georgesNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "GeorgesAlbert");
const { drop, moveTo } = await drag(georgesNodeContainer.querySelector(".o_hierarchy_node"));
await moveTo(".o_hierarchy_row:first-child");
assert.hasClass(georgesNodeContainer, "o_hierarchy_dragged");
assert.hasClass(target.querySelector(".o_hierarchy_row"), "o_hierarchy_hover");
await drop();
assert.containsNone(target, ".o_hierarchy_node.o_hierarchy_dragged");
assert.containsNone(target, ".o_hierarchy_row.o_hierarchy_hover");
});
QUnit.test("drag node and move it on another node", async function (assert) {
serverData.views["hr.employee,false,hierarchy"] = serverData.views["hr.employee,false,hierarchy"].replace("<hierarchy>", "<hierarchy draggable='1'>");
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsN(target, ".o_hierarchy_node", 3);
const nodeContainers = target.querySelectorAll(".o_hierarchy_node_container");
const georgesNodeContainer = nodeContainers[1];
const josephineNodeContainer = nodeContainers[2];
assert.strictEqual(georgesNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "GeorgesAlbert");
assert.strictEqual(josephineNodeContainer.querySelector(".o_hierarchy_node_content").textContent, "JosephineAlbert");
const { drop, moveTo } = await drag(georgesNodeContainer.querySelector(".o_hierarchy_node"));
await moveTo(josephineNodeContainer.querySelector(".o_hierarchy_node"));
assert.hasClass(georgesNodeContainer, "o_hierarchy_dragged");
assert.hasClass(georgesNodeContainer.querySelector(".o_hierarchy_node"), "shadow");
assert.hasClass(josephineNodeContainer, "o_hierarchy_hover");
await drop();
assert.containsNone(target, ".o_hierarchy_node.o_hierarchy_dragged");
assert.containsNone(target, ".o_hierarchy_node.o_hierarchy_hover");
assert.containsNone(target, ".o_hierarchy_node.shadow");
});
QUnit.test("check default icon is correctly used inside button to display child nodes", async function (assert) {
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsOnce(target, ".o_hierarchy_node button[name=hierarchy_search_subsidiaries].btn-primary");
assert.strictEqual(target.querySelector(".o_hierarchy_node button[name=hierarchy_search_subsidiaries].btn-primary").textContent.trim(), "Unfold 1");
assert.containsOnce(
target,
".o_hierarchy_node button[name=hierarchy_search_subsidiaries] i.fa-share-alt.o_hierarchy_icon",
"The default icon of the hierarchy view should be displayed inside the button to unfold the node."
);
});
QUnit.test("use other icon used next to Unfold string displayed inside the button", async function (assert) {
serverData.views["hr.employee,false,hierarchy"] = serverData.views["hr.employee,false,hierarchy"].replace("<hierarchy>", "<hierarchy icon='fa-users'>");
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
});
assert.containsOnce(target, ".o_hierarchy_node button[name=hierarchy_search_subsidiaries].btn-primary");
assert.strictEqual(target.querySelector(".o_hierarchy_node button[name=hierarchy_search_subsidiaries].btn-primary").textContent.trim(), "Unfold 1");
assert.containsOnce(
target,
".o_hierarchy_node button[name=hierarchy_search_subsidiaries] i.fa-users",
"The icon defined in the attribute icon in hierarchy tag should be displayed inside the button to unfold the node instead of the default one."
);
});
QUnit.test("use `hierarchy_res_id` context to load the view at that specific node with its siblings and parent node", async function (assert) {
serverData.models["hr.employee"].records.push(
{ id: 5, name: "Lisa", parent_id: 3, child_ids: []},
);
serverData.models["hr.employee"].records.find((rec) => rec.id === 3).child_ids.push(5);
await makeView({
type: "hierarchy",
resModel: "hr.employee",
serverData,
context: {
hierarchy_res_id: 5,
},
});
assert.containsN(target, ".o_hierarchy_row", 2);
assert.containsN(target, ".o_hierarchy_node", 3);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_hierarchy_node_content")),
["JosephineAlbert", "LisaJosephine", "LouisJosephine"]
);
assert.containsOnce(target, ".o_hierarchy_node_container button[name=hierarchy_search_parent_node]");
assert.containsOnce(target, ".o_hierarchy_node_highlighted");
assert.containsOnce(target, ".o_hierarchy_node.o_hierarchy_node_highlighted");
assert.strictEqual(target.querySelector(".o_hierarchy_node_highlighted .o_hierarchy_node_content").textContent, "LisaJosephine");
});
});
@@ -1,78 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, {
async _performRPC(route, args) {
if (args.method === "hierarchy_read") {
return this.mockHierarchyRead(args.model, args.args, args.kwargs);
}
return super._performRPC(route, args);
},
mockHierarchyRead(modelName, args, kwargs) {
const [domain, fields, parentFieldName, childFieldName] = args;
if (!(parentFieldName in fields)) {
fields.push(parentFieldName);
}
let records = this.mockSearchRead(modelName, [domain, fields], kwargs);
let focusedRecordId = false;
let fetchChildIdsForAllRecords = false;
if (records.length === 1) {
const record = records[0];
let domain = [[parentFieldName, "=", record.id], ["id", "!=", record.id]];
if (record[parentFieldName]) {
focusedRecordId = record.id;
const parentResId = record[parentFieldName][0];
domain = [
["id", "!=", record.id],
"|",
["id", "=", parentResId],
[parentFieldName, "in", [parentResId, record.id]],
];
}
records.push(...this.mockSearchRead(modelName, [domain, fields], kwargs));
} else if (!records.length) {
records = this.mockSearchRead(
modelName,
[
[[parentFieldName, "=", false]],
fields,
],
kwargs,
);
} else {
fetchChildIdsForAllRecords = true
}
const childrenIdsPerRecordId = {};
if (!childFieldName) {
const parentResIds = [];
for (const rec of records) {
if (rec[parentFieldName]) {
parentResIds.push(rec[parentFieldName][0]);
}
}
const recordIds = records.map((rec) => rec.id);
const data = this.mockReadGroup(modelName, {
domain: [[parentFieldName, "in", fetchChildIdsForAllRecords ? recordIds : recordIds.filter((id) => !parentResIds.includes(id))]],
groupby: [parentFieldName],
fields: ["id:array_agg"],
});
for (const d of data) {
childrenIdsPerRecordId[d[parentFieldName][0]] = d.id;
}
}
if (focusedRecordId || Object.keys(childrenIdsPerRecordId).length) {
for (const record of records) {
if (record.id in childrenIdsPerRecordId) {
record.__child_ids__ = childrenIdsPerRecordId[record.id];
}
if (record.id === focusedRecordId) {
record.__focus__ = true;
}
}
}
return records;
},
})