19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

View file

@ -1,8 +1,7 @@
/** @odoo-module */
import session from 'web.session'
import { _t } from "@web/core/l10n/translation";
import { user } from "@web/core/user";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { useEnv } from "@odoo/owl";
/**
* Redirect to the sub employee kanban view.
@ -15,8 +14,6 @@ import { useEnv } from "@odoo/owl";
export function onEmployeeSubRedirect() {
const actionService = useService('action');
const orm = useService('orm');
const rpc = useService('rpc');
const env = useEnv();
return async (event) => {
const employeeId = parseInt(event.currentTarget.dataset.employeeId);
@ -28,11 +25,11 @@ export function onEmployeeSubRedirect() {
const subordinateIds = await rpc('/hr/get_subordinates', {
employee_id: employeeId,
subordinates_type: type,
context: session.user_context
context: user.context
});
let action = await orm.call('hr.employee', 'get_formview_action', [employeeId]);
action = {...action,
name: env._t('Team'),
name: _t('Team'),
view_mode: 'kanban,list,form',
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
domain: [['id', 'in', subordinateIds]],

View file

@ -1,35 +1,22 @@
/** @odoo-module */
import {Field} from '@web/views/fields/field';
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { usePopover } from "@web/core/popover/popover_hook";
import { user } from "@web/core/user";
import { onEmployeeSubRedirect } from './hooks';
const { Component, onWillStart, onWillUpdateProps, useState } = owl;
function useUniquePopover() {
const popover = usePopover();
let remove = null;
return Object.assign(Object.create(popover), {
add(target, component, props, options) {
if (remove) {
remove();
}
remove = popover.add(target, component, props, options);
return () => {
remove();
remove = null;
};
},
});
}
import { Component, useState } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { useRecordObserver } from "@web/model/relational_model/utils";
class HrOrgChartPopover extends Component {
static template = "hr_org_chart.hr_orgchart_emp_popover";
static props = {
employee: Object,
close: Function,
};
async setup() {
super.setup();
this.rpc = useService('rpc');
this.orm = useService('orm');
this.actionService = useService("action");
this._onEmployeeSubRedirect = onEmployeeSubRedirect();
@ -44,61 +31,38 @@ class HrOrgChartPopover extends Component {
*/
async _onEmployeeRedirect(employeeId) {
const action = await this.orm.call('hr.employee', 'get_formview_action', [employeeId]);
this.actionService.doAction(action);
this.actionService.doAction(action);
}
}
HrOrgChartPopover.template = 'hr_org_chart.hr_orgchart_emp_popover';
export class HrOrgChart extends Field {
export class HrOrgChart extends Component {
static template = "hr_org_chart.hr_org_chart";
static props = {...standardFieldProps};
async setup() {
super.setup();
this.rpc = useService('rpc');
this.orm = useService('orm');
this.actionService = useService("action");
this.popover = useUniquePopover();
this.jsonStringify = JSON.stringify;
this.popover = usePopover(HrOrgChartPopover);
this.state = useState({'employee_id': null});
this.lastParent = null;
this.max_level = null;
this.lastEmployeeId = null;
this._onEmployeeSubRedirect = onEmployeeSubRedirect();
onWillStart(async () => {
this.employee = this.props.record.data;
// the widget is either dispayed in the context of a hr.employee form or a res.users form
this.state.employee_id =
this.employee.employee_ids !== undefined
? this.employee.employee_ids.resIds[0]
: this.employee.id;
const parentId =
this.employee.parent_id && this.employee.parent_id[0]
? this.employee.parent_id[0]
: false;
const forceReload =
this.lastRecord !== this.props.record || this.lastParent != parentId;
this.lastParent = parentId;
this.lastRecord = this.props.record;
await this.fetchEmployeeData(this.state.employee_id, forceReload);
});
onWillUpdateProps(async (nextProps) => {
const newParentId =
nextProps.record.data.parent_id && nextProps.record.data.parent_id[0]
? nextProps.record.data.parent_id[0]
: false;
const newEmployeeId = nextProps.record.data.id || false;
useRecordObserver(async (record) => {
const newParentId = record.data.parent_id?.id || false;
const newEmployeeId = record.resId || false;
if (this.lastParent !== newParentId || this.state.employee_id !== newEmployeeId) {
this.lastParent = newParentId;
this.max_level = null; // Reset max_level to default
await this.fetchEmployeeData(newEmployeeId, true);
await this.fetchEmployeeData(newEmployeeId, newParentId, true);
}
this.state.employee_id = newEmployeeId;
});
}
async fetchEmployeeData(employeeId, force = false) {
async fetchEmployeeData(employeeId, newParentId = null, force = false) {
if (!employeeId) {
this.managers = [];
this.children = [];
@ -108,14 +72,14 @@ export class HrOrgChart extends Field {
this.view_employee_id = null;
} else if (employeeId !== this.view_employee_id || force) {
this.view_employee_id = employeeId;
var orgData = await this.rpc(
let orgData = await rpc(
'/hr/get_org_chart',
{
employee_id: employeeId,
new_parent_id: newParentId,
context: {
...Component.env.session.user_context,
...user.context,
max_level: this.max_level,
new_parent_id: this.lastParent,
},
});
if (Object.keys(orgData).length === 0) {
@ -133,12 +97,7 @@ export class HrOrgChart extends Field {
}
_onOpenPopover(event, employee) {
this.popover.add(
event.currentTarget,
this.constructor.components.Popover,
{employee},
{closeOnClickAway: true}
);
this.popover.open(event.currentTarget, { employee });
}
/**
@ -150,19 +109,17 @@ export class HrOrgChart extends Field {
*/
async _onEmployeeRedirect(employeeId) {
const action = await this.orm.call('hr.employee', 'get_formview_action', [employeeId]);
this.actionService.doAction(action);
this.actionService.doAction(action);
}
async _onEmployeeMoreManager(managerId) {
this.max_level = 100; // Set a high level to fetch all managers
await this.fetchEmployeeData(this.state.employee_id, true);
await this.fetchEmployeeData(this.state.employee_id, null, true);
}
}
HrOrgChart.components = {
Popover: HrOrgChartPopover,
export const hrOrgChart = {
component: HrOrgChart,
};
HrOrgChart.template = 'hr_org_chart.hr_org_chart';
registry.category("fields").add("hr_org_chart", HrOrgChart);
registry.category("fields").add("hr_org_chart", hrOrgChart);

View file

@ -1,10 +1,27 @@
#o_employee_right {
@include media-breakpoint-up(lg) {
border-left: $border-width solid $o-form-separator-color;
}
#o_employee_org_chart {
--treeEntry-padding-v: #{map-get($spacers, 2)};
--treeEntry--after-width: #{$o-hr-org-chart-entry-pic-small-size * .25};
--treeEntry--before-top: calc(-2 * (var(--treeEntry-padding-v)) - (#{$o-hr-org-chart-entry-pic-small-size} * .5));
.o_org_chart_entry {
padding-top: var(--treeEntry-padding-v);
padding-bottom: var(--treeEntry-padding-v);
&:before {
bottom: 50%;
height: auto;
}
&:first-child:before {
--treeEntry--before-top: calc(var(--treeEntry-padding-v) * -1);
}
&:after {
top: calc(#{$o-hr-org-chart-entry-pic-small-size} * .5 + var(--treeEntry-padding-v));
width: var(--treeEntry--after-width);
}
.o_media_object {
width: $o-hr-org-chart-entry-pic-small-size;
height: $o-hr-org-chart-entry-pic-small-size;
@ -17,40 +34,81 @@
height: $o-hr-org-chart-entry-pic-size;
}
&:not(.o_org_chart_entry_self):hover .o_media_object {
box-shadow: 0 0 0 $border-width*2 $info;
.o_employee_redirect:hover {
.o_media_object {
box-shadow: 0 0 0 $border-width * 2 $info;
}
}
}
.o_org_chart_entry_self_container.o_org_chart_has_managers {
margin-left: $o-hr-org-chart-entry-pic-small-size * .25;
.o_org_chart_entry_self {
.o_media_object:not(.placeholder) {
outline: $border-width solid $info;
outline-offset: $border-width;
}
}
.o_org_chart_group_down.o_org_chart_has_managers {
padding-left: $o-hr-org-chart-entry-pic-size * .6;
.o_org_chart_group_up {
.o_org_chart_entry {
--treeEntry-padding-h: 0px;
--treeEntry--after-display: none;
--treeEntry--beforeAfter-left: #{$o-hr-org-chart-entry-pic-small-size * .5};
--treeEntry--before-top: calc(var(--treeEntry-padding-v) * -1);
&:before {
bottom: calc(100% - 1 * var(--treeEntry-padding-v));
}
&:first-of-type:before {
display: none;
}
}
}
// ORGANIGRAM LINES
.o_org_chart_group_up .o_treeEntry {
--treeEntry-padding-h: 0px;
--treeEntry--before-top: 50%;
--treeEntry--after-display: none;
--treeEntry--beforeAfter-left: #{$o-hr-org-chart-entry-pic-small-size * .5};
}
.o_org_chart_entry_self_container .o_treeEntry {
--treeEntry-padding-h: #{$o-hr-org-chart-entry-pic-small-size * .5};
--treeEntry-padding-v: #{$o-hr-org-chart-entry-pic-size * .25};
}
.o_org_chart_group_down .o_treeEntry {
.o_org_chart_group_down .o_treeEntry, .o_org_chart_entry_self_container .o_treeEntry {
--treeEntry-padding-h: #{$o-hr-org-chart-entry-pic-size};
--treeEntry-padding-v: #{$o-hr-org-chart-entry-pic-small-size * .5};
padding-left: calc(var(--treeEntry-padding-h) - var(--treeEntry--after-width));
}
.o_org_chart_entry_self_container {
.o_treeEntry:after {
top: calc(#{$o-hr-org-chart-entry-pic-size} * .5 + var(--treeEntry-padding-v));
}
&.o_org_chart_has_managers {
.o_org_chart_entry_self {
--treeEntry-padding-h: #{$o-hr-org-chart-entry-pic-small-size};
padding-left: calc(var(--treeEntry-padding-h) - var(--treeEntry--after-width));
}
& + .o_org_chart_group_down {
padding-left: calc(#{$o-hr-org-chart-entry-pic-small-size} - var(--treeEntry--after-width));
}
}
& + .o_org_chart_group_down {
--treeEntry--after-width: #{$o-hr-org-chart-entry-pic-size * .25};
--treeEntry-padding-h: calc(#{$o-hr-org-chart-entry-pic-size} - var(--treeEntry--after-width));
}
}
}
// Right to Left specific style to flip the popover arrow
.o_rtl .o_org_chart_popup.popover .arrow {
left: 100%;
transform: matrix(-1, 0, 0, 1, 0, 0);
// Specific popup style
.o_org_chart_popup {
--popover-header-padding-y: #{map-get($spacers, 1)};
--popover-header-padding-x: #{map-get($spacers, 2)};
.o_employee_redirect {
--btn-padding-x: var(--popover-header-padding-x);
}
.o_media_object {
$-media-object-size: $o-hr-org-chart-entry-pic-small-size - map-get($spacers, 2);
width: $-media-object-size;
height: $-media-object-size;
background-size: cover;
background-position: center center;
}
}

View file

@ -1,69 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr_org_chart.hr_org_chart_employee" owl="1">
<t t-name="hr_org_chart.hr_org_chart_employee">
<t t-set="is_self" t-value="employee.id == view_employee_id"/>
<section t-if="employee_type == 'self'" t-attf-class="o_org_chart_entry_self_container #{managers.length &gt; 0 ? 'o_org_chart_has_managers' : ''}">
<div t-attf-class="o_org_chart_entry o_org_chart_entry_#{employee_type} d-flex position-relative py-2 overflow-visible #{managers.length &gt; 0 ? 'o_treeEntry' : ''}">
<div t-attf-class="o_org_chart_entry o_org_chart_entry_#{employee_type} position-relative d-flex align-items-center overflow-visible #{managers.length &gt; 0 ? 'o_treeEntry' : ''}">
<t t-call="hr_org_chart.hr_org_chart_employee_content">
<t t-set="is_self" t-value="is_self"/>
</t>
</div>
</section>
<div t-else="" t-attf-class="o_org_chart_entry o_org_chart_entry_#{employee_type} o_treeEntry d-flex position-relative py-2 overflow-visible">
<div t-else="" t-attf-class="o_org_chart_entry o_org_chart_entry_#{employee_type} o_treeEntry position-relative d-flex align-items-center overflow-visible">
<t t-call="hr_org_chart.hr_org_chart_employee_content">
<t t-set="is_self" t-value="is_self"/>
</t>
</div>
</t>
<t t-name="hr_org_chart.hr_org_chart_employee_content" owl="1">
<div class="o_media_left position-relative">
<!-- NOTE: Since by the default on not squared images odoo add white borders,
use bg-images to get a clean and centred images -->
<a t-if="! is_self"
class="o_media_object d-block rounded-circle o_employee_redirect"
<t t-name="hr_org_chart.hr_org_chart_employee_informations">
<div class="o_media_left align-self-start rounded z-1">
<!-- NOTE: Use bg-images instead of img to get a clean and squared image -->
<div
class="o_media_object img-fluid d-block rounded"
t-att-style="'background-image:url(\'/web/image/hr.employee.public/' + employee.id + '/avatar_1024/\')'"
t-att-alt="employee.name"
t-att-data-employee-id="employee.id"
t-att-href="employee.link"
t-on-click.prevent="() => this._onEmployeeRedirect(employee.id)"/>
<div t-if="is_self"
class="o_media_object d-block rounded-circle border border-info"
t-att-style="'background-image:url(\'/web/image/hr.employee.public/' + employee.id + '/avatar_1024/\')'"/>
/>
</div>
<div class="d-flex flex-grow-1 align-items-center justify-content-between position-relative px-3">
<a t-if="!is_self" t-att-href="employee.link" class="o_employee_redirect d-flex flex-column" t-att-data-employee-id="employee.id" t-on-click.prevent="() => this._onEmployeeRedirect(employee.id)">
<b class="o_media_heading m-0 fs-6" t-esc="employee.name"/>
<small class="text-muted fw-bold" t-esc="employee.job_title"/>
</a>
<div t-if="is_self" class="d-flex flex-column">
<h5 class="o_media_heading m-0" t-esc="employee.name"/>
<small class="text-muted fw-bold" t-esc="employee.job_title"/>
</div>
<button t-if="employee.indirect_sub_count &gt; 0"
class="btn p-0 fs-3"
tabindex="0"
t-att-data-emp-name="employee.name"
t-att-data-emp-id="employee.id"
t-att-data-emp-dir-subs="employee.direct_sub_count"
t-att-data-emp-ind-subs="employee.indirect_sub_count"
data-bs-trigger="focus"
data-bs-toggle="popover"
t-on-click="(event) => this._onOpenPopover(event, employee)">
<a href="#"
t-attf-class="badge rounded-pill bg-white border {{employee.indirect_sub_count &lt; 10 ? 'px-2' : 'px-1' }}"
t-esc="employee.indirect_sub_count"
/>
</button>
<div class="position-relative d-flex flex-column flex-grow-1 justify-content-center ps-2 lh-sm">
<b class="o_media_heading fs-6 fw-bold" t-esc="employee.name"/>
<small class="o_employee_job text-muted" t-attf-class="#{is_self ? 'fw-bold' : 'opacity-75 opacity-100-hover'}" t-esc="employee.job_name"/>
</div>
</t>
<t t-name="hr_org_chart.hr_org_chart" owl="1">
<t t-name="hr_org_chart.hr_org_chart_employee_content">
<a t-if="!is_self" t-att-href="employee.link" class="o_employee_redirect d-flex w-100 me-auto opacity-trigger-hover" t-att-data-employee-id="employee.id" t-on-click.prevent="() => this._onEmployeeRedirect(employee.id)">
<t t-call="hr_org_chart.hr_org_chart_employee_informations">
<t t-set="is_self" t-value="is_self"/>
</t>
</a>
<div t-if="is_self" class="d-flex w-100 me-auto">
<t t-call="hr_org_chart.hr_org_chart_employee_informations">
<t t-set="is_self" t-value="is_self"/>
</t>
</div>
<button t-if="employee.indirect_sub_count &gt; 0"
class="btn btn-secondary btn-sm d-flex ms-2 rounded-pill"
tabindex="0"
t-att-data-emp-name="employee.name"
t-att-data-emp-id="employee.id"
t-att-data-emp-dir-subs="employee.direct_sub_count"
t-att-data-emp-ind-subs="employee.indirect_sub_count"
data-bs-trigger="focus"
data-bs-toggle="popover"
t-on-click="(event) => this._onOpenPopover(event, employee)">
<span
class="badge px-0"
t-esc="employee.indirect_sub_count"
/>
</button>
</t>
<t t-name="hr_org_chart.hr_org_chart">
<!-- NOTE: Desidered behaviour:
The maximun number of people is always 7 (including 'self'). Managers have priority over suburdinates
Eg. 1 Manager + 1 self = show just 5 subordinates (if availables)
@ -73,7 +72,7 @@
<t t-set="emp_count" t-value="0"/>
<div t-if='managers.length &gt; 0' class="o_org_chart_group_up position-relative">
<div t-if='managers_more' class="o_org_chart_more pe-3" t-attf-class="{{max_level !== null ? 'invisible' : ''}}">
<a href="#" t-att-data-employee-id="managers[0].id" class="o_employee_more_managers d-block bg-100 px-3" t-on-click.prevent="() => this._onEmployeeMoreManager(managers[0].id)">
<a href="#" t-att-data-employee-id="managers[0].id" class="o_employee_more_managers d-block bg-100 px-2" t-on-click.prevent="() => this._onEmployeeMoreManager(managers[0].id)">
<i class="fa fa-angle-double-up" role="img" aria-label="More managers" title="More managers"/>
</a>
</div>
@ -92,11 +91,44 @@
</t>
<t t-if="!children.length &amp;&amp; !managers.length">
<div class="alert alert-info" role="alert">
<p><b>No hierarchy position.</b></p>
<p>This employee has no manager or subordinate.</p>
<p>In order to get an organigram, set a manager and save the record.</p>
<p class="mb-3 text-muted fst-italic">Set a manager or reports to show in org chart.</p>
<div class="o_org_chart_entry_self_container opacity-50">
<div class="o_org_chart_entry o_org_chart_entry_self d-flex py-2 pe-none">
<div class="o_media_left">
<div class="o_media_object placeholder rounded opacity-25"/>
</div>
<div class="position-relative d-flex flex-grow-1 align-items-center px-2">
<div class="d-flex flex-column w-100 opacity-25">
<b class="o_media_heading placeholder col-4 m-0"/>
<small class="placeholder placeholder-xs col-3 mt-1 text-muted"/>
</div>
</div>
</div>
</div>
<section class="o_org_chart_group_down o_org_chart_has_managers opacity-50">
<div class="o_org_chart_entry o_treeEntry position-relative d-flex py-2 overflow-visible pe-none">
<div class="o_media_left">
<div class="o_media_object o_employee_redirect placeholder rounded opacity-25"/>
</div>
<div class="d-flex flex-grow-1 align-items-center justify-content-between px-2">
<div class="d-flex flex-column w-100 lh-sm opacity-25">
<b class="o_media_heading placeholder col-3 m-0"/>
<small class="placeholder placeholder-xs col-7 mt-1 text-muted"/>
</div>
</div>
</div>
<div class="o_org_chart_entry o_treeEntry position-relative d-flex py-2 overflow-visible pe-none">
<div class="o_media_left">
<div class="o_media_object o_employee_redirect placeholder rounded opacity-25"/>
</div>
<div class="d-flex flex-grow-1 align-items-center px-2">
<div class="d-flex flex-column w-100 opacity-25">
<b class="o_media_heading placeholder col-5 m-0"/>
<small class="placeholder placeholder-xs col-4 mt-1 text-muted"/>
</div>
</div>
</div>
</section>
</t>
<div t-if="children.length" t-attf-class="o_org_chart_group_down position-relative #{managers.length &gt; 0 ? 'o_org_chart_has_managers' : ''}">
@ -123,16 +155,14 @@
</div>
</t>
<t t-name="hr_org_chart.hr_orgchart_emp_popover" owl="1">
<div class="popover o_org_chart_popup" role="tooltip">
<div class="tooltip-arrow">
</div>
<t t-name="hr_org_chart.hr_orgchart_emp_popover">
<div class="o_org_chart_popup" role="tooltip">
<div class="tooltip-arrow"/>
<h3 class="popover-header">
<div class="d-flex align-items-center">
<span class="flex-shrink-0" t-att-style='"background-image:url(\"/web/image/hr.employee.public/" + props.employee.id + "/avatar_1024/\")"'/>
<b class="flew-grow-1"><t t-esc="props.employee.name"/></b>
<a href="#" class="ms-auto o_employee_redirect" t-att-data-employee-id="props.employee.id" t-on-click.prevent="() => this._onEmployeeRedirect(props.employee.id)"><i class="fa fa-external-link" role="img" aria-label='Redirect' title="Redirect"></i></a>
<span class="o_media_object flex-shrink-0 rounded me-1" t-att-style='"background-image:url(\"/web/image/hr.employee.public/" + props.employee.id + "/avatar_1024/\")"'/>
<b class="flex-grow-1 fw-medium"><t t-esc="props.employee.name"/></b>
<a href="#" class="o_employee_redirect btn btn-link" t-att-data-employee-id="props.employee.id" t-on-click.prevent="() => this._onEmployeeRedirect(props.employee.id)"><i class="fa fa-user" role="img" aria-label='View employee' title="View employee"/></a>
</div>
</h3>
<div class="popover-body">

View file

@ -0,0 +1,5 @@
import { HierarchyCard } from "@web_hierarchy/hierarchy_card";
export class HrEmployeeHierarchyCard extends HierarchyCard {
static template = "hr_org_chart.HrEmployeeHierarchyCard";
}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="hr_org_chart.HrEmployeeHierarchyCard" t-inherit="web_hierarchy.HierarchyCard">
<xpath expr="//button[@name='hierarchy_search_subsidiaries']" position="attributes">
<attribute name="class" separator=" " remove="d-grid"/>
<attribute name="class" separator=" " remove="rounded-0"/>
</xpath>
<xpath expr="//button[@name='hierarchy_search_subsidiaries']" position="inside">
<t t-out="props.node.childResIds.length"/> people
</xpath>
<xpath expr="//button[@name='hierarchy_search_subsidiaries']/t[@t-if]" position="replace">
<t t-if="!props.node.nodes.length">
<i class="fa fa-fw fa-caret-right"/>
</t>
</xpath>
<xpath expr="//button[@name='hierarchy_search_subsidiaries']/t[@t-else]" position="replace">
<t t-else="">
<i class="fa fa-fw fa-caret-down"/>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,13 @@
import { Avatar } from "@mail/views/web/fields/avatar/avatar";
import { HierarchyRenderer } from "@web_hierarchy/hierarchy_renderer";
import { HrEmployeeHierarchyCard } from "./hr_employee_hierarchy_card";
export class HrEmployeeHierarchyRenderer extends HierarchyRenderer {
static template = "hr_org_chart.HrEmployeeHierarchyRenderer";
static components = {
...HierarchyRenderer.components,
HierarchyCard: HrEmployeeHierarchyCard,
Avatar,
};
}

View file

@ -0,0 +1,7 @@
.o_hierarchy_renderer {
.o_hierarchy_parent_node_container {
.o_avatar > span {
width: 100% !important;
}
}
}

View file

@ -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.display_name || row.parentNode.data.name"
/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,11 @@
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);

View file

@ -0,0 +1,160 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import { contains, defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
class Employee extends models.Model {
_name = "hr.employee";
name = fields.Char();
parent_id = fields.Many2one({ string: "Manager", relation: "hr.employee" });
child_ids = fields.One2many({
string: "Subordinates",
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: [] },
];
show_action_helper() {
return false;
}
_views = {
hierarchy: `
<hierarchy js_class="hr_employee_hierarchy">
<templates>
<t t-name="hierarchy-box">
<div class="o_hierarchy_node_header">
<field name="name"/>
</div>
<div>
<field name="parent_id"/>
</div>
</t>
</templates>
</hierarchy>
`,
form: `
<form>
<sheet>
<group>
<field name="name"/>
<field name="parent_id"/>
</group>
</sheet>
</form>
`,
};
}
defineModels([Employee]);
defineMailModels();
test("load hierarchy view", async () => {
await mountView({
type: "hierarchy",
resModel: "hr.employee",
});
expect(".o_hierarchy_view").toHaveCount(1);
expect(".o_hierarchy_button_add").toHaveCount(1);
expect(".o_hierarchy_view .o_hierarchy_renderer").toHaveCount(1);
expect(".o_hierarchy_view .o_hierarchy_renderer > .o_hierarchy_container").toHaveCount(1);
expect(".o_hierarchy_row").toHaveCount(2);
expect(".o_hierarchy_separator").toHaveCount(1);
expect(".o_hierarchy_line_part").toHaveCount(2);
expect(".o_hierarchy_line_left").toHaveCount(1);
expect(".o_hierarchy_line_right").toHaveCount(1);
expect(".o_hierarchy_node_container").toHaveCount(3);
expect(".o_hierarchy_node").toHaveCount(3);
expect(".o_hierarchy_node_button").toHaveCount(2);
expect(".o_hierarchy_node_button.btn-primary").toHaveCount(1);
expect(".o_hierarchy_node_button.btn-primary.d-grid").toHaveCount(0, {
message: "'d-grid' class has been removed in that js_class",
});
expect(".o_hierarchy_node_button.btn-primary.rounded-0").toHaveCount(0, {
message: "'d-grid' class has been removed in that js_class",
});
expect(".o_hierarchy_node_button.btn-primary .o_hierarchy_icon").toHaveCount(0, {
message: "the icon has been replaced in that js_class",
});
expect(".o_hierarchy_node_button.btn-primary .fa-caret-right").toHaveCount(1, {
message: "the icon has been replaced in that js_class",
});
expect(".o_hierarchy_node_button.btn-primary").toHaveText("1 people");
// check nodes in each row
expect(".o_hierarchy_row:eq(0) .o_hierarchy_node").toHaveCount(1);
expect(".o_hierarchy_row:eq(0) .o_hierarchy_node_content").toHaveText("Albert");
expect(".o_hierarchy_node_button.btn-secondary").toHaveCount(1);
expect(".o_hierarchy_node_button.btn-secondary .fa-caret-down").toHaveCount(1);
expect(".o_hierarchy_node_button.btn-secondary").toHaveText("2 people");
});
test("display the avatar of the parent when there is more than one node in the same row of the parent", async () => {
await mountView({
type: "hierarchy",
resModel: "hr.employee",
});
expect(".o_hierarchy_row").toHaveCount(2);
expect(".o_hierarchy_node_button.btn-primary").toHaveCount(1);
await contains(".o_hierarchy_node_button.btn-primary").click();
expect(".o_hierarchy_row").toHaveCount(3);
expect(".o_hierarchy_node").toHaveCount(4);
expect(".o_hierarchy_separator").toHaveCount(2);
expect(".o_hierarchy_parent_node_container .o_avatar").toHaveCount(1);
expect(".o_avatar").toHaveText("Josephine");
});
test("hierarchy with a self manager employee", async () => {
Employee._records = [{ id: 1, name: "Albert", parent_id: 1, child_ids: [1] }]
await mountView({
context:{
hierarchy_res_id: 1,
},
type: "hierarchy",
resModel: "hr.employee",
});
expect(".o_hierarchy_row").toHaveCount(2);
expect(".o_hierarchy_node_button.btn-secondary").toHaveCount(1);
expect(".o_hierarchy_node").toHaveCount(2);
await contains(".o_hierarchy_node_button.btn-secondary").click();
expect(".o_hierarchy_row").toHaveCount(1);
expect(".o_hierarchy_node").toHaveCount(1);
});
test("hierarchy with a cycle", async () =>{
Employee._records = [
{ id: 1, name: "Albert", parent_id: 4, 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: [1] },
]
await mountView({
context:{
hierarchy_res_id: 1,
},
type: "hierarchy",
resModel: "hr.employee",
});
expect(".o_hierarchy_row").toHaveCount(4);
expect(".o_hierarchy_node").toHaveCount(5);
expect(".o_hierarchy_row:nth-of-type(8) .o_hierarchy_node").toHaveCount(1);
// check that the node in the cycle cannot be expanded
expect(".o_hierarchy_row:nth-of-type(8) .o_hierarchy_node_button").toHaveCount(0);
expect(".o_hierarchy_row:nth-of-type(1) .o_hierarchy_node_content").toHaveText("Albert\nLouis");
await contains(".o_hierarchy_row:nth-of-type(1) .btn-secondary").click();
await contains(".o_hierarchy_row:nth-of-type(1) .btn-primary").click();
await contains(".o_hierarchy_row:nth-of-type(3) .btn-primary").click();
await contains(".o_hierarchy_row:nth-of-type(6) .btn-primary").click();
// check the root of the tree is still Albert
expect(".o_hierarchy_row:nth-of-type(1) .o_hierarchy_node_content").toHaveText("Albert\nLouis");
expect(".o_hierarchy_row:nth-of-type(8) .o_hierarchy_node_content").toHaveText("Albert\nLouis");
});

View file

@ -0,0 +1,219 @@
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Employee extends models.Model {
_name = "hr.employee";
child_ids = fields.One2many({
string: "Subordinates",
relation: "hr.employee",
relation_field: "parent_id",
});
_records = [
{
id: 1,
child_ids: [],
},
];
}
defineModels([Employee]);
defineMailModels();
test("hr org chart: empty render", async () => {
expect.assertions(3);
onRpc("/hr/get_org_chart", async (request) => {
const { params: args } = await request.json();
expect(args).toInclude("employee_id", {
message: "it should have 'employee_id' as argument",
});
expect("new_parent_id" in args).toBe(true, {
message: "it should have 'new_parent_id' as argument",
});
return {
children: [],
managers: [],
managers_more: false,
};
});
onRpc("/hr/get_redirect_model", () => "hr.employee");
await mountView({
type: "form",
resModel: "hr.employee",
arch: `
<form>
<field name="child_ids" widget="hr_org_chart"/>
<field name="id" invisible="1"/>
</form>`,
resId: 1,
});
expect(queryOne('[name="child_ids"]').children).toHaveLength(3, {
message: "the chart should have 3 child",
});
});
test("hr org chart: render without data", async () => {
expect.assertions(3);
onRpc("/hr/get_org_chart", async (request) => {
const { params: args } = await request.json();
expect(args).toInclude("employee_id", {
message: "it should have 'employee_id' as argument",
});
expect("new_parent_id" in args).toBe(true, {
message: "it should have 'new_parent_id' as argument",
});
return {}; // return no data
});
await mountView({
type: "form",
resModel: "hr.employee",
arch: `
<form>
<field name="child_ids" widget="hr_org_chart"/>
<field name="id" invisible="1"/>
</form>`,
resId: 1,
});
expect(queryOne('[name="child_ids"]').children).toHaveLength(3, {
message: "the chart should have 3 child",
});
});
test("hr org chart: basic render", async () => {
expect.assertions(4);
onRpc("/hr/get_org_chart", async (request) => {
const { params: args } = await request.json();
expect(args).toInclude("employee_id", {
message: "it should have 'employee_id' as argument",
});
expect("new_parent_id" in args).toBe(true, {
message: "it should have 'new_parent_id' as argument",
});
return {
children: [
{
direct_sub_count: 0,
indirect_sub_count: 0,
job_id: 2,
job_name: "Sub-Gooroo",
link: "fake_link",
name: "Michael Hawkins",
id: 2,
},
],
managers: [],
managers_more: false,
self: {
direct_sub_count: 1,
id: 1,
indirect_sub_count: 1,
job_id: 1,
job_name: "Gooroo",
link: "fake_link",
name: "Antoine Langlais",
},
};
});
onRpc("/hr/get_redirect_model", () => "hr.employee");
await mountView({
type: "form",
resModel: "hr.employee",
arch: `<form>
<sheet>
<div id="o_employee_container">
<div id="o_employee_main">
<div id="o_employee_org_chart">
<field name="child_ids" widget="hr_org_chart"/>
<field name="id" invisible="1"/>
</div>
</div>
</div>
</sheet>
</form>`,
resId: 1,
});
expect(".o_org_chart_entry_sub").toHaveCount(1, {
message: "the chart should have 1 subordinate",
});
expect(".o_org_chart_entry_self").toHaveCount(1, {
message: "the current employee should only be displayed once in the chart",
});
});
test("hr org chart: basic manager render", async () => {
expect.assertions(5);
onRpc("/hr/get_org_chart", async (request) => {
const { params: args } = await request.json();
expect(args).toInclude("employee_id", {
message: "it should have 'employee_id' as argument",
});
expect("new_parent_id" in args).toBe(true, {
message: "it should have 'new_parent_id' as argument",
});
return {
children: [
{
direct_sub_count: 0,
indirect_sub_count: 0,
job_id: 2,
job_name: "Sub-Gooroo",
link: "fake_link",
name: "Michael Hawkins",
id: 2,
},
],
managers: [
{
direct_sub_count: 1,
id: 1,
indirect_sub_count: 2,
job_id: 1,
job_name: "Chief Gooroo",
link: "fake_link",
name: "Antoine Langlais",
},
],
managers_more: false,
self: {
direct_sub_count: 1,
id: 1,
indirect_sub_count: 1,
job_id: 3,
job_name: "Gooroo",
link: "fake_link",
name: "John Smith",
},
};
});
onRpc("/hr/get_redirect_model", () => "hr.employee");
await mountView({
type: "form",
resModel: "hr.employee",
arch: `<form>
<sheet>
<div id="o_employee_container">
<div id="o_employee_main">
<div id="o_employee_org_chart">
<field name="child_ids" widget="hr_org_chart"/>
<field name="id" invisible="1"/>
</div>
</div>
</div>
</sheet>
</form>`,
resId: 1,
});
expect(".o_org_chart_group_up .o_org_chart_entry_manager").toHaveCount(1, {
message: "the chart should have 1 manager",
});
expect(".o_org_chart_group_down .o_org_chart_entry_sub").toHaveCount(1, {
message: "the chart should have 1 subordinate",
});
expect(".o_org_chart_entry_self").toHaveCount(1, {
message: "the chart should have only once the current employee",
});
});

View file

@ -1,191 +0,0 @@
/** @odoo-module **/
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let target;
let serverData;
QUnit.module("hr_org_chart", {
async beforeEach() {
target = getFixture();
serverData = {
models: {
hr_employee: {
fields: {
child_ids: {string: "one2many Subordinates field", type: "one2many", relation: 'hr_employee'},
},
records: [{
id: 1,
child_ids: [],
}]
},
},
};
setupViewRegistries();
},
}, function () {
QUnit.test("hr org chart: empty render", async function (assert) {
assert.expect(2);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "it should have 'employee_id' as argument");
return Promise.resolve({
children: [],
managers: [],
managers_more: false,
});
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
}
});
assert.strictEqual($(target.querySelector('[name="child_ids"]')).children().length, 1,
"the chart should have 1 child");
});
QUnit.test("hr org chart: render without data", async function (assert) {
assert.expect(2);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "it should have 'employee_id' as argument");
return Promise.resolve({}); // return no data
}
}
});
assert.strictEqual($(target.querySelector('[name="child_ids"]')).children().length, 1,
"the chart should have 1 child");
});
QUnit.test("hr org chart: basic render", async function (assert) {
assert.expect(3);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<sheet>' +
'<div id="o_employee_container"><div id="o_employee_main">' +
'<div id="o_employee_right">' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</div>' +
'</div></div>' +
'</sheet>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "it should have 'employee_id' as argument");
return Promise.resolve({
children: [{
direct_sub_count: 0,
indirect_sub_count: 0,
job_id: 2,
job_name: 'Sub-Gooroo',
link: 'fake_link',
name: 'Michael Hawkins',
id: 2,
}],
managers: [],
managers_more: false,
self: {
direct_sub_count: 1,
id: 1,
indirect_sub_count: 1,
job_id: 1,
job_name: 'Gooroo',
link: 'fake_link',
name: 'Antoine Langlais',
}
});
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
}
});
assert.containsOnce(target, '.o_org_chart_entry_sub',
"the chart should have 1 subordinate");
assert.containsOnce(target, '.o_org_chart_entry_self',
"the current employee should only be displayed once in the chart");
});
QUnit.test("hr org chart: basic manager render", async function (assert) {
assert.expect(4);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<sheet>' +
'<div id="o_employee_container"><div id="o_employee_main">' +
'<div id="o_employee_right">' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</div>' +
'</div></div>' +
'</sheet>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "should have 'employee_id' as argument");
return Promise.resolve({
children: [{
direct_sub_count: 0,
indirect_sub_count: 0,
job_id: 2,
job_name: 'Sub-Gooroo',
link: 'fake_link',
name: 'Michael Hawkins',
id: 2,
}],
managers: [{
direct_sub_count: 1,
id: 1,
indirect_sub_count: 2,
job_id: 1,
job_name: 'Chief Gooroo',
link: 'fake_link',
name: 'Antoine Langlais',
}],
managers_more: false,
self: {
direct_sub_count: 1,
id: 1,
indirect_sub_count: 1,
job_id: 3,
job_name: 'Gooroo',
link: 'fake_link',
name: 'John Smith',
}
});
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
}
});
assert.containsOnce(target, '.o_org_chart_group_up .o_org_chart_entry_manager', "the chart should have 1 manager");
assert.containsOnce(target, '.o_org_chart_group_down .o_org_chart_entry_sub', "the chart should have 1 subordinate");
assert.containsOnce(target, '.o_org_chart_entry_self', "the chart should have only once the current employee");
});
});

View file

@ -0,0 +1,36 @@
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("indirect_subordinates_tour", {
steps: () => [
{
content: "Click the number next to employee georges",
trigger: "div[name='child_ids'] .o_org_chart_entry button > .badge:contains('2')",
run: "click",
},
{
content: "Click Indirect Subordinates",
trigger: ".o_org_chart_popup a.o_employee_sub_redirect[data-type='indirect']",
run: "click",
},
],
});
registry.category("web_tour.tours").add("employee_view_access_multicompany", {
steps: () => [
{
content: "Employee list view",
trigger: ".o_switch_view.o_list",
run: "click",
},
{
content: "Click on the employee C",
trigger: 'table.o_list_table tbody td:contains("Employee C")',
run: "click",
},
{
content: "Click the number next to employee C",
trigger: "div[name='child_ids'] .o_org_chart_entry button > .badge:contains('1')",
run: "click",
},
],
});