[MERGE] hr_*: migrate widgets/views to Owl

Migration of the different views/widgets for the HR scope.

Related: odoo/enterprise#30517

task-2928190

closes odoo/odoo#98301

Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-08-24 17:17:07 +02:00
committed by GitHub
37 changed files with 977 additions and 1316 deletions
+1 -4
View File
@@ -56,13 +56,10 @@
'hr/static/src/views/**/*.js',
'hr/static/src/components/**/*.js',
'hr/static/src/components/**/*.scss',
'hr/static/src/user_menu/*.js',
'hr/static/src/scss/hr.scss',
'hr/static/src/js/chat_mixin.js',
'hr/static/src/js/hr_employee.js',
'hr/static/src/js/language.js',
'hr/static/src/js/m2x_avatar_employee.js',
'hr/static/src/js/standalone_m2o_avatar_employee.js',
'hr/static/src/js/user_menu.js',
'hr/static/src/js/work_permit_upload.js',
],
'web.assets_qweb': [
-36
View File
@@ -1,36 +0,0 @@
odoo.define('hr.chat_mixin', function (require) {
"use strict";
const { Component } = owl;
// CHAT MIXIN
const ChatMixin = {
/**
* @override
*/
_render: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
var $chat_button = self.$el.find('.o_employee_chat_btn');
$chat_button.off('click').on('click', self._onOpenChat.bind(self));
});
},
destroy: function () {
if (this.$el) {
this.$el.find('.o_employee_chat_btn').off('click');
}
return this._super();
},
async _onOpenChat(ev) {
ev.preventDefault();
ev.stopImmediatePropagation();
const messaging = await Component.env.services.messaging.get();
messaging.openChat({ employeeId: this.state.data.id });
return true;
},
};
return ChatMixin;
});
-136
View File
@@ -1,136 +0,0 @@
odoo.define('hr.employee_chat', function (require) {
'use strict';
var viewRegistry = require('web.view_registry');
var FormController = require('web.FormController');
var FormView = require('web.FormView');
var FormRenderer = require('web.FormRenderer');
const ListController = require('web.ListController');
const ListView = require('web.ListView');
const ChatMixin = require('hr.chat_mixin');
const core = require('web.core');
const _t = core._t;
// USAGE OF CHAT MIXIN IN FORM VIEWS
var EmployeeFormRenderer = FormRenderer.extend(ChatMixin);
const EmployeeArchiveMixin = {
_getArchiveAction: function (id) {
return {
type: 'ir.actions.act_window',
name: _t('Employee Termination'),
res_model: 'hr.departure.wizard',
views: [[false, 'form']],
view_mode: 'form',
target: 'new',
context: {
'active_id': id,
'toggle_active': true,
}
}
}
};
const EmployeeFormController = FormController.extend(EmployeeArchiveMixin, {
/**
* Override the archive action to directly open the departure wizard
* @override
* @private
*/
_getActionMenuItems: function (state) {
let self = this;
let actionMenuItems = this._super(...arguments);
const activeField = this.model.getActiveField(state);
if (actionMenuItems != null && this.archiveEnabled && activeField in state.data) {
//This might break in future version, don't see a better way however
let archiveString = _t("Archive");
let archiveMenuItem = actionMenuItems.items.other.find(item => {return (item.description === archiveString)});
if (archiveMenuItem) {
archiveMenuItem.callback = () => {self.do_action(
self._getArchiveAction(self.model.localIdsToResIds([this.handle])[0]), {
on_close: function () {
self.update({}, {reload: true});
}
})}
}
}
return actionMenuItems;
}
})
var EmployeeFormView = FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller: EmployeeFormController,
Renderer: EmployeeFormRenderer
}),
});
viewRegistry.add('hr_employee_form', EmployeeFormView);
const EmployeeListController = ListController.extend(EmployeeArchiveMixin, {
/**
* Override the archive action to directly open the departure wizard
* @override
* @private
*/
_getActionMenuItems: function (state) {
let self = this;
let actionMenuItems = this._super(...arguments);
if (actionMenuItems != null && this.archiveEnabled) {
//This might break in future version, don't see a better way however
let archiveString = _t("Archive");
let archiveMenuItem = actionMenuItems.items.other.find(item => {return (item.description === archiveString)});
if (archiveMenuItem) {
//On this one we want the default action when multiple are selected
let originalCallback = archiveMenuItem.callback;
archiveMenuItem.callback = () => {
let records = self.getSelectedRecords()
if (records.length == 1 && records[0].data.active === true) {
self.do_action(
self._getArchiveAction(records[0].res_id), {
on_close: function () {
self.update({}, {reload: true});
}
})
} else {
originalCallback();
}
};
}
}
return actionMenuItems;
},
/**
* When we run Launch Plan list action, in case if we close the
* wizzard, the originally selected employees stay selected.
*
* @override
*/
async _executeButtonAction(actionData, recordData) {
const prom = new Promise((resolve, reject) => {
this.trigger_up('execute_action', {
action_data: actionData,
env: recordData,
on_closed: () => this.isDestroyed() || actionData.context['action_plan'] ? Promise.resolve() : this.reload(),
on_success: resolve,
on_fail: () => this.update({}, { keepSelection: true, reload: false }).then(reject).guardedCatch(reject)
});
});
return this.alive(prom);
},
});
const EmployeeListView = ListView.extend({
config: _.extend({}, ListView.prototype.config, {
Controller: EmployeeListController,
})
})
viewRegistry.add('hr_employee_list', EmployeeListView);
});
-25
View File
@@ -1,25 +0,0 @@
/** @odoo-module **/
import FormController from 'web.FormController';
import FormView from 'web.FormView';
import viewRegistry from 'web.view_registry';
var EmployeeFormController = FormController.extend({
saveRecord: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
if (arguments[0].indexOf('lang') >= 0) {
self.do_action('reload_context');
}
});
},
});
var EmployeeProfileFormView = FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller: EmployeeFormController,
}),
});
viewRegistry.add('hr_employee_profile_form', EmployeeProfileFormView);
export default EmployeeProfileFormView;
@@ -0,0 +1,18 @@
/** @odoo-module */
export const ArchiveEmployeeMixin = {
_openArchiveEmployee(id) {
return {
type: 'ir.actions.act_window',
name: this.env._t('Employee Termination'),
res_model: 'hr.departure.wizard',
views: [[false, 'form']],
view_mode: 'form',
target: 'new',
context: {
'active_id': id,
'toggle_active': true,
}
}
}
};
+50
View File
@@ -0,0 +1,50 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { useService } from "@web/core/utils/hooks";
import { patch } from '@web/core/utils/patch';
import { formView } from '@web/views/form/form_view';
import { FormController } from '@web/views/form/form_controller';
import { FormRenderer } from '@web/views/form/form_renderer';
import { ArchiveEmployeeMixin } from '../mixins/archive_employee_mixin';
import { EmployeeChatMixin } from '../mixins/chat_mixin';
export class EmployeeFormController extends FormController {
setup() {
super.setup();
this.actionService = useService('action');
}
getActionMenuItems() {
const menuItems = super.getActionMenuItems();
if (!this.archiveEnabled || !this.model.root.isActive) {
return menuItems;
}
const archiveAction = menuItems.other.find((item) => item.key === "archive");
if (archiveAction) {
archiveAction.callback = () => {
const archiveAction = this._openArchiveEmployee(this.model.root.resId);
this.actionService.doAction(archiveAction, {
onClose: async () => {
await this.model.load();
this.model.notify();
}
});
};
}
return menuItems;
}
}
patch(EmployeeFormController.prototype, 'employee_form_controller_archive_mixin', ArchiveEmployeeMixin);
export class EmployeeFormRenderer extends FormRenderer {}
patch(EmployeeFormRenderer.prototype, 'employee_form_renderer_mixin', EmployeeChatMixin);
registry.category('views').add('hr_employee_form', {
...formView,
Controller: EmployeeFormController,
Renderer: EmployeeFormRenderer,
});
+47
View File
@@ -0,0 +1,47 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { useService } from "@web/core/utils/hooks";
import { patch } from '@web/core/utils/patch';
import { listView } from '@web/views/list/list_view';
import { ListController } from '@web/views/list/list_controller';
import { ArchiveEmployeeMixin } from '../mixins/archive_employee_mixin';
export class EmployeeListController extends ListController {
setup() {
super.setup();
this.actionService = useService('action');
}
getActionMenuItems() {
const menuItems = super.getActionMenuItems();
const selectedRecords = this.model.root.selection;
// Only override the Archive action when only 1 record is selected.
if (!this.archiveEnabled || selectedRecords.length > 1 || !selectedRecords[0].data.active) {
return menuItems;
}
const archiveAction = menuItems.other.find((item) => item.key === "archive");
if (archiveAction) {
archiveAction.callback = () => {
const archiveAction = this._openArchiveEmployee(this.model.root.resId);
this.actionService.doAction(archiveAction, {
onClose: async () => {
await this.model.load();
this.model.notify();
}
});
};
}
return menuItems;
}
}
patch(EmployeeListController.prototype, 'employee_list_controller_archive_mixin', ArchiveEmployeeMixin);
registry.category('views').add('hr_employee_list', {
...listView,
Controller: EmployeeListController,
});
@@ -0,0 +1,29 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { useService } from "@web/core/utils/hooks";
import { formView } from '@web/views/form/form_view';
import { FormController } from '@web/views/form/form_controller';
export class EmployeeProfileFormController extends FormController {
setup() {
super.setup();
this.action = useService('action');
}
async save(params = {}) {
const dirtyFields = this.model.root.dirtyFields.map((f) => f.name);
super.save(params);
if (dirtyFields.includes('lang')) {
this.action.doAction("reload_context");
}
}
}
registry.category('views').add('hr_employee_profile_form', {
...formView,
Controller: EmployeeProfileFormController,
});
+3 -3
View File
@@ -34,7 +34,7 @@
<field name="name">hr.employee.public.form</field>
<field name="model">hr.employee.public</field>
<field name="arch" type="xml">
<form string="Employee" create="0" write="0" js_class="hr_employee_form">
<form string="Employee" create="0" write="0" js_class="hr_employee_form" class="o_employee_form">
<field name="image_128" invisible="1" />
<header/>
<sheet>
@@ -50,7 +50,7 @@
<label for="name" string="Employee Name"/>
<h1>
<field name="name" placeholder="e.g. John Doe" required="True"/>
<a title="Chat" icon="fa-comments" href="#" class="ml8 o_employee_chat_btn" invisible="not context.get('chat_icon')" attrs="{'invisible': [('user_id','=', False)]}" role="button"><i class="fa fa-comments"/></a>
<a title="Chat" icon="fa-comments" t-on-click.prevent="() => openChat(props.record.resId)" href="#" class="ml8" invisible="not context.get('chat_icon')" attrs="{'invisible': [('user_id','=', False)]}" role="button"><i class="fa fa-comments"/></a>
</h1>
<h2>
<field name="job_title" placeholder="Job Title" />
@@ -196,7 +196,7 @@
<field name="res_model">hr.employee.public</field>
<field name="view_mode">kanban,tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
<field name="context">{'chat_icon': True}</field>
<field name="view_id" eval="False"/>
<field name="search_view_id" ref="hr_employee_public_view_search"/>
<field name="help" type="html">
+1 -1
View File
@@ -70,7 +70,7 @@
<div role="img" class="fa fa-2xs fa-fw fa-circle text-warning o_button_icon hr_presence" attrs="{'invisible': [('hr_presence_state', '!=', 'to_define')]}" aria-label="Away" title="Away"/>
<div role="img" class="fa fa-2xs fa-fw fa-circle text-danger o_button_icon hr_presence" attrs="{'invisible': [('hr_presence_state', '!=', 'absent')]}" aria-label="Not available" title="Not available"/>
</div>
<a title="Chat" icon="fa-comments" href="#" class="ml8 o_employee_chat_btn" invisible="not context.get('chat_icon')" attrs="{'invisible': [('user_id','=', False)]}" role="button"><i class="fa fa-comments"/></a>
<a title="Chat" icon="fa-comments" t-on-click.prevent="() => openChat(props.record.resId)" href="#" class="ml8" invisible="not context.get('chat_icon')" attrs="{'invisible': [('user_id','=', False)]}" role="button"><i class="fa fa-comments"/></a>
</h1>
<h2>
<field name="job_title" placeholder="Job Position" />
+3 -3
View File
@@ -23,14 +23,14 @@ This module extend the employee form with a organizational chart.
'hr_org_chart/static/src/scss/variables.scss',
],
'web.assets_backend': [
'hr_org_chart/static/src/scss/hr_org_chart.scss',
'hr_org_chart/static/src/js/hr_org_chart.js',
'hr_org_chart/static/src/fields/*.scss',
'hr_org_chart/static/src/fields/*.js',
],
'web.qunit_suite_tests': [
'hr_org_chart/static/tests/**/*',
],
'web.assets_qweb': [
'hr_org_chart/static/src/xml/**/*',
'hr_org_chart/static/src/fields/*.xml',
],
},
'license': 'LGPL-3',
@@ -0,0 +1,181 @@
/** @odoo-module */
import {Field} from '@web/views/fields/field';
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillStart, useState } = owl;
import { usePopover } from "@web/core/popover/popover_hook";
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;
};
},
});
}
class HrOrgChartPopover extends Component {
async setup() {
super.setup();
this.rpc = useService('rpc');
this.orm = useService('orm');
this.actionService = useService("action");
}
/**
* Get subordonates of an employee through a rpc call.
*
* @private
* @param {integer} employee_id
* @returns {Promise}
*/
async _getSubordinatesData(employee_id, type) {
const subData = await this.rpc(
'/hr/get_subordinates',
{
employee_id: employee_id,
subordinates_type: type,
context: Component.env.session.user_context,
}
);
return subData;
}
/**
* Redirect to the employee form view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
async _onEmployeeRedirect(employeeId) {
const action = await this.orm.call('hr.employee', 'get_formview_action', [employeeId]);
this.actionService.doAction(action);
}
/**
* Redirect to the sub employee form view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
async _onEmployeeSubRedirect(event) {
const employee_id = parseInt(event.currentTarget.dataset.employeeId);
const type = event.currentTarget.dataset.type || 'direct';
if (!employee_id) {
return {};
}
const subData = await this._getSubordinatesData(employee_id, type);
const domain = [['id', 'in', subData]];
var action = await this.orm.call('hr.employee', 'get_formview_action', [employee_id]);
action = Object.assign(action, {
'name': this.env._t('Team'),
'view_mode': 'kanban,list,form',
'views': [[false, 'kanban'], [false, 'list'], [false, 'form']],
'domain': domain,
'context': {
'default_parent_id': employee_id,
}
});
delete action['res_id'];
this.actionService.doAction(action);
}
}
HrOrgChartPopover.template = 'hr_org_chart.hr_orgchart_emp_popover'
export class HrOrgChart extends Field {
async setup() {
super.setup();
this.rpc = useService('rpc');
this.orm = useService('orm');
this.actionService = useService("action");
this.popover = useUniquePopover();
this.jsonStringify = JSON.stringify;
const recordData = this.props.record.data;
this.state = useState({'employee_id': null})
// the widget is either dispayed in the context of a hr.employee form or a res.users form
if (!this.state.employee_id) {
this.state.employee_id = recordData.employee_ids !== undefined ? recordData.employee_ids.resIds[0] : recordData.id;
}
this.employee = recordData;
onWillStart(async () => await this.fetchEmployeeData(this.state.employee_id));
}
async fetchEmployeeData(employeeId) {
if (!employeeId) {
this.managers = [];
this.children = [];
this.view_employee_id = null;
} else {
var orgData = await this.rpc(
'/hr/get_org_chart',
{
employee_id: employeeId,
context: Component.env.session.user_context,
}
);
if (Object.keys(orgData).length === 0) {
orgData = {
managers: [],
children: [],
}
}
this.managers = orgData.managers;
this.children = orgData.children;
this.managers_more = orgData.managers_more;
this.self = orgData.self;
this.view_employee_id = employeeId;
}
}
_onOpenPopover(event, employee) {
this.popover.add(
event.currentTarget,
this.constructor.components.Popover,
{employee},
{closeOnClickAway: true}
);
}
/**
* Redirect to the employee form view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
async _onEmployeeRedirect(employeeId) {
const action = await this.orm.call('hr.employee', 'get_formview_action', [employeeId]);
this.actionService.doAction(action);
}
async _onEmployeeMoreManager(managerId) {
await this.fetchEmployeeData(managerId);
this.state.employee_id = managerId;
}
}
HrOrgChart.components = {
Popover: HrOrgChartPopover,
};
HrOrgChart.template = 'hr_org_chart.hr_org_chart';
registry.category("fields").add("hr_org_chart", HrOrgChart);
@@ -1,21 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr_org_chart_employee">
<t t-name="hr_org_chart.hr_org_chart_employee" owl="1">
<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' : ''}">
<t t-call="hr_org_chart_employee_content"/>
<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">
<t t-call="hr_org_chart_employee_content"/>
<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_employee_content">
<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 -->
@@ -24,14 +28,15 @@
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-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-start 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">
<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>
@@ -47,7 +52,8 @@
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">
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"
@@ -57,7 +63,7 @@
</div>
</t>
<t t-name="hr_org_chart">
<t t-name="hr_org_chart.hr_org_chart" owl="1">
<!-- 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)
@@ -65,23 +71,22 @@
-->
<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">
<a href="#" t-att-data-employee-id="managers[0].id" class="o_employee_more_managers d-block bg-100 px-3">
<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)">
<i class="fa fa-angle-double-up" role="img" aria-label="More managers" title="More managers"/>
</a>
</div>
<t t-foreach="managers" t-as="employee">
<t t-foreach="managers" t-as="employee" t-key="employee_index">
<t t-set="emp_count" t-value="emp_count + 1"/>
<t t-call="hr_org_chart_employee">
<t t-call="hr_org_chart.hr_org_chart_employee">
<t t-set="employee_type" t-value="'manager'"/>
</t>
</t>
</div>
<t t-if="children.length || managers.length" t-call="hr_org_chart_employee">
<t t-if="children.length || managers.length" t-call="hr_org_chart.hr_org_chart_employee">
<t t-set="employee_type" t-value="'self'"/>
<t t-set="employee" t-value="self"/>
</t>
@@ -95,10 +100,10 @@
</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' : ''}">
<t t-foreach="children" t-as="employee">
<t t-foreach="children" t-as="employee" t-key="employee_index">
<t t-set="emp_count" t-value="emp_count + 1"/>
<t t-if="emp_count &lt; 20">
<t t-call="hr_org_chart_employee">
<t t-call="hr_org_chart.hr_org_chart_employee">
<t t-set="employee_type" t-value="'sub'"/>
</t>
</t>
@@ -110,55 +115,61 @@
<a href="#"
t-att-data-employee-id="self.id"
t-att-data-employee-name="self.name"
class="o_org_chart_show_more o_employee_sub_redirect btn btn-link ps-2">See All</a>
class="o_org_chart_show_more o_employee_sub_redirect btn btn-link ps-2"
t-on-click.prevent="_onEmployeeSubRedirect">See All</a>
</div>
</div>
</t>
</div>
</t>
<t t-name="hr_orgchart_emp_popover">
<div class="popover o_org_chart_popup" role="tooltip"><div class="tooltip-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>
</t>
<t t-name="hr_orgchart_emp_popover_content">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-end"><b t-esc="employee.direct_sub_count"/></td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='direct'
t-att-data-employee-name="employee.name" t-att-data-employee-id="employee.id">
<b>Direct subordinates</b></a>
</td>
</tr>
<tr>
<td class="text-end">
<b t-esc="employee.indirect_sub_count - employee.direct_sub_count"/>
</td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='indirect'
t-att-data-employee-name="employee.name" t-att-data-employee-id="employee.id">
Indirect subordinates</a>
</td>
</tr>
<tr>
<td class="text-end"><b t-esc="employee.indirect_sub_count"/></td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='total'
t-att-data-employee-name="employee.name" t-att-data-employee-id="employee.id">
Total</a>
</td>
</tr>
</tbody>
</table>
</t>
<t t-name="hr_orgchart_emp_popover_title">
<div class="d-flex align-items-center">
<span class="flex-shrink-0" t-att-style='"background-image:url(\"/web/image/hr.employee.public/" + employee.id + "/avatar_1024/\")"'/>
<b class="flew-grow-1"><t t-esc="employee.name"/></b>
<a href="#" class="ms-auto o_employee_redirect" t-att-data-employee-id="employee.id"><i class="fa fa-external-link" role="img" aria-label='Redirect' title="Redirect"></i></a>
<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>
<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(employee.id)"><i class="fa fa-external-link" role="img" aria-label='Redirect' title="Redirect"></i></a>
</div>
</h3>
<div class="popover-body">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-end"><b t-esc="props.employee.direct_sub_count"/></td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='direct'
t-att-data-employee-name="props.employee.name" t-att-data-employee-id="props.employee.id"
t-on-click.prevent="_onEmployeeSubRedirect">
<b>Direct subordinates</b></a>
</td>
</tr>
<tr>
<td class="text-end">
<b t-esc="props.employee.indirect_sub_count - props.employee.direct_sub_count"/>
</td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='indirect'
t-att-data-employee-name="props.employee.name" t-att-data-employee-id="props.employee.id"
t-on-click.prevent="_onEmployeeSubRedirect">
Indirect subordinates</a>
</td>
</tr>
<tr>
<td class="text-end"><b t-esc="props.employee.indirect_sub_count"/></td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='total'
t-att-data-employee-name="props.employee.name" t-att-data-employee-id="props.employee.id"
t-on-click.prevent="_onEmployeeSubRedirect">
Total</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</t>
@@ -1,199 +0,0 @@
odoo.define('web.OrgChart', function (require) {
"use strict";
var AbstractField = require('web.AbstractField');
var concurrency = require('web.concurrency');
var core = require('web.core');
var field_registry = require('web.field_registry');
var session = require('web.session');
var QWeb = core.qweb;
var _t = core._t;
var FieldOrgChart = AbstractField.extend({
events: {
"click .o_employee_redirect": "_onEmployeeRedirect",
"click .o_employee_sub_redirect": "_onEmployeeSubRedirect",
"click .o_employee_more_managers": "_onEmployeeMoreManager"
},
/**
* @constructor
* @override
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.dm = new concurrency.DropMisordered();
this.employee = null;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Get the chart data through a rpc call.
*
* @private
* @param {integer} employee_id
* @returns {Promise}
*/
_getOrgData: function () {
return this.dm.add(this._rpc({
route: '/hr/get_org_chart',
params: {
employee_id: this.employee,
context: session.user_context,
},
})).then(function (data) {
return data;
});
},
/**
* Get subordonates of an employee through a rpc call.
*
* @private
* @param {integer} employee_id
* @returns {Promise}
*/
_getSubordinatesData: function (employee_id, type) {
return this.dm.add(this._rpc({
route: '/hr/get_subordinates',
params: {
employee_id: employee_id,
subordinates_type: type,
context: session.user_context,
},
}));
},
/**
* @override
* @private
*/
_render: function () {
if (!this.recordData.id) {
return this.$el.html(QWeb.render("hr_org_chart", {
managers: [],
children: [],
}));
}
else if (!this.employee) {
// the widget is either dispayed in the context of a hr.employee form or a res.users form
this.employee = this.recordData.employee_ids !== undefined ? this.recordData.employee_ids.res_ids[0] : this.recordData.id;
}
var self = this;
return this._getOrgData().then(function (orgData) {
if (_.isEmpty(orgData)) {
orgData = {
managers: [],
children: [],
}
}
orgData.view_employee_id = self.recordData.id;
self.$el.html(QWeb.render("hr_org_chart", orgData));
self.$('[data-bs-toggle="popover"]').each(function () {
$(this).popover({
html: true,
title: function () {
var $title = $(QWeb.render('hr_orgchart_emp_popover_title', {
employee: {
name: $(this).data('emp-name'),
id: $(this).data('emp-id'),
},
}));
$title.on('click',
'.o_employee_redirect', _.bind(self._onEmployeeRedirect, self));
return $title;
},
container: this,
placement: 'left',
trigger: 'focus',
content: function () {
var $content = $(QWeb.render('hr_orgchart_emp_popover_content', {
employee: {
id: $(this).data('emp-id'),
name: $(this).data('emp-name'),
direct_sub_count: parseInt($(this).data('emp-dir-subs')),
indirect_sub_count: parseInt($(this).data('emp-ind-subs')),
},
}));
$content.on('click',
'.o_employee_sub_redirect', _.bind(self._onEmployeeSubRedirect, self));
return $content;
},
template: QWeb.render('hr_orgchart_emp_popover', {}),
});
});
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
_onEmployeeMoreManager: function(event) {
event.preventDefault();
this.employee = parseInt($(event.currentTarget).data('employee-id'));
this._render();
},
/**
* Redirect to the employee form view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
_onEmployeeRedirect: function (event) {
var self = this;
event.preventDefault();
var employee_id = parseInt($(event.currentTarget).data('employee-id'));
return this._rpc({
model: 'hr.employee',
method: 'get_formview_action',
args: [employee_id],
}).then(function(action) {
return self.do_action(action);
});
},
/**
* Redirect to the sub employee form view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
_onEmployeeSubRedirect: function (event) {
event.preventDefault();
var employee_id = parseInt($(event.currentTarget).data('employee-id'));
var type = $(event.currentTarget).data('type') || 'direct';
var self = this;
if (employee_id) {
this._getSubordinatesData(employee_id, type).then(function(data) {
var domain = [['id', 'in', data]];
return self._rpc({
model: 'hr.employee',
method: 'get_formview_action',
args: [employee_id],
}).then(function(action) {
action = _.extend(action, {
'name': _t('Team'),
'view_mode': 'kanban,list,form',
'views': [[false, 'kanban'], [false, 'list'], [false, 'form']],
'domain': domain,
'context': {
'default_parent_id': employee_id,
}
});
delete action['res_id'];
return self.do_action(action);
});
});
}
},
});
field_registry.add('hr_org_chart', FieldOrgChart);
return FieldOrgChart;
});
@@ -1,38 +1,42 @@
odoo.define('hr_org_chart.tests', function (require) {
"use strict";
/** @odoo-module **/
var FormView = require('web.FormView');
var testUtils = require("web.test_utils");
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
var createView = testUtils.createView;
let target;
let serverData;
QUnit.module('hr_org_chart', {
before: function () {
this.data = {
hr_employee: {
fields: {
child_ids: {string: "one2many Subordinates field", type: "one2many", relation: 'hr_employee'},
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: [],
}]
},
records: [{
id: 1,
child_ids: [],
}]
}
},
};
setupViewRegistries();
},
}, function () {
QUnit.test("hr org chart: empty render", async function (assert) {
assert.expect(2);
var form = await createView({
View: FormView,
model: 'hr_employee',
data: this.data,
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</form>',
res_id: 1,
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");
@@ -44,44 +48,40 @@ QUnit.module('hr_org_chart', {
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
return this._super(route, args);
}
});
assert.strictEqual(form.$('[name="child_ids"]').children().length, 1,
assert.strictEqual($(target.querySelector('[name="child_ids"]')).children().length, 1,
"the chart should have 1 child");
form.destroy();
});
QUnit.test("hr org chart: render without data", async function (assert) {
assert.expect(2);
var form = await createView({
View: FormView,
model: 'hr_employee',
data: this.data,
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</form>',
res_id: 1,
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
}
return this._super(route, args);
}
});
assert.strictEqual(form.$('[name="child_ids"]').children().length, 1,
assert.strictEqual($(target.querySelector('[name="child_ids"]')).children().length, 1,
"the chart should have 1 child");
form.destroy();
});
QUnit.test("hr org chart: basic render", async function (assert) {
assert.expect(3);
var form = await createView({
View: FormView,
model: 'hr_employee',
data: this.data,
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<sheet>' +
@@ -92,7 +92,7 @@ QUnit.module('hr_org_chart', {
'</div></div>' +
'</sheet>' +
'</form>',
res_id: 1,
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");
@@ -121,22 +121,20 @@ QUnit.module('hr_org_chart', {
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
return this._super(route, args);
}
});
assert.containsOnce(form, '.o_org_chart_entry_sub',
assert.containsOnce(target, '.o_org_chart_entry_sub',
"the chart should have 1 subordinate");
assert.containsOnce(form, '.o_org_chart_entry_self',
assert.containsOnce(target, '.o_org_chart_entry_self',
"the current employee should only be displayed once in the chart");
form.destroy();
});
QUnit.test("hr org chart: basic manager render", async function (assert) {
assert.expect(4);
var form = await createView({
View: FormView,
model: 'hr_employee',
data: this.data,
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<sheet>' +
@@ -147,7 +145,7 @@ QUnit.module('hr_org_chart', {
'</div></div>' +
'</sheet>' +
'</form>',
res_id: 1,
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "should have 'employee_id' as argument");
@@ -184,14 +182,10 @@ QUnit.module('hr_org_chart', {
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
return this._super(route, args);
}
});
assert.containsOnce(form, '.o_org_chart_group_up .o_org_chart_entry_manager', "the chart should have 1 manager");
assert.containsOnce(form, '.o_org_chart_group_down .o_org_chart_entry_sub', "the chart should have 1 subordinate");
assert.containsOnce(form, '.o_org_chart_entry_self', "the chart should have only once the current employee");
form.destroy();
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");
});
});
});
@@ -4,7 +4,7 @@
<field name="name">hr.applicant.skill.view.form</field>
<field name="model">hr.applicant.skill</field>
<field name="arch" type="xml">
<form string="Skills">
<form string="Skills" class="o_hr_skills_dialog_form">
<sheet>
<group>
<group>
@@ -20,12 +20,12 @@
attrs="{'invisible': ['|', ('skill_id', '=', False), ('skill_type_id', '=', False)]}"/>
<div class="o_row"
attrs="{'invisible': ['|', ('skill_id', '=', False), ('skill_type_id', '=', False)]}">
<span class="col-4 ps-0">
<span clas="ps-0" style="flex:1">
<field name="skill_level_id"
attrs="{'readonly': [('skill_id', '=', False)]}"
context="{'from_skill_level_dropdown': True}" />
</span>
<span class="col-8">
<span style="flex:1">
<field name="level_progress" widget="progressbar" class="o_hr_skills_progress" attrs="{'invisible': [('skill_level_id', '=', False)]}" />
</span>
</div>
@@ -10,7 +10,7 @@
<div class="row">
<div class="o_hr_skills_editable o_hr_skills_group o_group_skills col-lg-5 d-flex flex-column">
<field name="id" invisible="1"/>
<field mode="tree" nolabel="1" name="applicant_skill_ids" widget="hr_skills" context="{'default_applicant_id': id}">
<field mode="tree" nolabel="1" name="applicant_skill_ids" widget="skills_one2many" context="{'default_applicant_id': id}">
<tree>
<field name="skill_type_id" invisible="1"/>
<field name="skill_id"/>
+1
View File
@@ -3,3 +3,4 @@
from . import models
from . import report
from . import tests
+8 -5
View File
@@ -34,15 +34,18 @@ This module introduces skills and resumé management for employees.
'application': True,
'assets': {
'web.assets_backend': [
'hr_skills/static/src/css/hr_skills.scss',
'hr_skills/static/src/js/*.js',
],
'web.qunit_suite_tests': [
'hr_skills/static/tests/**/*',
'hr_skills/static/src/fields/*.js',
'hr_skills/static/src/fields/*.scss',
'hr_skills/static/src/views/*.js',
],
'web.assets_qweb': [
'hr_skills/static/src/fields/skills_one2many.xml',
'hr_skills/static/src/fields/*.xml',
'hr_skills/static/src/xml/**/*',
],
'web.assets_tests': [
'hr_skills/static/tests/tours/*',
],
},
'license': 'LGPL-3',
}
+1 -1
View File
@@ -9,7 +9,7 @@ class EmployeeSkill(models.Model):
_name = 'hr.employee.skill'
_description = "Skill level for an employee"
_rec_name = 'skill_id'
_order = "skill_level_id"
_order = "skill_type_id, skill_level_id"
employee_id = fields.Many2one('hr.employee', required=True, ondelete='cascade')
skill_id = fields.Many2one('hr.skill', compute='_compute_skill_id', store=True, domain="[('skill_type_id', '=', skill_type_id)]", readonly=False, required=True)
@@ -1,159 +0,0 @@
.o_form_sheet {
$o-hrs-timeline-entry-padding: .5rem;
$o-hrs-timeline-dot-size: .6rem;
// Overall design
// =========================================
.o_hr_skills_group {
// Overwrite '.o_list_table' default design
.o_list_view {
.o_list_table {
cursor: auto;
}
tbody:first-of-type > .o_resume_group_header:first-child {
box-shadow: none;
}
}
&.o_hr_skills_editable {
.o_data_row {
cursor: pointer;
}
}
// Deny user interaction to headers but keep access to buttons
.o_group_header, .o_resume_group_header {
&, &:hover {
background: none !important;
cursor: initial;
pointer-events: none;
}
.o_field_x2many_list_row_add, .o_field_x2many_list_row_add:hover {
cursor: pointer;
pointer-events: initial;
}
}
// Resumé design
// =========================================
&.o_group_resume {
.o_data_row td {
padding: $o-hrs-timeline-entry-padding;
&.o_resume_timeline_cell {
div {
width: $o-hrs-timeline-dot-size;
height: $o-hrs-timeline-dot-size;
}
&:before {
@include o-position-absolute(0, $left: ($o-hrs-timeline-dot-size * .5 + $o-hrs-timeline-entry-padding));
width: 1px;
height: 100%;
margin-left: -.01rem;
background-color: $border-color;
content: "";
}
}
}
.o_resume_line_title, .o_resume_line_desc {
white-space: normal;
}
.o_resume_line_title, .o_resume_line_dates {
line-height: 1;
}
.o_resume_group_header + .o_data_row .o_resume_timeline_cell:before {
top: $o-hrs-timeline-entry-padding;
}
.o_data_row.o_data_row_last {
.o_resume_line_desc {
margin-bottom: $headings-margin-bottom;
}
.o_resume_timeline_cell:before {
height: $o-hrs-timeline-entry-padding;
}
}
}
// Skills design
// =========================================
&.o_group_skills {
.o_resume_empty_helper {
display: none;
}
.o_group_header {
> .o_group_name {
padding: .8rem .5rem 0 0;
}
&:first-child > .o_group_name {
padding-top: 0;
}
}
.o_skill_cell {
padding-left: 0;
white-space: normal !important;
.o_progressbar {
display: inline-flex;
align-items: center;
}
.o_progress {
border: 0;
background: $gray-300;
height: 5px;
}
.o_progressbar_value {
width: auto;
font-size: $font-size-sm;
font-weight: bold;
}
}
}
}
}
// Editing mode
// =========================================
.o_form_view.o_form_editable {
.o_form_sheet .o_hr_skills_group {
.o_group_name {
background-color: map-get($grays, '200');
}
.o_resume_group_header .btn {
margin-top: .25rem;
margin-right: .4rem;
}
&.o_group_skills .o_group_name {
padding-top: .4em;
padding-bottom: .4rem;
}
.o_group_name, .o_skill_cell {
padding-left: .5rem;
}
&.o_group_skills .o_group_name > b, .o_horizontal_separator {
color: color-contrast(map-get($grays, '200'));
font-style: italic;
}
}
.o_hr_skills_progress {
.o_progressbar_value {
width: auto;
}
}
}
@@ -0,0 +1,46 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { formatDate } from "@web/core/l10n/dates";
import { SkillsX2ManyField } from "./skills_one2many";
import { CommonSkillsListRenderer } from "../views/skills_list_renderer";
export class ResumeListRenderer extends CommonSkillsListRenderer {
get groupBy() {
return 'line_type_id';
}
get colspan() {
if (this.props.activeActions) {
return 3;
}
return 2;
}
formatDate(date) {
return formatDate(date);
}
setDefaultColumnWidths() {}
get RowsTemplate() {
return 'hr_skills.ResumeListRenderer.Rows';
}
get RecordRowTemplate() {
return 'hr_skills.ResumeListRenderer.RecordRow';
}
}
ResumeListRenderer.template = 'hr_skills.ResumeListRenderer';
export class ResumeX2ManyField extends SkillsX2ManyField {
setup() {
super.setup();
this.Renderer = ResumeListRenderer;
}
}
registry.category("fields")
.add("resume_one2many", ResumeX2ManyField);
@@ -0,0 +1,46 @@
.o_field_resume_one2many {
$o-hrs-timeline-entry-padding: .5rem;
$o-hrs-timeline-dot-size: .6rem;
.o_data_row td {
padding: $o-hrs-timeline-entry-padding;
&.o_resume_timeline_cell {
div {
width: $o-hrs-timeline-dot-size;
height: $o-hrs-timeline-dot-size;
}
&:before {
@include o-position-absolute(0, $left: ($o-hrs-timeline-dot-size * .5 + $o-hrs-timeline-entry-padding));
width: 1px;
height: 100%;
margin-left: -.01rem;
background-color: $border-color;
content: "";
}
}
}
.o_resume_line_title, .o_resume_line_desc {
white-space: normal;
}
.o_resume_line_title, .o_resume_line_dates {
line-height: 1;
}
.o_resume_group_header + .o_data_row .o_resume_timeline_cell:before {
top: $o-hrs-timeline-entry-padding;
}
.o_data_row.o_data_row_last {
.o_resume_line_desc {
margin-bottom: $headings-margin-bottom;
}
.o_resume_timeline_cell:before {
height: $o-hrs-timeline-entry-padding;
}
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="hr_skills.ResumeListRenderer" owl="1" t-inherit-mode="primary" t-inherit="hr_skills.SkillsListRenderer">
<xpath expr="//table" position="attributes">
<attribute name="t-attf-class" add="table-borderless" remove="table-striped" separator=" "/>
</xpath>
<xpath expr="//thead/tr" position="replace">
<tr>
<th style="width: 32px; min-width: 32px;"></th>
<th class="w-100"></th>
<th t-if="isEditable" class="o_list_record_remove_header" style="width: 32px; min-width: 32px"></th>
</tr>
</xpath>
</t>
<t t-name="hr_skills.ResumeListRenderer.Rows" owl="1" t-inherit-mode="primary" t-inherit="hr_skills.SkillsListRenderer.Rows">
<xpath expr="//tr" position="attributes">
<attribute name="class" add="o_resume_group_header" separator=" "/>
</xpath>
<xpath expr="//th[hasclass('o_group_name')]" position="after">
<th></th>
</xpath>
</t>
<t t-name="hr_skills.ResumeListRenderer.RecordRow" owl="1" t-inherit-mode="primary" t-inherit="web.ListRenderer.RecordRow">
<xpath expr="//t[@t-foreach='state.columns']" position="replace">
<t t-set="data" t-value="record.data"/>
<td class="o_resume_timeline_cell position-relative pe-lg-2">
<div class="rounded-circle bg-info position-relative"/>
</td>
<td class="o_data_cell pt-0" t-on-click="(ev) => this.onCellClicked(record, null, ev)">
<div class="o_resume_line" t-att-data-id="id">
<small class="o_resume_line_dates fw-bold">
<t t-esc="formatDate(data.date_start)"/> - <t t-if="data.date_end" t-esc="formatDate(data.date_end)"/><t t-else="">Current</t>
</small>
<h4 class="o_resume_line_title mt-2" t-esc="data.name"/>
<p t-if="data.description" class="o_resume_line_desc" t-esc="data.description"/>
</div>
</td>
</xpath>
</t>
</odoo>
@@ -0,0 +1,44 @@
/** @odoo-module */
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
import { registry } from "@web/core/registry";
import { CommonSkillsListRenderer } from "../views/skills_list_renderer";
export class SkillsListRenderer extends CommonSkillsListRenderer {
get groupBy() {
return 'skill_type_id';
}
calculateColumnWidth(column) {
if (column.name != 'skill_level_id') {
return {
type: 'absolute',
value: '90px',
}
}
return super.calculateColumnWidth(column);
}
}
SkillsListRenderer.template = 'hr_skills.SkillsListRenderer';
export class SkillsX2ManyField extends X2ManyField {
setup() {
super.setup();
this.Renderer = SkillsListRenderer;
}
async onAdd({ context } = {}) {
const employeeId = this.props.record.resId;
return super.onAdd({
context: {
...context,
default_employee_id: employeeId,
}
});
}
};
registry.category("fields").add("skills_one2many", SkillsX2ManyField);
@@ -0,0 +1,23 @@
.o_field_skills_one2many, .o_field_resume_one2many {
.o_progress {
background: $gray-300;
border: 0;
height: 5px;
}
.o_progressbar_value {
font-size: $font-size-sm;
font-weight: bold;
}
}
table.o_skill_table, .o_hr_skills_dialog_form {
.o_progressbar {
display: flex;
align-items: center;
.o_progressbar_value {
width: auto;
}
}
}
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="hr_skills.SkillsListRenderer" owl="1" t-inherit-mode="primary" t-inherit="web.ListRenderer">
<xpath expr="//table" position="attributes">
<attribute name="t-attf-class" add="mb-1 {{ !isEditable ? 'cursor-default' : '' }} o_skill_table" separator=" "/>
</xpath>
<xpath expr="//thead" position="attributes">
<attribute name="style">visibility: collapse;</attribute>
</xpath>
<xpath expr="//tbody[hasclass('ui-sortable')]/t" position="replace">
<t t-call="{{RowsTemplate}}">
<t t-set="list" t-value="props.list"/>
</t>
</xpath>
<xpath expr="//table" position="after">
<t t-if="!showTable">
<button t-on-click="props.onAdd" class="btn btn-secondary" role="button" t-if="isEditable">
Create a new entry
</button>
</t>
</xpath>
</t>
<t t-name="hr_skills.SkillsListRenderer.Rows" owl="1">
<t t-foreach="Object.entries(groupedList)" t-as="skill_group" t-key="skill_group[0]">
<tr class="o_group_has_content o_group_header">
<th tabindex="-1" class="o_group_name" t-att-colspan="colspan">
<div class="d-flex justify-content-between align-items-center">
<span t-esc="skill_group[1].name"/>
<button class="btn btn-secondary btn-sm"
t-if="isEditable"
t-on-click="() => props.onAdd({ context: { default_skill_type_id: skill_group[1].id }})"
role="button">ADD</button>
</div>
</th>
</tr>
<t t-foreach="skill_group[1].list.records" t-as="record" t-key="record.id">
<t t-set="group" t-value="skill_group[1]"/>
<t t-call="{{RecordRowTemplate}}"/>
</t>
</t>
</t>
</odoo>
@@ -1,273 +0,0 @@
odoo.define('web.FieldResume', function (require) {
"use strict";
var time = require('web.time');
var FieldOne2Many = require('web.relational_fields').FieldOne2Many;
var FieldProgressBar = require('web.basic_fields').FieldProgressBar;
var ListRenderer = require('web.ListRenderer');
var field_registry = require('web.field_registry');
var core = require('web.core');
var qweb = core.qweb;
var _t = core._t;
var AbstractGroupedOne2ManyRenderer = ListRenderer.extend({
/**
* This abstract renderer is use to render a one2many field in a form view.
* The records in the one2many field are displayed grouped by a specific field.
*
* A concrete renderer can/should set:
* - groupBy: field to group records
* - dataRowTemplate: template to render a record's data
* - groupTitleTemplate (optional): template to render the header row of a group
* - addLineButtonTemplate (optional): template to render the 'Add a line' button at the end of each group (edit mode only)
**/
groupBy: '', // Field: records are grouped based on this field
groupTitleTemplate: 'hr_default_group_row', // Template used to render the title row of a group
dataRowTemplate: '', // Template used to render a record
addLineButtonTemplate: 'group_add_item',
/**
* Don't freeze the columns because as the header is empty, the algorithm
* won't work.
*
* @override
* @private
*/
_freezeColumnWidths: function () {},
/**
* Renders a empty header
*
* @override
* @private
*/
_renderHeader: function () {
return $('<thead/>');
},
/**
* Renders a empty footer
*
* @override
* @private
*/
_renderFooter: function () {
return $('<tfoot/>');
},
/**
* @override
* @private
*/
_renderGroupRow: function (display_name) {
return qweb.render(this.groupTitleTemplate, {display_name: display_name});
},
/**
* This method is meant to be overriten by concrete renderers and
* is called each time a row is rendered.
* It is a hook to format record's data before it's given to the qweb template.
*
* @private
*/
_formatData: function (data) {
return data;
},
_renderRow: function (record, isLast) {
return $(qweb.render(this.dataRowTemplate, {
id: record.id,
data: this._formatData(record.data),
is_last: isLast,
}));
},
/**
* This method is meant to be overridden by concrete renderers.
* Returns a context used for the 'Add a line' button.
* It's useful to set default values.
* An 'Add a line' button is added after each group of records.
* The group passed as parameters allow to set a different context based on the group.
* If no records exist, group is undefined.
*
* @private
*/
_getCreateLineContext: function (group) {
return {};
},
_renderTrashIcon: function() {
return qweb.render('hr_trash_button');
},
_renderAddItemButton: function (group) {
return qweb.render(this.addLineButtonTemplate, {
context: JSON.stringify(this._getCreateLineContext(group)),
});
},
_renderBody: function () {
var self = this;
var grouped_by = _.groupBy(this.state.data, function (record) {
return record.data[self.groupBy].res_id;
});
var groupTitle;
var $body = $('<tbody>');
for (var key in grouped_by) {
var group = grouped_by[key];
if (key === 'undefined') {
groupTitle = _t("Other");
} else {
groupTitle = group[0].data[self.groupBy].data.display_name;
}
var $title_row = $(self._renderGroupRow(groupTitle));
$body.append($title_row);
// Render each rows
group.forEach(function (record, index) {
var isLast = (index + 1 === group.length);
var $row = self._renderRow(record, isLast);
if (self.addTrashIcon) $row.append(self._renderTrashIcon());
$body.append($row);
});
if (self.addCreateLine) {
$title_row.find('.o_group_name').append(self._renderAddItemButton(group));
}
}
if ($body.is(':empty') && self.addCreateLine) {
$body.append(this._renderAddItemButton());
}
return $body;
},
/**
* This function enables the top right menu on list views to hide/show fields or, when studio is installed,
* edit the view's content.
* For this widget we do not want it at all.
*
* @override
* @private
* @returns {boolean}
*/
_shouldRenderOptionalColumnsDropdown: function () {
return false;
}
});
var ResumeLineRenderer = AbstractGroupedOne2ManyRenderer.extend({
groupBy: 'line_type_id',
groupTitleTemplate: 'hr_resume_group_row',
dataRowTemplate: 'hr_resume_data_row',
_formatData: function (data) {
var dateFormat = time.getLangDateFormat();
var date_start = data.date_start && data.date_start.format(dateFormat) || "";
var date_end = data.date_end && data.date_end.format(dateFormat) || _t("Current");
return _.extend(data, {
date_start: date_start,
date_end: date_end,
});
},
_getCreateLineContext: function (group) {
var ctx = this._super(group);
return group ? _.extend({default_line_type_id: group[0].data[this.groupBy] && group[0].data[this.groupBy].data.id || ""}, ctx) : ctx;
},
_render: function () {
var self = this;
return this._super().then(function () {
self.$el.find('table').removeClass('table-striped o_list_table_ungrouped');
self.$el.find('table').addClass('o_resume_table table-borderless');
});
},
});
var SkillsRenderer = AbstractGroupedOne2ManyRenderer.extend({
groupBy: 'skill_type_id',
dataRowTemplate: 'hr_skill_data_row',
_renderRow: function (record) {
var $row = this._super(record);
// Add progress bar widget at the end of rows
var $td = $('<td/>', {class: 'o_data_cell o_skill_cell'});
var progress = new FieldProgressBar(this, 'level_progress', record, {
current_value: record.data.level_progress,
attrs: this.arch.attrs,
});
progress.appendTo($td);
return $row.append($td);
},
_getCreateLineContext: function (group) {
var ctx = this._super(group);
return group ? _.extend({ default_skill_type_id: group[0].data[this.groupBy].data.id }, ctx) : ctx;
},
_render: function () {
var self = this;
return this._super().then(function () {
self.$el.find('table').toggleClass('table-striped');
});
},
_onAddRecord: function(ev) {
ev.preventDefault();
ev.stopPropagation();
let ctx = _.extend(JSON.parse(ev.currentTarget.dataset.context || '{}'), this.state.getContext());
this._rpc({
model: 'hr.skill.type',
method: 'search_count',
args: [[
['skill_ids', '!=', false],
['skill_level_ids', '!=', false]
]],
}).then(count => {
if (count) {
this.unselectRow().then(() => this.trigger_up('add_record', {context: [ctx]}));
} else {
this.do_action('hr_skills.hr_skill_type_action');
}
});
}
});
var FieldResume = FieldOne2Many.extend({
/**
* @override
* @private
*/
_getRenderer: function () {
return ResumeLineRenderer;
},
});
var FieldSkills = FieldOne2Many.extend({
/**
* @override
* @private
*/
_getRenderer: function () {
return SkillsRenderer;
},
});
field_registry.add('hr_resume', FieldResume);
field_registry.add('hr_skills', FieldSkills);
return FieldResume;
});
@@ -0,0 +1,64 @@
/** @odoo-module */
import { ListRenderer } from "@web/views/list/list_renderer";
export class CommonSkillsListRenderer extends ListRenderer {
get colspan() {
const span = this.allColumns.length;
if (this.isEditable) {
return span + 1;
}
return span;
}
get groupBy() {
return '';
}
get groupedList() {
const grouped = {};
for (const record of this.list.records) {
const data = record.data;
const group = data[this.groupBy];
if (grouped[group[1]] === undefined) {
grouped[group[1]] = {
id: parseInt(group[0]),
name: group[1] || this.env._t('Other'),
list: {
records: [],
},
};
}
grouped[group[1]].list.records.push(record);
}
return grouped;
}
get showTable() {
return this.props.list.records.length;
}
get isEditable() {
return this.props.editable !== false;
}
async onCellClicked(record, column, ev) {
if (!this.isEditable) {
return;
}
return await super.onCellClicked(record, column, ev);
}
get RowsTemplate() {
return 'hr_skills.SkillsListRenderer.Rows';
}
get RecordRowTemplate() {
return 'web.ListRenderer.RecordRow';
}
}
@@ -0,0 +1,147 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('hr_skills_tour', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(),
{
content: "Open Employees app",
trigger: ".o_app[data-menu-xmlid='hr.menu_hr_root']",
},
{
content: "Create a new employee",
trigger: ".o-kanban-button-new",
},
{
content: "Pick a name",
trigger: ".o_field_widget[name='name'] input",
run: "text Jony McHallyFace",
},
{
content: "Save",
trigger: ".o_form_button_save",
},
{
content: "Switch to edit",
trigger: ".o_form_button_edit",
},
{
content: "Add a new Resume experience",
trigger: ".o_field_resume_one2many tr.o_resume_group_header button.btn-secondary",
},
{
content: "Enter some company name",
trigger: ".o_form_view_dialog .o_field_widget[name='name'] input",
run: "text Mamie Rock",
},
{
content: "Set start date",
trigger: ".o_field_widget[name='date_start'] input",
run: "text 12/05/2017",
},
{
content: "Give some description",
trigger: ".o_field_widget[name='description'] textarea",
run: "text Sang some songs and played some music",
},
{
content: "Save it",
trigger: ".o_form_button_save",
run: "click",
},
{
content: "Edit newly created experience",
trigger: ".o_resume_line_title:contains('Mamie Rock')",
run: "click",
},
{
content: "Change type",
trigger: ".o_field_widget[name='line_type_id'] input",
run: "text Experience",
},
{
content: "Choose experience",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Experience")',
run: "click",
},
{
content: "Save experience change",
trigger: ".o_form_button_save",
in_modal: true,
run: "click",
},
{
content: "Add a new Skill",
trigger: ".o_field_skills_one2many button:contains('Create a new entry')",
},
{
content: "Select Music",
trigger: ".o_field_widget[name='skill_type_id'] label:contains('Best Music')",
run: "click",
},
{
content: "Select a song",
trigger: ".o_field_widget[name='skill_id'] input",
run: "text Fortun",
},
{
content: "Choose the song",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Fortunate Son")',
run: "click",
},
{
content: "Select a level",
trigger: ".o_field_widget[name='skill_level_id'] input",
run: "text Level",
},
{
content: "Choose the level",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Level 2")',
run: "click",
},
{
content: "Save new skill",
trigger: ".o_form_button_save",
in_modal: true,
run: "click",
},
{
content: "Add a new Skill",
trigger: ".o_field_skills_one2many button:contains('ADD')",
},
{
content: "Select a song", // "Music" should be already selected
trigger: ".o_field_widget[name='skill_id'] input",
run: "text Mary",
},
{
content: "Choose the song",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Oh Mary")',
run: "click",
},
{
content: "Select a level",
trigger: ".o_field_widget[name='skill_level_id'] input",
run: "text Level 7",
},
{
content: "Choose the level",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Level 7")',
run: "click",
},
{
content: "Save new skill",
trigger: ".o_form_button_save",
in_modal: true,
run: "click",
},
...tour.stepUtils.saveForm(),
{
content: "Go back to employees",
trigger: 'a[data-menu-xmlid="hr.menu_hr_root"]',
run: "click",
}
]);
@@ -1,328 +0,0 @@
odoo.define('hr_skills.field_one_to_many_group_tests', function (require) {
"use strict";
var FormView = require('web.FormView');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
QUnit.module('skills_widgets', {
beforeEach: function () {
this.data = {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
line_ids: { string: "one2many field", type: "one2many", relation: 'line', relation_field: 'trululu' },
skill_ids: { string: "one2many field", type: "one2many", relation: 'partner_skill', relation_field: 'partner_id' },
},
records: [{
id: 1,
display_name: "first record",
line_ids: [37, 38, 39],
skill_ids: [75, 76, 77]
}],
onchanges: {},
},
partner_skill: {
fields: {
skill_id: { string: "Name", type: "many2one", relation: 'skill'},
skill_type_id: { string: "Type", type: "many2one", relation: 'skill_type' },
skill_level_id: { string: "Level", type: "many2one", relation: 'skill_level' },
level_progress: { string: "Progress", type: "int" },
},
records: [{
id: 75,
skill_id: 444,
skill_type_id: 221,
skill_level_id: 112,
level_progress: 50,
}, {
id: 76,
skill_id: 445,
skill_type_id: 222,
skill_level_id: 111,
level_progress: 50,
}, {
id: 77,
skill_id: 446,
skill_type_id: 222,
skill_level_id: 111,
level_progress: 70,
}],
},
skill: {
fields: {
name: { string: "Name", type: "char" },
},
records: [
{ id: 444, name: 'Python' },
{ id: 445, name: 'Piano' },
{ id: 446, name: 'Flute' },
]
},
skill_level: {
fields: {
name: { string: "Name", type: "char" },
},
records: [
{ id: 111, name: 'L1' },
{ id: 112, name: 'Intermediate' }
]
},
skill_type: {
fields: {
name: { string: "Name", type: "char" },
},
records: [
{ id: 221, name: 'Dev' },
{ id: 222, name: 'Music' },
]
},
line: {
fields: {
name: { string: "Name", type: "char" },
line_type_id: { string: "Type", relation: 'line_type', type: "many2one" },
description: { string: "Description", type: "text" },
date_start: { string: "Date start", type: "date" },
date_end: { string: "Date end", type: "date" },
trululu: { string: "Trululu", type: "many2one", relation: 'partner' },
display_type: { string: "display type", type: "selection"},
},
records: [{
id: 37,
name: "ULB",
line_type_id: 50,
date_start: "2017-01-25",
date_end: "2019-01-25",
description: 'Hello',
trululu: 1,
display_type: 'classic',
}, {
id: 38,
name: "UCL",
line_type_id: 50,
date_start: "2013-01-25",
date_end: "2014-01-25",
description: 'World',
trululu: 1,
display_type: 'classic',
}, {
id: 39,
name: "KUL",
line_type_id: 51,
date_start: "2008-01-25",
description: 'Hi',
trululu: 1,
display_type: 'classic',
}],
onchanges: {},
},
line_type: {
fields: {
name: { string: "Name", type: "char" },
},
records: [{
id: 50,
name: 'AAA',
}, {
id: 51,
name: 'BBB'
}],
}
};
}
}, function () {
QUnit.test('resumé one2many field group by field render', async function (assert) {
assert.expect(16);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="line_ids" widget="hr_resume">' +
'<tree>' +
'<field name="name"/>' +
'<field name="line_type_id"/>' +
'<field name="description"/>' +
'<field name="date_start"/>' +
'<field name="date_end"/>' +
'<field name="display_type"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
});
var $headers = form.$('.o_resume_group_header');
assert.strictEqual($headers.length, 2, 'There should be 2 headers');
assert.strictEqual($headers.find('td:contains(AAA)').length, 1, "it should have line type AAA");
assert.strictEqual($headers.find('td:contains(BBB)').length, 1, "it should have line type BBB");
var dataRows = form.$('.o_data_row');
assert.strictEqual(dataRows.length, 3, 'There should be 3 data rows');
var $row = $(dataRows[0]);
assert.strictEqual($row.find('td:contains(01/25/2017)').length, 1, "it should have start date 01/25/2017");
assert.strictEqual($row.find('td:contains(01/25/2019)').length, 1, "it should have end date 01/25/2019");
assert.strictEqual($row.find('td:contains(ULB)').length, 1, "it should have line name ULB");
assert.strictEqual($row.find('td:contains(Hello)').length, 1, "it should have line description Hello");
$row = $(dataRows[1]);
assert.strictEqual($row.find('td:contains(01/25/2013)').length, 1, "it should have start date 01/25/2013");
assert.strictEqual($row.find('td:contains(01/25/2014)').length, 1, "it should have end date 01/25/2014");
assert.strictEqual($row.find('td:contains(UCL)').length, 1, "it should have line name UCL");
assert.strictEqual($row.find('td:contains(World)').length, 1, "it should have line description World");
$row = $(dataRows[2]);
assert.strictEqual($row.find('td:contains(01/25/2008)').length, 1, "it should have start date 01/25/2008");
assert.strictEqual($row.find('td:contains(Current)').length, 1, "it should have end date Current");
assert.strictEqual($row.find('td:contains(KUL)').length, 1, "it should have line name KUL");
assert.strictEqual($row.find('td:contains(Hi)').length, 1, "it should have line description Hi");
form.destroy();
});
QUnit.test('resumé one2many field group by field create', async function (assert) {
assert.expect(5);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="line_ids" widget="hr_resume">' +
'<tree>' +
'<field name="name"/>' +
'<field name="line_type_id"/>' +
'<field name="description"/>' +
'<field name="date_start"/>' +
'<field name="date_end"/>' +
'</tree>' +
'</field>' +
'</form>',
archs: {
'line,false,form': '<form>' +
'<field name="name"/>' +
'<field name="line_type_id"/>' +
'<field name="description"/>' +
'<field name="date_start"/>' +
'<field name="date_end"/>' +
'</form>',
},
res_id: 1,
mockRPC: function (route, args) {
var result = this._super.apply(this, arguments);
if (args.method === 'write') {
var new_line_data = args.args[1].line_ids[3][2];
assert.strictEqual(new_line_data.date_end, '2030-01-01');
assert.strictEqual(new_line_data.date_start, '2025-01-01');
assert.strictEqual(new_line_data.line_type_id, 50, "it should have the line type from context");
assert.strictEqual(new_line_data.name, 'new line');
assert.strictEqual(new_line_data.description, 'new description');
}
return result;
},
});
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a')[0]);
// Fill line form (type should be set from the add button context)
await testUtils.fields.editInput($('input[name="name"]'), 'new line');
await testUtils.fields.editInput($('textarea[name="description"]'), 'new description');
await testUtils.fields.editSelect($('input[name="date_start"]'), '2025-01-01');
await testUtils.fields.editSelect($('input[name="date_end"]'), '2030-01-01');
await testUtils.modal.clickButton('Save & Close');
await testUtils.form.clickSave(form);
form.destroy();
});
QUnit.test('resumé one2many field group by field delete', async function (assert) {
assert.expect(2);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="line_ids" widget="hr_resume">' +
'<tree>' +
'<field name="name"/>' +
'<field name="line_type_id"/>' +
'<field name="description"/>' +
'<field name="date_start"/>' +
'<field name="date_end"/>' +
'</tree>' +
'</field>' +
'</form>',
archs: {
'line,false,form': '<form>' +
'<field name="name"/>' +
'<field name="line_type_id"/>' +
'<field name="description"/>' +
'<field name="date_start"/>' +
'<field name="date_end"/>' +
'</form>',
},
res_id: 1,
mockRPC: function (route, args) {
var result = this._super.apply(this, arguments);
if (args.method === 'write') {
var orm_cmd = args.args[1].line_ids[2][0];
var id = args.args[1].line_ids[2][1];
assert.strictEqual(orm_cmd, 2, "it should delete resume line");
assert.strictEqual(id, 37, "it should delete resume line #37");
}
return result;
},
});
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_list_record_remove')[0]);
await testUtils.form.clickSave(form);
form.destroy();
});
QUnit.test('skills one2many field group by field render', async function (assert) {
assert.expect(13);
var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="skill_ids" widget="hr_skills">' +
'<tree>' +
'<field name="skill_id"/>' +
'<field name="skill_type_id"/>' +
'<field name="skill_level_id"/>' +
'<field name="level_progress"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
});
var $headers = form.$('.o_group_header');
assert.strictEqual($headers.length, 2, 'There should be 2 headers');
assert.strictEqual($headers.find('td:contains(Dev)').length, 1, "it should have skill type Dev");
assert.strictEqual($headers.find('td:contains(Music)').length, 1, "it should have skill type Music");
var dataRows = form.$('.o_data_row');
assert.strictEqual(dataRows.length, 3, 'There should be 3 data rows');
var $row = $(dataRows[0]);
assert.strictEqual($row.find('td:contains(Python)').length, 1, "it should have skill name Python");
assert.strictEqual($row.find('td:contains(Intermediate)').length, 1, "it should have skill name Intermediate");
assert.strictEqual($row.find('td:contains(50)').length, 1, "it should have skill progress 50");
$row = $(dataRows[1]);
assert.strictEqual($row.find('td:contains(Piano)').length, 1, "it should have skill name Piano");
assert.strictEqual($row.find('td:contains(L1)').length, 1, "it should have skill level L1");
assert.strictEqual($row.find('td:contains(50)').length, 1, "it should have skill progress 50");
$row = $(dataRows[2]);
assert.strictEqual($row.find('td:contains(Flute)').length, 1, "it should have skill name Flute");
assert.strictEqual($row.find('td:contains(L1)').length, 1, "it should have skill level L1");
assert.strictEqual($row.find('td:contains(70)').length, 1, "it should have skill progress 70");
form.destroy();
});
});
});
+3
View File
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_ui
+24
View File
@@ -0,0 +1,24 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
@odoo.tests.tagged('-at_install', 'post_install')
class SkillsTestUI(odoo.tests.HttpCase):
def test_ui(self):
levels = self.env['hr.skill.level'].create([{
'name': f'Level {x}',
'level_progress': x * 10,
} for x in range(10)])
skill_type = self.env['hr.skill.type'].create({
'name': 'Best Music',
'skill_level_ids': levels.ids,
})
self.env['hr.skill'].create([{
'name': 'Fortunate Son',
'skill_type_id': skill_type.id,
}, {
'name': 'Oh Mary',
'skill_type_id': skill_type.id,
}])
self.start_tour("/web", 'hr_skills_tour', login='admin')
+32 -28
View File
@@ -33,23 +33,26 @@
<field name="model">hr.resume.line</field>
<field name="arch" type="xml">
<form string="Resumé">
<div class="oe_title">
<label for="name" string="Title"/>
<h1>
<field name="name" placeholder="e.g. Odoo Inc." required="True"/>
</h1>
</div>
<group>
<sheet>
<div class="oe_title">
<label for="name" string="Title"/>
<h1>
<field name="name" placeholder="e.g. Odoo Inc." required="True"/>
</h1>
</div>
<group>
<field name="line_type_id"/>
<field name="display_type" required="1"/>
<group>
<field name="employee_id" invisible="1"/>
<field name="line_type_id"/>
<field name="display_type" required="1"/>
</group>
<group>
<field name="date_start" required="True"/>
<field name="date_end"/>
</group>
</group>
<group>
<field name="date_start" required="True"/>
<field name="date_end"/>
</group>
</group>
<field name="description" placeholder="Description"/>
<field name="description" placeholder="Description"/>
</sheet>
</form>
</field>
</record>
@@ -64,10 +67,10 @@
<div class="row">
<div class="o_hr_skills_editable o_hr_skills_group o_group_resume col-lg-7 d-flex flex-column">
<separator string="Resume"/>
<!-- This field uses a custom tree view rendered by the 'hr_resume' widget.
<!-- This field uses a custom tree view rendered by the 'resume_one2many' widget.
Adding fields in the tree arch below makes them accessible to the widget
-->
<field mode="tree" nolabel="1" name="resume_line_ids" widget="hr_resume">
<field mode="tree" nolabel="1" name="resume_line_ids" widget="resume_one2many">
<tree>
<field name="line_type_id"/>
<field name="name"/>
@@ -77,10 +80,11 @@
<field name="display_type" invisible="1"/>
</tree>
</field>
</div>
<div class="o_hr_skills_editable o_hr_skills_group o_group_skills col-lg-5 d-flex flex-column">
<separator string="Skills"/>
<field mode="tree" nolabel="1" name="employee_skill_ids" widget="hr_skills" context="{'default_employee_id': id}">
<field mode="tree" nolabel="1" name="employee_skill_ids" widget="skills_one2many">
<tree>
<field name="skill_type_id" invisible="1"/>
<field name="skill_id"/>
@@ -104,11 +108,11 @@
<xpath expr="//page[@name='public']" position="before">
<page name="public" string="Resumé">
<div class="row">
<div class="o_hr_skills_group o_group_resume col-lg-7 d-flex">
<!-- This field uses a custom tree view rendered by the 'hr_resume' widget.
<div class="o_hr_skills_group o_group_resume col-lg-7 d-flex flex-column">
<!-- This field uses a custom tree view rendered by the 'resume_one2many' widget.
Adding fields in the tree arch below makes them accessible to the widget
-->
<field mode="tree" nolabel="1" name="resume_line_ids" widget="hr_resume" attrs="{'readonly': True}" options="{'no_open': True}">
<field mode="tree" nolabel="1" name="resume_line_ids" widget="resume_one2many">
<tree>
<field name="line_type_id"/>
<field name="name"/>
@@ -121,7 +125,7 @@
</div>
<div class="o_hr_skills_group o_group_skills col-lg-5 d-flex flex-column">
<separator string="Skills"/>
<field mode="tree" nolabel="1" name="employee_skill_ids" widget="hr_skills" attrs="{'readonly': True}" options="{'no_open': True}">
<field mode="tree" nolabel="1" name="employee_skill_ids" widget="skills_one2many">
<tree>
<field name="skill_type_id" invisible="1"/>
<field name="skill_id"/>
@@ -145,10 +149,10 @@
<page name="public" string="Resumé">
<div class="row">
<div class="o_hr_skills_group o_group_resume col-lg-7 d-flex">
<!-- This field uses a custom tree view rendered by the 'hr_resume' widget.
<!-- This field uses a custom tree view rendered by the 'resume_one2many' widget.
Adding fields in the tree arch below makes them accessible to the widget
-->
<field mode="tree" nolabel="1" name="resume_line_ids" widget="hr_resume" attrs="{'readonly': [('can_edit', '=', False)]}">
<field mode="tree" nolabel="1" name="resume_line_ids" widget="resume_one2many" attrs="{'readonly': [('can_edit', '=', False)]}">
<tree>
<field name="line_type_id"/>
<field name="name"/>
@@ -161,7 +165,7 @@
</div>
<div class="o_hr_skills_group o_group_skills col-lg-5 d-flex flex-column">
<separator string="Skills"/>
<field mode="tree" nolabel="1" name="employee_skill_ids" widget="hr_skills" attrs="{'readonly': [('can_edit', '=', False)]}">
<field mode="tree" nolabel="1" name="employee_skill_ids" widget="skills_one2many" attrs="{'readonly': [('can_edit', '=', False)]}">
<tree>
<field name="skill_type_id" invisible="1"/>
<field name="skill_id"/>
@@ -259,7 +263,7 @@
<field name="name">hr.employees.skill.form</field>
<field name="model">hr.employee.skill</field>
<field name="arch" type="xml">
<form string="Skills">
<form string="Skills" class="o_hr_skills_dialog_form">
<sheet>
<group>
<group>
@@ -275,12 +279,12 @@
attrs="{'invisible': ['|', ('skill_id', '=', False), ('skill_type_id', '=', False)]}"/>
<div class="o_row"
attrs="{'invisible': ['|', ('skill_id', '=', False), ('skill_type_id', '=', False)]}">
<span class="col-4 ps-0">
<span class="ps-0" style="flex:1">
<field name="skill_level_id"
attrs="{'readonly': [('skill_id', '=', False)]}"
context="{'from_skill_level_dropdown': True}" />
</span>
<span class="col-8">
<span style="flex:1">
<field name="level_progress" widget="progressbar" class="o_hr_skills_progress" attrs="{'invisible': [('skill_level_id', '=', False)]}" />
</span>
</div>