[MERGE] web_hierarchy,hr_org_chart: add hierarchy view for employees and departments
Purpose
=======
The goal is to be able to have a hierarchy view to see the organisation of a company, that is, to have organisation chart for `hr.employee` model.
To do that, a new view will be introduced in `web_hierarchy` module, called `Hierarchy` view and it will first be used in `hr_org_chart` module for `hr.employee`, `hr.employee.public` and `hr.department` models.
Implementation details
----------------------
- A new python method will be introduced in the models when `web_hierarchy` module is
installed. That method, called `hierarchy_read`, is created to fetch the data for the
hierarchy view when the view will be loaded.
- The definition of hierarchy view is inspired to the definition of the kanban,
that is, a template called `hierarchy-box` has to be defined in the arch to draw the card
displayed in the node of the hierarchy view.
- Some attributes can be defined on the `hierarchy` tag:
- `parent_field` (default='parent_id') contains the parent field name
to use to fetch the parent record and to be able to fetch the hierarchy.
- `child_field` (optional attribute) contains the child field name
to use to find the child records (it is in fact the one2many field).
If that field is defined then some queries can be avoided since we
directly fetch the children when a read/search_read is done.
- `icon` (default='fa-share-alt o_hierarchy_icon') icon displayed inside the
`Unfold` button (to show the child nodes) in hierarchy node.
- `draggable` (default=False) True to allow the user to drag and drop a
node (= record) into a row or another node to update its parent node.
task-3376776
closes odoo/odoo#131162
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
export class HrPresenceStatus extends Component {
|
||||
static template = "hr.HrPresenceStatus";
|
||||
static props = {
|
||||
...standardFieldProps,
|
||||
tag: { type: String, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
tag: "small",
|
||||
};
|
||||
|
||||
get classNames() {
|
||||
const classNames = ["fa"];
|
||||
classNames.push(
|
||||
`fa-${this.props.tag === "small" ? "fw" : "2x"}`,
|
||||
this.icon,
|
||||
"o_button_icon",
|
||||
"hr_presence",
|
||||
"align-middle",
|
||||
this.color,
|
||||
)
|
||||
return classNames.join(" ");
|
||||
}
|
||||
|
||||
get color() {
|
||||
switch (this.value) {
|
||||
case "presence_present":
|
||||
case "presence_absent_active":
|
||||
return "text-success";
|
||||
case "presence_absent":
|
||||
return "text-muted";
|
||||
case "presence_to_define":
|
||||
return "text-warning";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return `fa-circle${this.value.startsWith("presence_absent") ? "-o" : ""}`;
|
||||
}
|
||||
|
||||
get label() {
|
||||
return this.value !== false
|
||||
? this.options.find(([value, label]) => value === this.value)[1]
|
||||
: "";
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.props.record.fields[this.props.name].selection.filter(
|
||||
(option) => option[0] !== false && option[1] !== ""
|
||||
);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
}
|
||||
|
||||
export const hrPresenceStatus = {
|
||||
component: HrPresenceStatus,
|
||||
displayName: _t("HR Presence Status"),
|
||||
extractProps({ viewType }, dynamicInfo) {
|
||||
return {
|
||||
tag: viewType === "kanban" ? "span" : "small",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("fields").add("hr_presence_status", hrPresenceStatus)
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr.HrPresenceStatus">
|
||||
<t t-tag="props.tag" role="img" t-att-class="classNames" t-att-aria-label="label" t-att-title="label"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -147,31 +147,8 @@
|
||||
</div>
|
||||
<ul>
|
||||
<li id="last_login"/>
|
||||
<li id="presence">
|
||||
<t t-if="record.show_hr_icon_display.raw_value">
|
||||
<div class="o_employee_availability align-items-center">
|
||||
<!-- Employee is present/connected and it is normal according to his work schedule -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_present'"
|
||||
class="fa fa-2x fa-circle text-success" name="presence_present" role="img"
|
||||
aria-label="Present" title="Present"
|
||||
/>
|
||||
<!-- Employee is not present and it is normal according to his work schedule -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_absent'"
|
||||
class="fa fa-2x fa-circle-o text-warning" name="presence_absent" role="img"
|
||||
aria-label="Absent" title="Absent"
|
||||
/>
|
||||
<!-- Employee is connected but according to his work schedule, he should not work for now -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_absent_active'"
|
||||
class="fa fa-2x fa-circle-o text-success" name="presence_absent_active" role="img"
|
||||
aria-label="Present but not active" title="Present but not active"
|
||||
/>
|
||||
<!-- Employee is not here but according to his work schedule, he should be connected -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_to_define'"
|
||||
class="fa fa-2x fa-circle text-muted" name="presence_to_define" role="img"
|
||||
aria-label="To define" title="To define"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
<li t-if="record.show_hr_icon_display.raw_value">
|
||||
<field name="hr_icon_display" class="o_employee_availability align-items-center" widget="hr_presence_status" />
|
||||
</li>
|
||||
<li t-if="record.work_email.raw_value">
|
||||
<i class="fa fa-fw me-2 fa-envelope text-primary" title="Email"/>
|
||||
|
||||
@@ -52,8 +52,6 @@
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="user_id" invisible="1"/>
|
||||
<field name="user_partner_id" invisible="1"/>
|
||||
<field name="hr_presence_state" invisible="1"/>
|
||||
<field name="hr_icon_display" invisible="1"/>
|
||||
<field name="image_128" invisible="1" />
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="last_activity_time" invisible="1"/>
|
||||
@@ -81,16 +79,7 @@
|
||||
</div>
|
||||
<div class="o_employee_avatar m-0 p-0">
|
||||
<field name="image_1920" widget='image' class="oe_avatar m-0" options='{"zoom": true, "preview_image":"avatar_128"}'/>
|
||||
<div id="hr_presence_status" class="d-flex align-items-end fs-6 o_employee_availability">
|
||||
<!-- Employee is present/connected and it is normal according to his work schedule -->
|
||||
<small role="img" class="fa fa-fw fa-circle text-success o_button_icon hr_presence align-middle" invisible="hr_icon_display != 'presence_present'" aria-label="Present" title="Present"/>
|
||||
<!-- Employee is not present and it is normal according to his work schedule -->
|
||||
<small role="img" class="fa fa-fw fa-circle-o text-muted o_button_icon hr_presence align-middle" invisible="hr_icon_display != 'presence_absent'" aria-label="Absent" title="Absent" name="presence_absent"/>
|
||||
<!-- Employee is connected but according to his work schedule, he should not work for now -->
|
||||
<small role="img" class="fa fa-fw fa-circle-o text-success o_button_icon hr_presence align-middle" invisible="hr_icon_display != 'presence_absent_active'" aria-label="Present but not active" title="Present but not active" name="presence_absent_active"/>
|
||||
<!-- Employee is not here but according to his work schedule, he should be connected -->
|
||||
<small role="img" class="fa fa-fw fa-circle text-warning o_button_icon hr_presence align-middle" invisible="hr_icon_display != 'presence_to_define'" aria-label="To define" title="To define" name="presence_to_define"/>
|
||||
</div>
|
||||
<field name="hr_icon_display" class="d-flex align-items-end fs-6 o_employee_availability" widget="hr_presence_status"/>
|
||||
</div>
|
||||
</div>
|
||||
<group>
|
||||
@@ -300,31 +289,8 @@
|
||||
</div>
|
||||
<ul>
|
||||
<li id="last_login"/>
|
||||
<li id="presence">
|
||||
<t t-if="record.show_hr_icon_display.raw_value">
|
||||
<div class="o_employee_availability d-flex flex-row align-items-start">
|
||||
<!-- Employee is present/connected and it is normal according to his work schedule -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_present'"
|
||||
class="fa fa-2x fa-circle text-success" name="presence_present" role="img"
|
||||
aria-label="Present" title="Present"
|
||||
/>
|
||||
<!-- Employee is not present/connected and it is normal according to his work schedule -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_absent'"
|
||||
class="fa fa-2x fa-circle-o text-warning" name="presence_absent" role="img"
|
||||
aria-label="Absent" title="Absent"
|
||||
/>
|
||||
<!-- Employee is connected but according to his work schedule, he should not work for now -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_absent_active'"
|
||||
class="fa fa-2x fa-circle-o text-success" name="presence_absent_active" role="img"
|
||||
aria-label="Present but not active" title="Present but not active"
|
||||
/>
|
||||
<!-- Employee is not here but according to his work schedule, he should be connected -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_to_define'"
|
||||
class="fa fa-2x fa-circle text-muted" name="presence_to_define" role="img"
|
||||
aria-label="To define" title="To define"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
<li t-if="record.show_hr_icon_display.raw_value">
|
||||
<field name="hr_icon_display" class="o_employee_availability" widget="hr_presence_status" />
|
||||
</li>
|
||||
<li t-if="record.work_email.raw_value" class="o_text_overflow">
|
||||
<i class="fa fa-fw me-2 fa-envelope text-primary" title="Email"/>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
import { HrPresenceStatus } from "@hr/components/hr_presence_status/hr_presence_status";
|
||||
|
||||
patch(HrPresenceStatus.prototype, {
|
||||
get classNames() {
|
||||
const classNames = super.classNames;
|
||||
if (this.value.startsWith("presence_holiday")) {
|
||||
return `${classNames} fa-plane`;
|
||||
}
|
||||
return classNames;
|
||||
},
|
||||
|
||||
get color() {
|
||||
if (this.value.startsWith("presence_holiday")) {
|
||||
return `text-${this.value === "presence_holiday_present" ? "success" : "warning"}`;
|
||||
}
|
||||
return super.color;
|
||||
},
|
||||
});
|
||||
@@ -94,21 +94,6 @@
|
||||
<xpath expr="//templates" position="before">
|
||||
<field name="is_absent"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_employee_availability')]" position="inside">
|
||||
<t t-if="record.hr_icon_display.raw_value.startsWith('presence_holiday')">
|
||||
<!-- Employee is on time off but he is connected -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_holiday_present'"
|
||||
class="fa fa-2x fa-plane text-success me-1" name="presence_absent_active" role="img"
|
||||
aria-label="Present but on leave" title="Present but on leave"
|
||||
/>
|
||||
<!-- Employee is on time off and he is not connected -->
|
||||
<span t-if="record.hr_icon_display.raw_value == 'presence_holiday_absent'"
|
||||
class="fa fa-2x fa-plane text-warning me-1" name="presence_absent" role="img"
|
||||
aria-label="To define" title="On Time Off"
|
||||
/>
|
||||
<span>On Time Off</span>
|
||||
</t>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -124,23 +109,6 @@
|
||||
<field name="leave_date_to"/>
|
||||
<field name="is_absent"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_employee_availability')]" position="inside">
|
||||
<span class="d-flex" style="font-size: 100%"
|
||||
t-att-class="record.current_leave_state.raw_value=='validate'?'oe_kanban_button oe_kanban_color_3':'oe_kanban_button oe_kanban_color_2'"
|
||||
t-att-title="luxon.DateTime.fromISO(record.leave_date_from.raw_value).toFormat('ccc d MMM') + ' - ' + luxon.DateTime.fromISO(record.leave_date_to.raw_value).toFormat('ccc d MMM')">
|
||||
<!-- Employee is absent, in holiday but he is connected -->
|
||||
<i t-if="record.hr_icon_display.raw_value == 'presence_holiday_present'"
|
||||
class="fa fa-2x fa-plane text-success me-1" name="presence_absent_active" role="img"
|
||||
aria-label="Present but on leave" title="Present but on leave"
|
||||
/>
|
||||
<!-- Employee is on holiday, not present and not connected -->
|
||||
<i t-if="record.hr_icon_display.raw_value == 'presence_holiday_absent'"
|
||||
class="fa fa-2x fa-plane text-warning me-1" name="presence_absent" role="img"
|
||||
aria-label="To define" title="On Time Off"
|
||||
/>
|
||||
<field t-if="record.current_leave_id.raw_value" name="current_leave_id"/>
|
||||
</span>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -151,14 +119,8 @@
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name = "priority" eval="20" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='hr_presence_status']" position="attributes">
|
||||
<attribute name="invisible">not last_activity or not user_id or not id</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='hr_presence_status']" position="inside">
|
||||
<!-- Employee is absent, in holiday but he is connected -->
|
||||
<div role="img" class="fa fa-fw fa-plane text-success o_button_icon hr_presence" invisible="hr_icon_display != 'presence_holiday_present'" aria-label="Present but on leave" title="Present but on leave" name="presence_absent_active"/>
|
||||
<!-- Employee is on holiday, not present and not connected -->
|
||||
<div role="img" class="fa fa-fw fa-plane text-warning o_button_icon hr_presence" invisible="hr_icon_display != 'presence_holiday_absent'" aria-label="On Time Off" title="On Time Off" name="presence_absent"/>
|
||||
<xpath expr="//field[@name='hr_icon_display']" position="attributes">
|
||||
<attribute name="invisible">not last_activity or not id</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='managers']" position="inside">
|
||||
<field name="leave_manager_id" widget="many2one_avatar_user"/>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
import { HrPresenceStatus, hrPresenceStatus } from "@hr/components/hr_presence_status/hr_presence_status";
|
||||
|
||||
patch(HrPresenceStatus.prototype, {
|
||||
get color() {
|
||||
if (this.location) {
|
||||
let color = "text-muted";
|
||||
if (this.props.record.data.hr_presence_state !== "to_define") {
|
||||
color = this.props.record.data.hr_presence_state === "present" ? "text-success" : "text-warning";
|
||||
}
|
||||
return color;
|
||||
}
|
||||
return super.color;
|
||||
},
|
||||
|
||||
get icon() {
|
||||
if (this.location) {
|
||||
switch (this.location) {
|
||||
case "home":
|
||||
return "fa-home";
|
||||
case "office":
|
||||
return "fa-building";
|
||||
case "other":
|
||||
return "fa-map-marker";
|
||||
}
|
||||
}
|
||||
return super.icon;
|
||||
},
|
||||
|
||||
get label() {
|
||||
if (this.location) {
|
||||
return _t("Working from %s", this.location);
|
||||
}
|
||||
return super.label;
|
||||
},
|
||||
|
||||
get location() {
|
||||
let location = this.value?.split("_")[1] || "";
|
||||
if (location && !['home', 'office', 'other'].includes(location)) {
|
||||
location = "";
|
||||
}
|
||||
return location;
|
||||
},
|
||||
});
|
||||
|
||||
const additionalFieldDependencies = [
|
||||
{ name: "hr_presence_state", type: "selection" },
|
||||
];
|
||||
if (typeof hrPresenceStatus.fieldDependencies === "function") {
|
||||
const oldFieldDependencies = hrPresenceStatus.fieldDependencies;
|
||||
hrPresenceStatus.fieldDependencies = (widgetInfo) => {
|
||||
const fieldDependencies = oldFieldDependencies(widgetInfo);
|
||||
fieldDependencies.push(...additionalFieldDependencies);
|
||||
return fieldDependencies;
|
||||
}
|
||||
} else {
|
||||
hrPresenceStatus.fieldDependencies = [
|
||||
...(hrPresenceStatus.fieldDependencies || []),
|
||||
...additionalFieldDependencies,
|
||||
];
|
||||
}
|
||||
@@ -53,45 +53,8 @@
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.hr_kanban_view_employees"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[hasclass('o_employee_availability')]" position="inside">
|
||||
<t t-set="text_color" t-value="'text-muted'"/>
|
||||
<t t-if="record.hr_presence_state.raw_value !== 'to_define'">
|
||||
<t t-set="text_color" t-value="record.hr_presence_state.raw_value === 'present' ? 'text-success' : 'text-warning'"/>
|
||||
</t>
|
||||
<t t-set="location_map" t-value="{'home': 'fa-home', 'office': 'fa-building', 'other': 'fa-map-marker'}"/>
|
||||
<t t-set="location" t-value="record.hr_icon_display.raw_value.split('_')[1]"/>
|
||||
<t t-set="icon" t-value="location_map[location]"/>
|
||||
<t t-if="location !== 'holiday'">
|
||||
<span class="fa fa-2x" t-attf-class="{{icon}} {{text_color}} d-flex me-2" role="img"
|
||||
t-attf-aria-label="Working from {{location}}" t-attf-title="Working from {{location}}"
|
||||
t-attf-name="presence {{record.hr_presence_state.raw_value}} at {{location}}"
|
||||
/>
|
||||
<field name="name_work_location_display" string="Work Location"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="hr_employee_public_view_kanban" model="ir.ui.view">
|
||||
<field name="name">hr.employee.kanban.inherit.homeworking</field>
|
||||
<field name="model">hr.employee.public</field>
|
||||
<field name="inherit_id" ref="hr.hr_employee_public_view_kanban"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[hasclass('o_employee_availability')]" position="inside">
|
||||
<t t-set="text_color" t-value="text-muted"/>
|
||||
<t t-if="record.hr_presence_state.raw_value !== 'to_define'">
|
||||
<t t-set="text_color" t-value="record.hr_presence_state.raw_value === 'present' ? 'text-success' : 'text-warning'"/>
|
||||
</t>
|
||||
<t t-set="location_map" t-value="{'home': 'fa-home', 'office': 'fa-building', 'other': 'fa-map-marker'}"/>
|
||||
<t t-set="location" t-value="record.hr_icon_display.raw_value.split('_')[1]"/>
|
||||
<t t-set="icon" t-value="location_map[location]"/>
|
||||
<t t-if="location !== 'holiday'">
|
||||
<span class="fa fa-2x" t-attf-class="{{icon}} {{text_color}}" role="img"
|
||||
t-att-aria-label="`Working from ${location}`" t-att-title="`Working from ${location}`"
|
||||
t-att-name="`presence ${record.hr_presence_state.raw_value} at ${location}`"
|
||||
/>
|
||||
</t>
|
||||
<xpath expr="//field[@name='hr_icon_display'][hasclass('o_employee_availability')]" position="after">
|
||||
<field name="name_work_location_display" class="ps-2" string="Work Location"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -13,10 +13,13 @@ 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_views.xml'
|
||||
'views/hr_department_views.xml',
|
||||
'views/hr_views.xml',
|
||||
'views/hr_employee_public_views.xml',
|
||||
'views/hr_org_chart_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web._assets_primary_variables': [
|
||||
@@ -24,6 +27,7 @@ This module extend the employee form with a organizational chart.
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'hr_org_chart/static/src/fields/*',
|
||||
'hr_org_chart/static/src/views/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'hr_org_chart/static/tests/**/*',
|
||||
|
||||
@@ -12,6 +12,12 @@ class HrEmployeeBase(models.AbstractModel):
|
||||
'Indirect Subordinates Count',
|
||||
compute='_compute_subordinates', recursive=True, store=False,
|
||||
compute_sudo=True)
|
||||
department_color = fields.Integer("Department Color", related="department_id.color")
|
||||
child_count = fields.Integer(
|
||||
'Direct Subordinates Count',
|
||||
compute='_compute_child_count', recursive=True,
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
def _get_subordinates(self, parents=None):
|
||||
"""
|
||||
@@ -56,3 +62,13 @@ class HrEmployeeBase(models.AbstractModel):
|
||||
if not self.env.user.employee_id.subordinate_ids:
|
||||
return [('id', operator, self.env.user.employee_id.id)]
|
||||
return (['!'] if operator == '!=' else []) + [('id', 'in', self.env.user.employee_id.subordinate_ids.ids)]
|
||||
|
||||
def _compute_child_count(self):
|
||||
employee_read_group = self._read_group(
|
||||
[('parent_id', 'in', self.ids)],
|
||||
['parent_id'],
|
||||
['id:count'],
|
||||
)
|
||||
child_count_per_parent_id = dict(employee_read_group)
|
||||
for employee in self:
|
||||
employee.child_count = child_count_per_parent_id.get(employee._origin, 0)
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Avatar } from "@mail/views/web/fields/avatar/avatar";
|
||||
|
||||
import { HierarchyRenderer } from "@web_hierarchy/hierarchy_renderer";
|
||||
|
||||
export class HrEmployeeHierarchyRenderer extends HierarchyRenderer {
|
||||
static template = "hr_org_chart.HrEmployeeHierarchyRenderer";
|
||||
static components = {
|
||||
...HierarchyRenderer.components,
|
||||
Avatar,
|
||||
};
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.o_hierarchy_renderer {
|
||||
.o_hierarchy_parent_node_container {
|
||||
.o_avatar > span {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
|
||||
<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"
|
||||
resId="row.parentNode.resId"
|
||||
displayName="row.parentNode.data.name"
|
||||
/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { hierarchyView } from "@web_hierarchy/hierarchy_view";
|
||||
import { HrEmployeeHierarchyRenderer } from "./hr_employee_hierarchy_renderer";
|
||||
|
||||
export const hrEmployeeHierarchyView = {
|
||||
...hierarchyView,
|
||||
Renderer: HrEmployeeHierarchyRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("hr_employee_hierarchy", hrEmployeeHierarchyView);
|
||||
@@ -10,46 +10,104 @@ class TestEmployee(TestHrCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
def test_is_subordinate(self):
|
||||
employee_georges, employee_paul, employee_pierre = self.env['hr.employee'].with_user(self.res_users_hr_officer).create([
|
||||
cls.employee_georges, cls.employee_paul, cls.employee_pierre = cls.env['hr.employee'].with_user(cls.res_users_hr_officer).create([
|
||||
{'name': 'Georges'},
|
||||
{'name': 'Paul'},
|
||||
{'name': 'Pierre'},
|
||||
])
|
||||
self.res_users_hr_officer.employee_id = employee_georges
|
||||
employee_paul.parent_id = employee_georges
|
||||
employees = employee_paul + employee_pierre
|
||||
|
||||
def test_is_subordinate(self):
|
||||
self.res_users_hr_officer.employee_id = self.employee_georges
|
||||
self.employee_paul.parent_id = self.employee_georges
|
||||
employees = self.employee_paul + self.employee_pierre
|
||||
self.assertTrue(
|
||||
employee_paul.is_subordinate,
|
||||
self.employee_paul.is_subordinate,
|
||||
'Paul should be a subordinate of the current user since the current is his manager.')
|
||||
self.assertFalse(
|
||||
employee_pierre.is_subordinate,
|
||||
self.employee_pierre.is_subordinate,
|
||||
'Pierre should not be a subordinate of the current user since Pierre has no manager.')
|
||||
|
||||
self.assertEqual(employees.filtered_domain(employees._search_is_subordinate('=', True)), employee_paul)
|
||||
self.assertEqual(employees.filtered_domain(employees._search_is_subordinate('=', False)), employee_pierre)
|
||||
self.assertEqual(employees.filtered_domain(employees._search_is_subordinate('=', True)), self.employee_paul)
|
||||
self.assertEqual(employees.filtered_domain(employees._search_is_subordinate('=', False)), self.employee_pierre)
|
||||
|
||||
employee_pierre.parent_id = employee_paul
|
||||
self.employee_pierre.parent_id = self.employee_paul
|
||||
self.assertTrue(
|
||||
employee_paul.is_subordinate,
|
||||
self.employee_paul.is_subordinate,
|
||||
'Paul should be a subordinate of the current user since the current is his manager.')
|
||||
self.assertTrue(
|
||||
employee_pierre.is_subordinate,
|
||||
self.employee_pierre.is_subordinate,
|
||||
"Pierre should now be a subordinate of the current user since Paul is his manager and the current user is the Paul's manager.")
|
||||
|
||||
self.assertEqual(employees.filtered_domain(employees._search_is_subordinate('=', True)), employees)
|
||||
self.assertFalse(employees.filtered_domain(employees._search_is_subordinate('=', False)))
|
||||
|
||||
employee_paul.parent_id = False
|
||||
self.employee_paul.parent_id = False
|
||||
employees._compute_is_subordinate()
|
||||
|
||||
self.assertFalse(
|
||||
employee_paul.is_subordinate,
|
||||
self.employee_paul.is_subordinate,
|
||||
'Paul should no longer be a subordinate of the current user since Paul has no manager.')
|
||||
self.assertFalse(
|
||||
employee_pierre.is_subordinate,
|
||||
self.employee_pierre.is_subordinate,
|
||||
"Pierre should not be a subordinate of the current user since Paul is his manager and the current user is not the Paul's manager.")
|
||||
|
||||
self.assertFalse(employees.filtered_domain(employees._search_is_subordinate('=', True)))
|
||||
self.assertEqual(employees.filtered_domain(employees._search_is_subordinate('=', False)), employees)
|
||||
|
||||
def test_hierarchy_read(self):
|
||||
HrEmployee = self.env['hr.employee']
|
||||
employees = self.employee_georges + self.employee_paul + self.employee_pierre
|
||||
result = HrEmployee.hierarchy_read([('id', 'in', employees.ids)], ['id'], 'parent_id')
|
||||
for emp in employees:
|
||||
self.assertIn({'id': emp.id, 'parent_id': False}, result)
|
||||
|
||||
self.employee_georges.parent_id = self.employee_paul
|
||||
self.employee_pierre.parent_id = self.employee_paul
|
||||
result = HrEmployee.hierarchy_read([('id', 'in', employees.ids)], ['id'], 'parent_id')
|
||||
self.assertEqual(len(result), 3)
|
||||
for emp in employees:
|
||||
emp_dict = {'id': emp.id, 'parent_id': emp.parent_id.id and (emp.parent_id.id, emp.parent_id.display_name)}
|
||||
if not emp.parent_id:
|
||||
emp_dict['__child_ids__'] = [self.employee_georges.id, self.employee_pierre.id]
|
||||
self.assertIn(emp_dict, result)
|
||||
|
||||
employee_count = HrEmployee.search_count([('id', 'not in', employees.ids), ('parent_id', '=', False)])
|
||||
result = HrEmployee.hierarchy_read([('parent_id', '=', False)], ['id'], 'parent_id')
|
||||
self.assertEqual(len(result), 1 + employee_count)
|
||||
for employee_dict in result:
|
||||
self.assertFalse(employee_dict['parent_id'], "Each employee in the result should not have any parent set.")
|
||||
self.assertIn({'id': self.employee_paul.id, 'parent_id': False, '__child_ids__': [self.employee_georges.id, self.employee_pierre.id]}, result)
|
||||
|
||||
result = HrEmployee.hierarchy_read([('id', '=', self.employee_paul.id)], ['id'], 'parent_id')
|
||||
self.assertEqual(len(result), 3)
|
||||
for emp in employees:
|
||||
emp_dict = {'id': emp.id, 'parent_id': emp.parent_id.id and (emp.parent_id.id, emp.parent_id.display_name)}
|
||||
self.assertIn(emp_dict, result)
|
||||
|
||||
result = HrEmployee.hierarchy_read([('id', '=', self.employee_georges.id)], ['id'], 'parent_id')
|
||||
self.assertEqual(len(result), 3)
|
||||
for emp in employees:
|
||||
emp_dict = {'id': emp.id, 'parent_id': emp.parent_id.id and (emp.parent_id.id, emp.parent_id.display_name)}
|
||||
if emp == self.employee_georges:
|
||||
emp_dict['__focus__'] = True
|
||||
self.assertIn(emp_dict, result)
|
||||
|
||||
self.employee_pierre.parent_id = self.employee_georges
|
||||
result = HrEmployee.hierarchy_read([('id', '=', self.employee_georges.id)], ['id'], 'parent_id')
|
||||
self.assertEqual(len(result), 3)
|
||||
for emp in employees:
|
||||
emp_dict = {'id': emp.id, 'parent_id': emp.parent_id.id and (emp.parent_id.id, emp.parent_id.display_name)}
|
||||
if emp == self.employee_georges:
|
||||
emp_dict['__focus__'] = True
|
||||
self.assertIn(emp_dict, result)
|
||||
|
||||
result = HrEmployee.hierarchy_read([('id', '=', self.employee_pierre.id)], ['id'], 'parent_id')
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertIn(
|
||||
{'id': self.employee_pierre.id, 'parent_id': (self.employee_georges.id, self.employee_georges.name), '__focus__': True},
|
||||
result
|
||||
)
|
||||
self.assertIn(
|
||||
{'id': self.employee_georges.id, 'parent_id': (self.employee_paul.id, self.employee_paul.name)},
|
||||
result
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="hr_department_hierarchy_view" model="ir.ui.view">
|
||||
<field name="name">hr.departmnent.view.hierarchy</field>
|
||||
<field name="model">hr.department</field>
|
||||
<field name="arch" type="xml">
|
||||
<hierarchy child_field="child_ids" draggable="1">
|
||||
<field name="name" />
|
||||
<field name="color" />
|
||||
<field name="total_employee" />
|
||||
<templates>
|
||||
<t t-name="hierarchy-box">
|
||||
<div t-attf-class="o_hierarchy_node_header d-flex justify-content-center align-items-center o_hierarchy_node_color_{{ record.color.raw_value }}">
|
||||
<field name="name" />
|
||||
</div>
|
||||
<div class="o_hierarchy_node_body d-flex flex-column">
|
||||
<field name="manager_id" class="pt-1 ps-1" widget="many2one_avatar" />
|
||||
<div>
|
||||
<button class="btn btn-link" name="%(hr.act_employee_from_department)d" type="action">
|
||||
<t t-out="record.total_employee.raw_value"/> Employees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</hierarchy>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr.hr_department_kanban_action" model="ir.actions.act_window">
|
||||
<field name="view_mode">kanban,tree,hierarchy,form</field>
|
||||
</record>
|
||||
|
||||
<record id="hr.hr_department_tree_action" model="ir.actions.act_window">
|
||||
<field name="view_mode">tree,kanban,hierarchy,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="hr_employee_public_hierarchy_view" model="ir.ui.view">
|
||||
<field name="name">hr.employee.public.hierarchy.view</field>
|
||||
<field name="model">hr.employee.public</field>
|
||||
<field name="arch" type="xml">
|
||||
<hierarchy child_field="child_ids" js_class="hr_employee_hierarchy" icon="fa-users" draggable="0">
|
||||
<field name="name" />
|
||||
<field name="job_id" />
|
||||
<field name="department_color" />
|
||||
<field name="hr_icon_display" />
|
||||
<field name="department_id" />
|
||||
<templates>
|
||||
<t t-name="hierarchy-box">
|
||||
<div t-attf-class="o_hierarchy_node_header d-flex justify-content-center pb-4 o_hierarchy_node_color_{{ record.department_color.raw_value }}"
|
||||
t-att-title="record.department_id.value"
|
||||
>
|
||||
<field name="image_1024" preview_image="image_128" options="{'zoom': true, 'zoom_delay': 1000}" widget="background_image" />
|
||||
</div>
|
||||
<div class="o_hierarchy_node_body d-flex flex-column text-center">
|
||||
<div class="w-100 position-relative">
|
||||
<field class="fw-bold" name="name" />
|
||||
<field name="hr_icon_display" class="d-flex align-items-end o_employee_availability" widget="hr_presence_status" />
|
||||
</div>
|
||||
<field name="job_id" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</hierarchy>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr.hr_employee_public_action" model="ir.actions.act_window">
|
||||
<field name="view_mode">kanban,tree,form,hierarchy</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem
|
||||
id="menu_hr_employee_org_chart"
|
||||
name="Org Chart"
|
||||
action="action_hr_employee_org_chart"
|
||||
groups="hr.group_hr_user"
|
||||
parent="hr.menu_hr_employee_payroll"
|
||||
sequence="5"
|
||||
/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,10 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="action_hr_employee_org_chart" model="ir.actions.act_window">
|
||||
<field name="name">Org Chart</field>
|
||||
<field name="res_model">hr.employee</field>
|
||||
<field name="view_mode">hierarchy,kanban,tree,form,activity,graph,pivot</field>
|
||||
<field name="domain">[]</field>
|
||||
<field name="context">{'chat_icon': True}</field>
|
||||
<field name="view_id" eval="False"/>
|
||||
<field name="search_view_id" ref="hr.view_employee_filter"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new employee
|
||||
</p><p>
|
||||
With just a quick glance on the Odoo employee screen, you
|
||||
can easily find all the information you need for each person;
|
||||
contact data, job position, availability, etc.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_employee_view_form_inherit_org_chart" model="ir.ui.view">
|
||||
<field name="name">hr.employee.view.form.inherit.org_chart</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="%(hr_org_chart.action_hr_employee_org_chart)d" string="Org Chart" type="action" context="{'hierarchy_res_id': id}" invisible="not parent_id and not child_ids"/>
|
||||
</xpath>
|
||||
<div id="o_work_employee_main" position="after">
|
||||
<div id="o_employee_right" class="col-lg-4 px-0 ps-lg-5 pe-lg-0">
|
||||
<separator string="Organization Chart"/>
|
||||
@@ -14,6 +36,36 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_employee_hierarchy_view" model="ir.ui.view">
|
||||
<field name="name">hr.employee.view.hierarchy</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<hierarchy child_field="child_ids" js_class="hr_employee_hierarchy" icon="fa-users" draggable="1">
|
||||
<field name="name" />
|
||||
<field name="job_id" />
|
||||
<field name="department_color" />
|
||||
<field name="hr_icon_display" />
|
||||
<field name="department_id" />
|
||||
<templates>
|
||||
<t t-name="hierarchy-box">
|
||||
<div t-attf-class="o_hierarchy_node_header d-flex justify-content-center pb-4 o_hierarchy_node_color_{{ record.department_color.raw_value }}"
|
||||
t-att-title="record.department_id.value"
|
||||
>
|
||||
<field name="image_1024" preview_image="image_128" options="{'zoom': true, 'zoom_delay': 1000}" widget="background_image" />
|
||||
</div>
|
||||
<div class="o_hierarchy_node_body d-flex flex-column text-center">
|
||||
<div class="w-100 position-relative">
|
||||
<field class="fw-bold" name="name" />
|
||||
<field name="hr_icon_display" class="d-flex align-items-end o_employee_availability" widget="hr_presence_status" />
|
||||
</div>
|
||||
<field name="job_id" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</hierarchy>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_employee_public_view_form_inherit_org_chart" model="ir.ui.view">
|
||||
<field name="name">hr.employee.public.view.form.inherit.org_chart</field>
|
||||
<field name="model">hr.employee.public</field>
|
||||
@@ -41,4 +93,8 @@
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr.open_view_employee_list_my" model="ir.actions.act_window">
|
||||
<field name="view_mode">kanban,tree,form,activity,graph,pivot,hierarchy</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -61,10 +61,12 @@
|
||||
<nav class="o_cp_switch_buttons d-print-none d-none d-xl-inline-flex btn-group">
|
||||
<t t-foreach="env.config.viewSwitcherEntries" t-as="view" t-key="view.type">
|
||||
<button class="btn btn-secondary o_switch_view "
|
||||
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
|
||||
t-attf-class="o_{{view.type}} {{view.active ? 'active' : ''}}"
|
||||
t-att-data-tooltip="view.name"
|
||||
t-on-click="() => this.onViewClicked(view.type)"
|
||||
/>
|
||||
>
|
||||
<i t-att-class="view.icon" />
|
||||
</button>
|
||||
</t>
|
||||
</nav>
|
||||
</t>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<t t-name="web.SearchBar">
|
||||
<div class="o_cp_searchview d-flex input-group" role="search" t-ref="root">
|
||||
<div class="o_searchview form-control d-print-contents d-flex align-items-center py-1 border-end-0" role="search" aria-autocomplete="list">
|
||||
<div class="o_searchview form-control d-print-contents d-flex align-items-center py-1" role="search" aria-autocomplete="list">
|
||||
<i class="o_searchview_icon d-print-none oi oi-search me-2"
|
||||
role="img"
|
||||
aria-label="Search..."
|
||||
|
||||
@@ -120,17 +120,19 @@ export class KanbanCompiler extends ViewCompiler {
|
||||
compileField(el, params) {
|
||||
let compiled;
|
||||
let isSpan = false;
|
||||
const recordExpr = params.recordExpr || '__comp__.props.record';
|
||||
const dataPointIdExpr = params.dataPointIdExpr || `${recordExpr}.id`;
|
||||
if (!el.hasAttribute("widget")) {
|
||||
isSpan = true;
|
||||
// fields without a specified widget are rendered as simple spans in kanban records
|
||||
const fieldId = el.getAttribute("field_id");
|
||||
compiled = createElement("span", {
|
||||
"t-out": `__comp__.getFormattedValue("${fieldId}")`,
|
||||
"t-out": params.formattedValueExpr || `__comp__.getFormattedValue("${fieldId}")`,
|
||||
});
|
||||
} else {
|
||||
compiled = super.compileField(el, params);
|
||||
const fieldId = el.getAttribute("field_id");
|
||||
compiled.setAttribute("id", `'${fieldId}_' + __comp__.props.record.id`);
|
||||
compiled.setAttribute("id", `'${fieldId}_' + ${dataPointIdExpr}`);
|
||||
// In x2many kanban, records can be edited in a dialog. The same record as the one of
|
||||
// the kanban is used for the form view dialog, so its mode is switched to "edit", but
|
||||
// we don't want to see it in edition in the background. For that reason, we force its
|
||||
@@ -140,10 +142,10 @@ export class KanbanCompiler extends ViewCompiler {
|
||||
if (readonlyAttr) {
|
||||
compiled.setAttribute(
|
||||
"readonly",
|
||||
`__comp__.props.record.isInEdition || (${readonlyAttr})`
|
||||
`${recordExpr}.isInEdition || (${readonlyAttr})`
|
||||
);
|
||||
} else {
|
||||
compiled.setAttribute("readonly", `__comp__.props.record.isInEdition`);
|
||||
compiled.setAttribute("readonly", `${recordExpr}.isInEdition`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -309,9 +309,10 @@ export class ViewCompiler {
|
||||
if (tag === "a" && type === "url") {
|
||||
tag = "button";
|
||||
}
|
||||
const recordExpr = params.recordExpr || "__comp__.props.record";
|
||||
const button = createElement("ViewButton", {
|
||||
tag: toStringExpression(tag),
|
||||
record: "__comp__.props.record",
|
||||
record: recordExpr,
|
||||
});
|
||||
|
||||
assignOwlDirectives(button, el);
|
||||
@@ -368,13 +369,14 @@ export class ViewCompiler {
|
||||
const fieldId = el.getAttribute("field_id");
|
||||
|
||||
const field = createElement("Field");
|
||||
const recordExpr = params.recordExpr || "__comp__.props.record";
|
||||
field.setAttribute("id", `'${fieldId}'`);
|
||||
field.setAttribute("name", `'${fieldName}'`);
|
||||
field.setAttribute("record", params.recordExpr || "__comp__.props.record");
|
||||
field.setAttribute("record", recordExpr);
|
||||
field.setAttribute("fieldInfo", `__comp__.props.archInfo.fieldNodes['${fieldId}']`);
|
||||
field.setAttribute(
|
||||
"readonly",
|
||||
`__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew`
|
||||
`__comp__.props.archInfo.activeActions?.edit === false and !${recordExpr}.isNew`
|
||||
);
|
||||
|
||||
if (el.hasAttribute("widget")) {
|
||||
|
||||
@@ -2198,6 +2198,21 @@ export class MockServer {
|
||||
});
|
||||
}
|
||||
criterion = [criterion[0], "in", childIDs];
|
||||
} else if (criterion[1] === "parent_of") {
|
||||
// 'parent_of' operator is not supported by domain.js, so we replace
|
||||
// in by the 'in' operator (with the ids of parent and its ancestors)
|
||||
const childID = criterion[2];
|
||||
const parentIDs = [];
|
||||
const recordPerID = {};
|
||||
for (const record of records) {
|
||||
recordPerID[record.id] = record;
|
||||
}
|
||||
let record = recordPerID[childID];
|
||||
while (record) {
|
||||
parentIDs.push(record.id);
|
||||
record = record.parent_id && recordPerID[record.parent_id];
|
||||
}
|
||||
criterion = [criterion[0], "in", parentIDs];
|
||||
}
|
||||
// In case of many2many field, if domain operator is '=' generally change it to 'in' operator
|
||||
const field = model.fields[criterion[0]] || {};
|
||||
|
||||
@@ -100,8 +100,9 @@ QUnit.module("Search", (hooks) => {
|
||||
|
||||
assert.strictEqual(views[0].getAttribute("data-tooltip"), "List");
|
||||
assert.hasClass(views[0], "active");
|
||||
assert.containsOnce(views[0], ".oi-view-list");
|
||||
assert.strictEqual(views[1].getAttribute("data-tooltip"), "Kanban");
|
||||
assert.hasClass(views[1], "oi-view-kanban");
|
||||
assert.containsOnce(views[1], ".oi-view-kanban");
|
||||
|
||||
controlPanel.env.services.action.switchView = (viewType) => {
|
||||
assert.step(viewType);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,29 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Web Hierarchy',
|
||||
'category': 'Hidden',
|
||||
'version': '1.0',
|
||||
'description':
|
||||
"""
|
||||
Odoo Web Hierarchy view
|
||||
=======================
|
||||
|
||||
This module adds a new view called to be able to define a view to display
|
||||
an organization such as an Organization Chart for employees for instance.
|
||||
""",
|
||||
'depends': ['web'],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'web_hierarchy/static/src/hierarchy.variables.scss',
|
||||
'web_hierarchy/static/src/**/*',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
('before', 'web_hierarchy/static/src/hierarchy.variables.scss', 'web_hierarchy/static/src/**/*.variables.dark.scss'),
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'web_hierarchy/static/tests/**/*',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_actions
|
||||
from . import ir_ui_view
|
||||
from . import models
|
||||
@@ -0,0 +1,9 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ActWindowView(models.Model):
|
||||
_inherit = 'ir.actions.act_window.view'
|
||||
|
||||
view_mode = fields.Selection(selection_add=[('hierarchy', 'Hierarchy')], ondelete={'hierarchy': 'cascade'})
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
@api.model
|
||||
def hierarchy_read(self, domain, fields, parent_field, child_field=None):
|
||||
if parent_field not in fields:
|
||||
fields.append(parent_field)
|
||||
records = self.search(domain)
|
||||
focus_record = self.env[self._name]
|
||||
fetch_child_ids_for_all_records = False
|
||||
if len(records) == 1:
|
||||
domain = [(parent_field, '=', records.id), ('id', '!=', records.id)]
|
||||
if records[parent_field]:
|
||||
focus_record = records
|
||||
records += focus_record[parent_field]
|
||||
domain = [('id', 'not in', records.ids), (parent_field, 'in', records.ids)]
|
||||
records += self.search(domain)
|
||||
elif not records:
|
||||
records = self.search([(parent_field, '=', False)])
|
||||
else:
|
||||
fetch_child_ids_for_all_records = True
|
||||
children_ids_per_record_id = {}
|
||||
if not child_field:
|
||||
children_ids_per_record_id = {
|
||||
record.id: child_ids
|
||||
for record, child_ids in self._read_group(
|
||||
[(parent_field, 'in', records.ids if fetch_child_ids_for_all_records else (records - records[parent_field]).ids)],
|
||||
(parent_field,),
|
||||
('id:array_agg',),
|
||||
)
|
||||
}
|
||||
result = records.read(fields)
|
||||
if children_ids_per_record_id or focus_record:
|
||||
for record_data in result:
|
||||
if record_data['id'] in children_ids_per_record_id:
|
||||
record_data['__child_ids__'] = children_ids_per_record_id[record_data['id']]
|
||||
if record_data['id'] == focus_record.id:
|
||||
record_data['__focus__'] = True
|
||||
return result
|
||||
@@ -0,0 +1,3 @@
|
||||
$hierarchy_element_dragged-background: $gray-100 !default;
|
||||
$hierarchy_element_hover-background: #{darken($gray-100, 5%)} !default;
|
||||
$hierarchy_element_hover-border: #{$gray-100} !default;
|
||||
@@ -0,0 +1,3 @@
|
||||
$hierarchy_element_dragged-background: white !default;
|
||||
$hierarchy_element_hover-background: #e6f2f3 !default;
|
||||
$hierarchy_element_hover-border: #017e84 !default;
|
||||
@@ -0,0 +1,74 @@
|
||||
/** @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/** @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 = "web_hierarchy.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
|
||||
<t t-name="web_hierarchy.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>
|
||||
@@ -0,0 +1,28 @@
|
||||
/** @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__",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, useRef } from "@odoo/owl";
|
||||
|
||||
import { useBus } from "@web/core/utils/hooks";
|
||||
import { useModel } from "@web/model/model";
|
||||
import { addFieldDependencies, extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
|
||||
import { CogMenu } from "@web/search/cog_menu/cog_menu";
|
||||
import { Layout } from "@web/search/layout";
|
||||
import { SearchBar } from "@web/search/search_bar/search_bar";
|
||||
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
|
||||
import { standardViewProps } from "@web/views/standard_view_props";
|
||||
import { useViewButtons } from "@web/views/view_button/view_button_hook";
|
||||
|
||||
export class HierarchyController extends Component {
|
||||
static components = {
|
||||
Layout,
|
||||
CogMenu,
|
||||
SearchBar,
|
||||
};
|
||||
static props = {
|
||||
...standardViewProps,
|
||||
Model: Function,
|
||||
Renderer: Function,
|
||||
buttonTemplate: String,
|
||||
archInfo: Object,
|
||||
};
|
||||
static template = "web_hierarchy.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) {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
<t t-name="web_hierarchy.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="web_hierarchy.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>
|
||||
@@ -0,0 +1,929 @@
|
||||
/** @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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/** @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);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** @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 "./hierarchy_node_draggable";
|
||||
|
||||
export class HierarchyRenderer extends Component {
|
||||
static components = {
|
||||
HierarchyCard,
|
||||
};
|
||||
static props = {
|
||||
model: Object,
|
||||
openRecord: Function,
|
||||
archInfo: Object,
|
||||
templates: Object,
|
||||
};
|
||||
static template = "web_hierarchy.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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
|
||||
<t t-name="web_hierarchy.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>
|
||||
@@ -0,0 +1,36 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { HierarchyArchParser } from "./hierarchy_arch_parser";
|
||||
import { HierarchyController } from "./hierarchy_controller";
|
||||
import { HierarchyModel } from "./hierarchy_model";
|
||||
import { HierarchyRenderer } from "./hierarchy_renderer";
|
||||
|
||||
export const hierarchyView = {
|
||||
type: "hierarchy",
|
||||
display_name: _t("Hierarchy"),
|
||||
icon: "fa fa-share-alt o_hierarchy_icon",
|
||||
isMobileFriendly: false,
|
||||
multiRecord: true,
|
||||
ArchParser: HierarchyArchParser,
|
||||
Controller: HierarchyController,
|
||||
Model: HierarchyModel,
|
||||
Renderer: HierarchyRenderer,
|
||||
buttonTemplate: "web_hierarchy.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);
|
||||
@@ -0,0 +1,11 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
/** @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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/** @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