[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:
@@ -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,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
|
||||
|
||||
@@ -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'})
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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;
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user