Initial commit: Hr packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 62531cd146
2820 changed files with 1432848 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
<defs>
<path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#269396"/>
<stop offset="100%" stop-color="#218689"/>
</linearGradient>
<path id="icon-d" d="M35.5,22.75 C39.6529297,22.75 43.0195312,26.1166016 43.0195312,30.2695312 C43.0195312,34.4224609 39.6529297,37.7890625 35.5,37.7890625 C31.3470703,37.7890625 27.9804687,34.4224609 27.9804687,30.2695312 C27.9804687,26.1166016 31.3470703,22.75 35.5,22.75 Z M43.6256055,38.3165755 L40.7623112,37.6007161 C37.2411654,40.1333659 32.9730794,39.5681836 30.2377604,37.6007161 L27.3744661,38.3165755 C25.0790039,38.8904232 23.46875,40.9527799 23.46875,43.3188542 L23.46875,47.671875 C23.46875,49.0957161 24.6230339,50.25 26.046875,50.25 L44.953125,50.25 C46.3769661,50.25 47.53125,49.0957161 47.53125,47.671875 L47.53125,43.3188542 C47.53125,40.9527799 45.9209961,38.8904232 43.6256055,38.3165755 Z M50.3958333,39.6510417 C53.1644531,39.6510417 55.4088542,37.4066406 55.4088542,34.6380208 C55.4088542,31.869401 53.1644531,29.625 50.3958333,29.625 C47.6272135,29.625 45.3828125,31.869401 45.3828125,34.6380208 C45.3828125,37.4066406 47.6272135,39.6510417 50.3958333,39.6510417 Z M20.6041667,39.6510417 C23.3727865,39.6510417 25.6171875,37.4066406 25.6171875,34.6380208 C25.6171875,31.869401 23.3727865,29.625 20.6041667,29.625 C17.8355469,29.625 15.5911458,31.869401 15.5911458,34.6380208 C15.5911458,37.4066406 17.8355469,39.6510417 20.6041667,39.6510417 Z M22.3229167,47.671875 L22.3229167,43.3188542 C22.3229167,42.1335612 22.6518424,41.0125781 23.2326367,40.0533008 C21.0850586,41.1074674 18.6968555,40.6769206 17.0959831,39.5255013 L15.1870964,40.0027409 C13.6568359,40.3852344 12.5833333,41.7602344 12.5833333,43.3375456 L12.5833333,46.2395833 C12.5833333,47.1888346 13.352832,47.9583333 14.3020833,47.9583333 L22.3350195,47.9583333 C22.3273388,47.8630362 22.3233016,47.7674804 22.3229167,47.671875 Z M55.8129036,40.0026693 L53.9040169,39.5254297 C51.9041797,40.9638802 49.5434049,40.902793 47.7604883,40.0423437 C48.3454362,41.004056 48.6770833,42.1289779 48.6770833,43.3188542 L48.6770833,47.671875 C48.6770833,47.7683398 48.6722135,47.8636589 48.6649805,47.9583333 L56.6979167,47.9583333 C57.647168,47.9583333 58.4166667,47.1888346 58.4166667,46.2395833 L58.4166667,43.3375456 C58.4166667,41.7602344 57.3431641,40.3852344 55.8129036,40.0026693 Z"/>
<path id="icon-e" d="M35.5,20.75 C39.6529297,20.75 43.0195312,24.1166016 43.0195312,28.2695312 C43.0195312,32.4224609 39.6529297,35.7890625 35.5,35.7890625 C31.3470703,35.7890625 27.9804687,32.4224609 27.9804687,28.2695312 C27.9804687,24.1166016 31.3470703,20.75 35.5,20.75 Z M43.6256055,36.3165755 L40.7623112,35.6007161 C37.2411654,38.1333659 32.9730794,37.5681836 30.2377604,35.6007161 L27.3744661,36.3165755 C25.0790039,36.8904232 23.46875,38.9527799 23.46875,41.3188542 L23.46875,45.671875 C23.46875,47.0957161 24.6230339,48.25 26.046875,48.25 L44.953125,48.25 C46.3769661,48.25 47.53125,47.0957161 47.53125,45.671875 L47.53125,41.3188542 C47.53125,38.9527799 45.9209961,36.8904232 43.6256055,36.3165755 Z M50.3958333,37.6510417 C53.1644531,37.6510417 55.4088542,35.4066406 55.4088542,32.6380208 C55.4088542,29.869401 53.1644531,27.625 50.3958333,27.625 C47.6272135,27.625 45.3828125,29.869401 45.3828125,32.6380208 C45.3828125,35.4066406 47.6272135,37.6510417 50.3958333,37.6510417 Z M20.6041667,37.6510417 C23.3727865,37.6510417 25.6171875,35.4066406 25.6171875,32.6380208 C25.6171875,29.869401 23.3727865,27.625 20.6041667,27.625 C17.8355469,27.625 15.5911458,29.869401 15.5911458,32.6380208 C15.5911458,35.4066406 17.8355469,37.6510417 20.6041667,37.6510417 Z M22.3229167,45.671875 L22.3229167,41.3188542 C22.3229167,40.1335612 22.6518424,39.0125781 23.2326367,38.0533008 C21.0850586,39.1074674 18.6968555,38.6769206 17.0959831,37.5255013 L15.1870964,38.0027409 C13.6568359,38.3852344 12.5833333,39.7602344 12.5833333,41.3375456 L12.5833333,44.2395833 C12.5833333,45.1888346 13.352832,45.9583333 14.3020833,45.9583333 L22.3350195,45.9583333 C22.3273388,45.8630362 22.3233016,45.7674804 22.3229167,45.671875 Z M55.8129036,38.0026693 L53.9040169,37.5254297 C51.9041797,38.9638802 49.5434049,38.902793 47.7604883,38.0423437 C48.3454362,39.004056 48.6770833,40.1289779 48.6770833,41.3188542 L48.6770833,45.671875 C48.6770833,45.7683398 48.6722135,45.8636589 48.6649805,45.9583333 L56.6979167,45.9583333 C57.647168,45.9583333 58.4166667,45.1888346 58.4166667,44.2395833 L58.4166667,41.3375456 C58.4166667,39.7602344 57.3431641,38.3852344 55.8129036,38.0026693 Z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="icon-b" fill="#fff">
<use xlink:href="#icon-a"/>
</mask>
<g mask="url(#icon-b)">
<rect width="70" height="70" fill="url(#icon-c)"/>
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
<path fill="#393939" d="M44,47 L4,47 C2,47 -7.10542736e-15,46.8509317 0,42.826087 L1.81527147e-16,22.6291049 L17.2090667,6.04664397 L19.583071,9.5209307 L30.2767729,0.11143939 L40.9146315,10.270152 L46.6446282,6.41116033 L55.3045682,10.7749724 L52.3812234,16.1277957 L58.2417324,21.9036543 L44,47 Z" opacity=".324" transform="translate(0 23)"/>
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
<use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#icon-d"/>
<use fill="#FFF" fill-rule="nonzero" xlink:href="#icon-e"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,10 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { ImageField } from '@web/views/fields/image/image_field';
export class BackgroundImageField extends ImageField {}
BackgroundImageField.template = 'hr.BackgroundImage';
registry.category("fields").add("background_image", BackgroundImageField);

View file

@ -0,0 +1,11 @@
div.o_field_widget.o_field_background_image {
display: inline-block;
> div {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
width: 100%;
height: 100%;
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr.BackgroundImage" owl="1">
<div
t-att-data-tooltip-template="hasTooltip and tooltipAttributes.template"
t-att-data-tooltip-info="hasTooltip and tooltipAttributes.info"
t-att-data-tooltip-delay="hasTooltip and props.zoomDelay"
t-attf-style="background-image: url('#{getUrl(props.previewImage or props.name)}');"/>
</t>
</templates>

View file

@ -0,0 +1,21 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { useOpenChat } from "@mail/views/open_chat_hook";
const { Component } = owl;
export class HrEmployeeChat extends Component {
setup() {
super.setup();
this.openChat = useOpenChat(this.props.record.resModel);
}
}
HrEmployeeChat.props = {
...standardWidgetProps,
};
HrEmployeeChat.template = 'hr.OpenChat';
registry.category("view_widgets").add("hr_employee_chat", HrEmployeeChat);

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr.OpenChat" owl="1">
<a t-if="props.record.data.user_id"
title="Chat"
icon="fa-comments"
t-on-click.prevent="() => openChat(props.record.resId)"
href="#"
class="ml8 o_employee_chat_btn"
role="button">
<i class="fa fa-comments align-middle fs-6"/>
</a>
</t>
<!-- TODO KBA: remove when Studio converted to owl -->
<t t-name="hr.OpenChatLegacy">
<a
title="Chat"
icon="fa-comments"
href="#"
class="ml8 o_employee_chat_btn"
role="button">
<i class="fa fa-comments align-middle fs-6"/>
</a>
</t>
</templates>

View file

@ -0,0 +1,13 @@
odoo.define('hr.OpenChatLegacy', function (require) {
"use strict";
const widgetRegistry = require('web.widget_registry');
const Widget = require('web.Widget');
const HrEmployeeChatLegacy = Widget.extend({
template: 'hr.OpenChatLegacy',
});
// TODO KBA remove when Studio converted to Owl
widgetRegistry.add('hr_employee_chat', HrEmployeeChatLegacy);
});

View file

@ -0,0 +1,9 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { BinaryField } from "@web/views/fields/binary/binary_field";
export class WorkPermitUploadField extends BinaryField {}
WorkPermitUploadField.template = "hr.WorkPermitUploadField";
registry.category("fields").add("work_permit_upload", WorkPermitUploadField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr.WorkPermitUploadField" t-inherit="web.BinaryField" t-inherit-mode="primary" owl="1">
<xpath expr="//label[hasclass('o_select_file_button')]" position="attributes">
<attribute name="class" remove="btn-primary" add="btn-secondary" separator=" " />
</xpath>
</t>
</templates>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M83.13,35.69a7.9,7.9,0,0,0-4.94-7.33L34.33,72.22H47.06L83.13,36.15Z" style="fill:#875b7b"/><path d="M53.39,27.78,16.87,64.3h0a7.9,7.9,0,0,0,5.21,7.43l44-44Z" style="fill:#875b7b"/><path d="M24.78,27.78a7.91,7.91,0,0,0-7.91,7.91V51.57L40.66,27.78Z" style="fill:#875b7b"/><path d="M59.78,72.22H75.22a7.91,7.91,0,0,0,7.91-7.91V48.87Z" style="fill:#875b7b"/></svg>

After

Width:  |  Height:  |  Size: 431 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M83.13,35.69a7.9,7.9,0,0,0-4.94-7.33L34.33,72.22H47.06L83.13,36.15Z" style="fill:#bb86fc"/><path d="M53.39,27.78,16.87,64.3h0a7.9,7.9,0,0,0,5.21,7.43l44-44Z" style="fill:#bb86fc"/><path d="M24.78,27.78a7.91,7.91,0,0,0-7.91,7.91V51.57L40.66,27.78Z" style="fill:#bb86fc"/><path d="M59.78,72.22H75.22a7.91,7.91,0,0,0,7.91-7.91V48.87Z" style="fill:#bb86fc"/></svg>

After

Width:  |  Height:  |  Size: 432 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M21.93,55H78.07a5,5,0,0,0,0-10H21.93a5,5,0,0,0,0,10Z" style="fill:#875b7b"/></svg>

After

Width:  |  Height:  |  Size: 153 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M21.93,55H78.07a5,5,0,0,0,0-10H21.93a5,5,0,0,0,0,10Z" style="fill:#bb86fc"/></svg>

After

Width:  |  Height:  |  Size: 154 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect x="16.87" y="27.78" width="66.26" height="44.44" rx="7.91" style="fill:#875b7b"/></svg>

After

Width:  |  Height:  |  Size: 155 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect x="16.87" y="27.78" width="66.26" height="44.44" rx="7.91" style="fill:#bb86fc"/></svg>

After

Width:  |  Height:  |  Size: 156 B

View file

@ -0,0 +1,74 @@
/** @odoo-module alias=hr.Many2OneAvatarEmployee **/
import fieldRegistry from 'web.field_registry';
import {
Many2OneAvatarUser,
KanbanMany2OneAvatarUser,
KanbanMany2ManyAvatarUser,
ListMany2ManyAvatarUser,
Many2ManyAvatarUser,
} from '@mail/js/m2x_avatar_user';
// This module defines variants of the Many2OneAvatarUser and Many2ManyAvatarUser
// field widgets, to support fields pointing to 'hr.employee'. It also defines the
// kanban version of the Many2OneAvatarEmployee widget.
//
// Usage:
// <field name="employee_id" widget="many2one_avatar_employee"/>
const M2XAvatarEmployeeMixin = {
supportedModels: ['hr.employee', 'hr.employee.public'],
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
_getEmployeeID() {
return this.value.res_id;
},
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* @override
*/
_onAvatarClicked(ev) {
ev.stopPropagation(); // in list view, prevent from opening the record
const employeeId = this._getEmployeeID(ev);
this._openChat({ employeeId: employeeId });
}
};
export const Many2OneAvatarEmployee = Many2OneAvatarUser.extend(M2XAvatarEmployeeMixin);
export const KanbanMany2OneAvatarEmployee = KanbanMany2OneAvatarUser.extend(M2XAvatarEmployeeMixin);
fieldRegistry.add('many2one_avatar_employee', Many2OneAvatarEmployee);
fieldRegistry.add('kanban.many2one_avatar_employee', KanbanMany2OneAvatarEmployee);
const M2MAvatarEmployeeMixin = Object.assign(M2XAvatarEmployeeMixin, {
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
_getEmployeeID(ev) {
return parseInt(ev.target.getAttribute('data-id'), 10);
},
});
export const Many2ManyAvatarEmployee = Many2ManyAvatarUser.extend(M2MAvatarEmployeeMixin, {});
export const KanbanMany2ManyAvatarEmployee = KanbanMany2ManyAvatarUser.extend(M2MAvatarEmployeeMixin, {});
export const ListMany2ManyAvatarEmployee = ListMany2ManyAvatarUser.extend(M2MAvatarEmployeeMixin, {});
fieldRegistry.add('many2many_avatar_employee', Many2ManyAvatarEmployee);
fieldRegistry.add('kanban.many2many_avatar_employee', KanbanMany2ManyAvatarEmployee);
fieldRegistry.add('list.many2many_avatar_employee', ListMany2ManyAvatarEmployee);
export default {
Many2OneAvatarEmployee,
};

View file

@ -0,0 +1,59 @@
/** @odoo-module **/
import StandaloneFieldManagerMixin from 'web.StandaloneFieldManagerMixin';
import Widget from 'web.Widget';
import { Many2OneAvatarEmployee } from '@hr/js/m2x_avatar_employee';
const StandaloneM2OAvatarEmployee = Widget.extend(StandaloneFieldManagerMixin, {
className: 'o_standalone_avatar_employee',
/**
* @override
*/
init(parent, value) {
this._super(...arguments);
StandaloneFieldManagerMixin.init.call(this);
this.value = value;
},
/**
* @override
*/
willStart() {
return Promise.all([this._super(...arguments), this._makeAvatarWidget()]);
},
/**
* @override
*/
start() {
this.avatarWidget.$el.appendTo(this.$el);
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Create a record, and initialize and start the avatar widget.
*
* @private
* @returns {Promise}
*/
async _makeAvatarWidget() {
const modelName = 'hr.employee.public';
const fieldName = 'employee_id';
const recordId = await this.model.makeRecord(modelName, [{
name: fieldName,
relation: modelName,
type: 'many2one',
value: this.value,
}]);
const state = this.model.get(recordId);
this.avatarWidget = new Many2OneAvatarEmployee(this, fieldName, state);
this._registerWidget(recordId, fieldName, this.avatarWidget);
return this.avatarWidget.appendTo(document.createDocumentFragment());
},
});
export default StandaloneM2OAvatarEmployee;

View file

@ -0,0 +1,10 @@
/** @odoo-module **/
import basicFields from 'web.basic_fields';
import fieldRegistry from 'web.field_registry';
const WorkPermitUpload = basicFields.FieldBinaryFile.extend({
template: "hr.WorkPermitUpload",
});
fieldRegistry.add('work_permit_upload', WorkPermitUpload);

View file

@ -0,0 +1,179 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
import { clear, insert } from '@mail/model/model_field_command';
registerModel({
name: 'Employee',
modelMethods: {
/**
* @param {Object} data
* @returns {Object}
*/
convertData(data) {
const data2 = {};
if ('id' in data) {
data2.id = data.id;
}
if ('user_id' in data) {
data2.hasCheckedUser = true;
if (!data.user_id) {
data2.user = clear();
} else {
const partnerNameGet = data['user_partner_id'];
const partnerData = {
display_name: partnerNameGet[1],
id: partnerNameGet[0],
};
const userNameGet = data['user_id'];
const userData = {
id: userNameGet[0],
partner: insert(partnerData),
display_name: userNameGet[1],
};
data2.user = insert(userData);
}
}
return data2;
},
/**
* Performs the `read` RPC on the `hr.employee.public`.
*
* @param {Object} param0
* @param {Object} param0.context
* @param {string[]} param0.fields
* @param {integer[]} param0.ids
*/
async performRpcRead({ context, fields, ids }) {
const employeesData = await this.messaging.rpc({
model: 'hr.employee.public',
method: 'read',
args: [ids, fields],
kwargs: {
context,
},
});
this.messaging.models['Employee'].insert(employeesData.map(employeeData =>
this.messaging.models['Employee'].convertData(employeeData)
));
},
/**
* Performs the `search_read` RPC on `hr.employee.public`.
*
* @param {Object} param0
* @param {Object} param0.context
* @param {Array[]} param0.domain
* @param {string[]} param0.fields
*/
async performRpcSearchRead({ context, domain, fields }) {
const employeesData = await this.messaging.rpc({
model: 'hr.employee.public',
method: 'search_read',
kwargs: {
context,
domain,
fields,
},
});
this.messaging.models['Employee'].insert(employeesData.map(employeeData =>
this.messaging.models['Employee'].convertData(employeeData)
));
},
},
recordMethods: {
/**
* Checks whether this employee has a related user and partner and links
* them if applicable.
*/
async checkIsUser() {
return this.messaging.models['Employee'].performRpcRead({
ids: [this.id],
fields: ['user_id', 'user_partner_id'],
context: { active_test: false },
});
},
/**
* Gets the chat between the user of this employee and the current user.
*
* If a chat is not appropriate, a notification is displayed instead.
*
* @returns {Channel|undefined}
*/
async getChat() {
if (!this.user && !this.hasCheckedUser) {
await this.checkIsUser();
}
if (!this.exists()) {
return;
}
// prevent chatting with non-users
if (!this.user) {
this.messaging.notify({
message: this.env._t("You can only chat with employees that have a dedicated user."),
type: 'info',
});
return;
}
return this.user.getChat();
},
/**
* Opens a chat between the user of this employee and the current user
* and returns it.
*
* If a chat is not appropriate, a notification is displayed instead.
*
* @param {Object} [options] forwarded to @see `Thread:open()`
*/
async openChat(options) {
const chat = await this.getChat();
if (!this.exists()) {
return;
}
if (!chat) {
return;
}
await chat.thread.open(options);
if (!this.exists()) {
return;
}
},
/**
* Opens the most appropriate view that is a profile for this employee.
*/
async openProfile(model = 'hr.employee.public') {
return this.messaging.openDocument({
id: this.id,
model: model,
});
},
},
fields: {
/**
* Whether an attempt was already made to fetch the user corresponding
* to this employee. This prevents doing the same RPC multiple times.
*/
hasCheckedUser: attr({
default: false,
}),
/**
* Unique identifier for this employee.
*/
id: attr({
identifying: true,
}),
/**
* Partner related to this employee.
*/
partner: one('Partner', {
inverse: 'employee',
related: 'user.partner',
}),
/**
* User related to this employee.
*/
user: one('User', {
inverse: 'employee',
}),
},
});

View file

@ -0,0 +1,33 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
// dummy import to ensure mail Messaging patches are loaded beforehand
import '@mail/models/messaging';
registerPatch({
name: 'Messaging',
recordMethods: {
/**
* @override
* @param {integer} [param0.employeeId]
*/
async getChat({ employeeId }) {
if (employeeId) {
const employee = this.messaging.models['Employee'].insert({ id: employeeId });
return employee.getChat();
}
return this._super(...arguments);
},
/**
* @override
*/
async openProfile({ id, model }) {
if (model === 'hr.employee' || model === 'hr.employee.public') {
const employee = this.messaging.models['Employee'].insert({ id });
return employee.openProfile(model);
}
return this._super(...arguments);
},
},
});

View file

@ -0,0 +1,62 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
registerPatch({
name: 'Partner',
recordMethods: {
/**
* Checks whether this partner has a related employee and links them if
* applicable.
*/
async checkIsEmployee() {
await this.messaging.models['Employee'].performRpcSearchRead({
context: { active_test: false },
domain: [['user_partner_id', '=', this.id]],
fields: ['user_id', 'user_partner_id'],
});
if (!this.exists()) {
return;
}
this.update({ hasCheckedEmployee: true });
},
/**
* When a partner is an employee, its employee profile contains more
* useful information to know who he is than its partner profile.
*
* @override
*/
async openProfile() {
// limitation of patch, `this._super` becomes unavailable after `await`
const _super = this._super.bind(this, ...arguments);
if (!this.employee && !this.hasCheckedEmployee) {
await this.checkIsEmployee();
}
if (!this.exists()) {
return;
}
if (this.employee) {
return this.employee.openProfile();
}
return _super();
},
},
fields: {
/**
* Employee related to this partner. It is computed through
* the inverse relation and should be considered read-only.
*/
employee: one('Employee', {
inverse: 'partner',
}),
/**
* Whether an attempt was already made to fetch the employee
* corresponding to this partner. This prevents doing the same RPC
* multiple times.
*/
hasCheckedEmployee: attr({
default: false,
}),
},
});

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
import { one } from '@mail/model/model_field';
registerPatch({
name: 'User',
fields: {
/**
* Employee related to this user.
*/
employee: one('Employee', {
inverse: 'user',
}),
},
});

View file

@ -0,0 +1,48 @@
.o_kanban_dashboard.o_hr_department_kanban {
--KanbanRecord-width: 450px;
--KanbanRecord-width-small: 350px;
}
.o_employee_form {
.o_employee_avatar {
position: absolute;
top: 60px;
right: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
.o_employee_availability {
position: absolute;
top: -5px;
right: -5px;
padding-bottom: 1px;
border-radius: 50%;
background-color: $o-view-background-color;
height: 1rem;
width: 1rem;
* {
margin-bottom: -1px;
height: 1rem;
width: 1rem;
}
}
}
.oe_title {
max-width: 75%;
}
}
.o_hr_narrow_field {
width: 8rem!important;
max-width: 8rem!important;
* {
max-width: 100%;
}
}
@for $size from 10 through 15 {
.o_hr_narrow_field-#{$size} {
width: #{$size}rem!important;
max-width: #{$size}rem!important;
}
}

View file

@ -0,0 +1,3 @@
input#hr_presence_control_email_amount {
max-width: 5rem;
}

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { preferencesItem } from "@web/webclient/user_menu/user_menu_items";
export function hrPreferencesItem(env) {
return Object.assign(
{},
preferencesItem(env),
{
description: env._t('My Profile'),
}
);
}
registry.category("user_menuitems").add('profile', hrPreferencesItem, { force: true })

View file

@ -0,0 +1,30 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
const { useComponent, useEnv } = owl;
export function useArchiveEmployee() {
const component = useComponent();
const env = useEnv();
const action = useService("action");
return (id) => {
action.doAction({
type: 'ir.actions.act_window',
name: env._t('Employee Termination'),
res_model: 'hr.departure.wizard',
views: [[false, 'form']],
view_mode: 'form',
target: 'new',
context: {
'active_id': id,
'toggle_active': true,
}
}, {
onClose: async () => {
await component.model.load();
component.model.notify();
},
});
}
}

View file

@ -0,0 +1,32 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2ManyTagsAvatarUserField, KanbanMany2ManyTagsAvatarUserField } from "@mail/views/fields/many2many_avatar_user_field/many2many_avatar_user_field";
export class Many2ManyTagsAvatarEmployeeField extends Many2ManyTagsAvatarUserField {
get relation() {
return "hr.employee.public";
}
}
Many2ManyTagsAvatarEmployeeField.extractProps = ({ field, attrs }) => {
return {
...Many2ManyTagsAvatarUserField.extractProps({ field, attrs }),
canQuickCreate: false,
relation: (attrs.options && attrs.options.relation) || field.relation,
}
};
Many2ManyTagsAvatarEmployeeField.additionalClasses = [...Many2ManyTagsAvatarUserField.additionalClasses, "o_field_many2many_avatar_user"];
registry.category("fields").add("many2many_avatar_employee", Many2ManyTagsAvatarEmployeeField);
export class KanbanMany2ManyTagsAvatarEmployeeField extends KanbanMany2ManyTagsAvatarUserField {
get relation() {
return "hr.employee.public";
}
}
KanbanMany2ManyTagsAvatarEmployeeField.additionalClasses = [...KanbanMany2ManyTagsAvatarUserField.additionalClasses, "o_field_many2many_avatar_user"];
registry.category("fields").add("kanban.many2many_avatar_employee", KanbanMany2ManyTagsAvatarEmployeeField);
registry.category("fields").add("list.many2many_avatar_employee", KanbanMany2ManyTagsAvatarEmployeeField);

View file

@ -0,0 +1,36 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2OneAvatarUserField, KanbanMany2OneAvatarUserField } from "@mail/views/fields/many2one_avatar_user_field/many2one_avatar_user_field";
export class Many2OneAvatarEmployeeField extends Many2OneAvatarUserField {
get relation() {
return "hr.employee.public";
}
}
Many2OneAvatarEmployeeField.extractProps = ({ field, attrs }) => {
return {
...Many2OneAvatarUserField.extractProps({ field, attrs }),
relation: (attrs.options && attrs.options.relation) || field.relation,
canQuickCreate: false,
}
};
Many2OneAvatarEmployeeField.additionalClasses = [...Many2OneAvatarUserField.additionalClasses, "o_field_many2one_avatar_user"];
registry.category("fields").add("many2one_avatar_employee", Many2OneAvatarEmployeeField);
export class KanbanMany2OneAvatarEmployeeField extends KanbanMany2OneAvatarUserField {
get relation() {
return "hr.employee.public";
}
}
KanbanMany2OneAvatarEmployeeField.extractProps = ({ attrs, field }) => {
return {
...KanbanMany2OneAvatarUserField.extractProps({ attrs, field }),
relation: (attrs.options && attrs.options.relation) || field.relation,
};
};
registry.category("fields").add("kanban.many2one_avatar_employee", KanbanMany2OneAvatarEmployeeField);

View file

@ -0,0 +1,44 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
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 { useArchiveEmployee } from '@hr/views/archive_employee_hook';
import { useOpenChat } from "@mail/views/open_chat_hook";
export class EmployeeFormController extends FormController {
setup() {
super.setup();
this.archiveEmployee = useArchiveEmployee();
}
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 = this.archiveEmployee.bind(this, this.model.root.resId);
}
return menuItems;
}
}
// TODO KBA: to remove in master
export class EmployeeFormRenderer extends FormRenderer {
setup() {
super.setup();
this.openChat = useOpenChat(this.props.record.resModel);
}
}
registry.category('views').add('hr_employee_form', {
...formView,
Controller: EmployeeFormController,
Renderer: EmployeeFormRenderer,
});

View file

@ -0,0 +1,28 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { kanbanView } from '@web/views/kanban/kanban_view';
import { KanbanModel } from '@web/views/kanban/kanban_model';
// TODO KBA: to remove in master
export class EmployeeKanbanRecord extends KanbanModel.Record {
async openChat(employeeId) {
const messaging = await this.model.env.services.messaging.get();
messaging.openChat({ employeeId });
}
}
export class EmployeeKanbanModel extends KanbanModel {
setup(params, { messaging }) {
super.setup(...arguments);
this.messagingService = messaging;
}
}
EmployeeKanbanModel.services = [...KanbanModel.services, "messaging"];
EmployeeKanbanModel.Record = EmployeeKanbanRecord;
registry.category('views').add('hr_employee_kanban', {
...kanbanView,
Model: EmployeeKanbanModel,
});

View file

@ -0,0 +1,36 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { listView } from '@web/views/list/list_view';
import { ListController } from '@web/views/list/list_controller';
import { useArchiveEmployee } from '@hr/views/archive_employee_hook';
export class EmployeeListController extends ListController {
setup() {
super.setup();
this.archiveEmployee = useArchiveEmployee();
}
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 = this.archiveEmployee.bind(this, selectedRecords[0].resId);
}
return menuItems;
}
}
registry.category('views').add('hr_employee_list', {
...listView,
Controller: EmployeeListController,
});

View file

@ -0,0 +1,14 @@
/** @odoo-module **/
import { helpers } from "@mail/views/open_chat_hook";
import { patch } from "@web/core/utils/patch";
patch(helpers, "hr_m2x_avatar_employee", {
SUPPORTED_M2X_AVATAR_MODELS: [...helpers.SUPPORTED_M2X_AVATAR_MODELS, "hr.employee", "hr.employee.public"],
buildOpenChatParams: function (resModel, id) {
if (["hr.employee", "hr.employee.public"].includes(resModel)) {
return { employeeId: id };
}
return this._super(...arguments);
},
});

View file

@ -0,0 +1,25 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { Record, RelationalModel } from "@web/views/basic_relational_model";
export class EmployeeProfileRecord extends Record {
async save() {
const dirtyFields = this.dirtyFields.map((f) => f.name);
const isSaved = await super.save(...arguments);
if (isSaved && dirtyFields.includes("lang")) {
this.model.actionService.doAction("reload_context");
}
return isSaved;
}
}
class EmployeeProfileModel extends RelationalModel {}
EmployeeProfileModel.Record = EmployeeProfileRecord;
registry.category("views").add("hr_employee_profile_form", {
...formView,
Model: EmployeeProfileModel,
});

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr.WorkPermitUpload" t-inherit="web.FieldBinaryFile" t-inherit-mode="primary">
<xpath expr="//button[@title='Select']" position="attributes">
<attribute name="class" remove="btn-primary" add="btn-secondary" separator=" "/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,10 @@
/** @odoo-module **/
import { addFakeModel, addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(['hr.employee.public']);
addFakeModel('m2x.avatar.employee', {
employee_id: { string: "Employee", type: 'many2one', relation: 'hr.employee.public' },
employee_ids: { string: "Employees", type: "many2many", relation: 'hr.employee.public' },
});

View file

@ -0,0 +1,182 @@
/** @odoo-module **/
import {
start,
startServer,
} from '@mail/../tests/helpers/test_utils';
import { makeFakeNotificationService } from "@web/../tests/helpers/mock_services";
import { Many2OneAvatarEmployee } from '@hr/js/m2x_avatar_employee';
import { dom } from 'web.test_utils';
QUnit.module('hr', {}, function () {
QUnit.module('M2XAvatarEmployeeLegacy', {
beforeEach() {
Many2OneAvatarEmployee.prototype.partnerIds = {};
},
});
QUnit.test('many2one_avatar_employee: click on an employee not associated with a user', async function (assert) {
assert.expect(6);
const pyEnv = await startServer();
const hrEmployeePublicId1 = pyEnv['hr.employee.public'].create({ name: 'Mario' });
const m2xHrAvatarUserId1 = pyEnv['m2x.avatar.employee'].create({ employee_id: hrEmployeePublicId1 });
const views = {
'm2x.avatar.employee,false,form': '<form js_class="legacy_form"><field name="employee_id" widget="many2one_avatar_employee"/></form>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
services: {
notification: makeFakeNotificationService(message => {
assert.ok(
true,
"should display a toast notification after failing to open chat"
);
assert.strictEqual(
message,
"You can only chat with employees that have a dedicated user.",
"should display the correct information in the notification"
);
}),
},
});
await openView({
res_model: 'm2x.avatar.employee',
res_id: m2xHrAvatarUserId1,
views: [[false, 'form']],
});
assert.strictEqual(document.querySelector('.o_field_widget[name=employee_id]').innerText.trim(), 'Mario');
await dom.click(document.querySelector('.o_m2o_avatar > img'));
assert.verifySteps([
`read m2x.avatar.employee ${m2xHrAvatarUserId1}`,
`read hr.employee.public ${hrEmployeePublicId1}`,
]);
});
QUnit.test('many2many_avatar_employee widget in form view', async function (assert) {
assert.expect(8);
const pyEnv = await startServer();
const [resPartnerId1, resPartnerId2] = pyEnv['res.partner'].create([{}, {}]);
const [resUsersId1, resUsersId2] = pyEnv['res.users'].create([{}, {}]);
const [hrEmployeePublicId1, hrEmployeePublicId2] = pyEnv['hr.employee.public'].create([
{ user_id: resUsersId1, user_partner_id: resPartnerId1 },
{ user_id: resUsersId2, user_partner_id: resPartnerId2 },
]);
const m2xAvatarEmployeeId1 = pyEnv['m2x.avatar.employee'].create(
{ employee_ids: [hrEmployeePublicId1, hrEmployeePublicId2] },
);
const views = {
'm2x.avatar.employee,false,form': '<form js_class="legacy_form"><field name="employee_ids" widget="many2many_avatar_employee"/></form>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
});
await openView({
res_model: 'm2x.avatar.employee',
res_id: m2xAvatarEmployeeId1,
views: [[false, 'form']],
});
assert.containsN(document.body, '.o_field_many2manytags.avatar.o_field_widget .badge', 2,
"should have 2 records");
assert.strictEqual(document.querySelector('.o_field_many2manytags.avatar.o_field_widget .badge img').getAttribute('data-src'),
`/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`,
"should have correct avatar image");
await dom.click(document.querySelector('.o_field_many2manytags.avatar .badge .o_m2m_avatar'));
await dom.click(document.querySelectorAll('.o_field_many2manytags.avatar .badge .o_m2m_avatar')[1]);
assert.verifySteps([
`read m2x.avatar.employee ${m2xAvatarEmployeeId1}`,
`read hr.employee.public ${hrEmployeePublicId1},${hrEmployeePublicId2}`,
`read hr.employee.public ${hrEmployeePublicId1}`,
`read hr.employee.public ${hrEmployeePublicId2}`,
]);
assert.containsN(
document.body,
'.o_ChatWindowHeader_name',
2,
"should have 2 chat windows"
);
});
QUnit.test('many2many_avatar_employee: click on an employee not associated with a user', async function (assert) {
assert.expect(10);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create({});
const resUsersId1 = pyEnv['res.users'].create({});
const [hrEmployeePublicId1, hrEmployeePublicId2] = pyEnv['hr.employee.public'].create([
{},
{ user_id: resUsersId1, user_partner_id: resPartnerId1 },
]);
const m2xAvatarEmployeeId1 = pyEnv['m2x.avatar.employee'].create(
{ employee_ids: [hrEmployeePublicId1, hrEmployeePublicId2] },
);
const views = {
'm2x.avatar.employee,false,form': '<form js_class="legacy_form"><field name="employee_ids" widget="many2many_avatar_employee"/></form>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
services: {
notification: makeFakeNotificationService(message => {
assert.ok(
true,
"should display a toast notification after failing to open chat"
);
assert.strictEqual(
message,
"You can only chat with employees that have a dedicated user.",
"should display the correct information in the notification"
);
}),
},
});
await openView({
res_model: 'm2x.avatar.employee',
res_id: m2xAvatarEmployeeId1,
views: [[false, 'form']],
});
assert.containsN(document.body, '.o_field_many2manytags.avatar.o_field_widget .badge', 2,
"should have 2 records");
assert.strictEqual(document.querySelector('.o_field_many2manytags.avatar.o_field_widget .badge img').getAttribute('data-src'),
`/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`,
"should have correct avatar image");
await dom.click(document.querySelector('.o_field_many2manytags.avatar .badge .o_m2m_avatar'));
await dom.click(document.querySelectorAll('.o_field_many2manytags.avatar .badge .o_m2m_avatar')[1]);
assert.verifySteps([
`read m2x.avatar.employee ${hrEmployeePublicId1}`,
`read hr.employee.public ${hrEmployeePublicId1},${hrEmployeePublicId2}`,
`read hr.employee.public ${hrEmployeePublicId1}`,
`read hr.employee.public ${hrEmployeePublicId2}`
]);
assert.containsOnce(document.body, '.o_ChatWindowHeader_name',
"should have 1 chat window");
});
});

View file

@ -0,0 +1,450 @@
/** @odoo-module **/
import {
afterNextRender,
start,
startServer,
} from '@mail/../tests/helpers/test_utils';
import { makeFakeNotificationService } from "@web/../tests/helpers/mock_services";
import { Many2OneAvatarEmployee } from '@hr/js/m2x_avatar_employee';
import { dom } from 'web.test_utils';
QUnit.module('hr', {}, function () {
QUnit.module('M2XAvatarEmployee', {
beforeEach() {
Many2OneAvatarEmployee.prototype.partnerIds = {};
},
});
QUnit.test('many2one_avatar_employee widget in list view', async function (assert) {
assert.expect(13);
const pyEnv = await startServer();
const [resPartnerId1, resPartnerId2] = pyEnv['res.partner'].create([
{ display_name: "Mario" },
{ display_name: "Luigi" },
]);
const [resUsersId1, resUsersId2] = pyEnv['res.users'].create([
{ partner_id: resPartnerId1 },
{ partner_id: resPartnerId2 },
]);
const [hrEmployeePublicId1, hrEmployeePublicId2] = pyEnv['hr.employee.public'].create([
{ name: "Mario", user_id: resUsersId1, user_partner_id: resPartnerId1 },
{ name: "Luigi", user_id: resUsersId2, user_partner_id: resPartnerId2 },
]);
pyEnv['m2x.avatar.employee'].create([
{ employee_id: hrEmployeePublicId1, employee_ids: [hrEmployeePublicId1, hrEmployeePublicId2] },
{ employee_id: hrEmployeePublicId2 },
{ employee_id: hrEmployeePublicId1 },
]);
const views = {
'm2x.avatar.employee,false,list': '<tree><field name="employee_id" widget="many2one_avatar_employee"/></tree>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
});
await openView({
res_model: 'm2x.avatar.employee',
views: [[false, 'list']],
});
assert.strictEqual(document.querySelector('.o_data_cell span:not(.o_m2o_avatar) span').innerText, 'Mario');
assert.strictEqual(document.querySelectorAll('.o_data_cell span:not(.o_m2o_avatar) span')[1].innerText, 'Luigi');
assert.strictEqual(document.querySelectorAll('.o_data_cell span:not(.o_m2o_avatar) span')[2].innerText, 'Mario');
// click on first employee
await afterNextRender(() =>
dom.click(document.querySelector('.o_data_cell .o_m2o_avatar > img'))
);
assert.verifySteps(
[`read hr.employee.public ${hrEmployeePublicId1}`],
"first employee should have been read to find its partner"
);
assert.containsOnce(
document.body,
'.o_ChatWindowHeader_name',
'should have opened chat window'
);
assert.strictEqual(
document.querySelector('.o_ChatWindowHeader_name').textContent,
"Mario",
'chat window should be with clicked employee'
);
// click on second employee
await afterNextRender(() =>
dom.click(document.querySelectorAll('.o_data_cell .o_m2o_avatar > img')[1]
));
assert.verifySteps(
[`read hr.employee.public ${hrEmployeePublicId2}`],
"second employee should have been read to find its partner"
);
assert.containsN(
document.body,
'.o_ChatWindowHeader_name',
2,
'should have opened second chat window'
);
assert.strictEqual(
document.querySelectorAll('.o_ChatWindowHeader_name')[1].textContent,
"Luigi",
'chat window should be with clicked employee'
);
// click on third employee (same as first)
await afterNextRender(() =>
dom.click(document.querySelectorAll('.o_data_cell .o_m2o_avatar > img')[2])
);
assert.verifySteps(
[],
"employee should not have been read again because we already know its partner"
);
assert.containsN(
document.body,
'.o_ChatWindowHeader_name',
2,
"should still have only 2 chat windows because third is the same partner as first"
);
});
QUnit.test('many2one_avatar_employee widget in kanban view', async function (assert) {
assert.expect(3);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create({});
const resUsersId1 = pyEnv['res.users'].create({ partner_id: resPartnerId1 });
const hrEmployeePublicId1 = pyEnv['hr.employee.public'].create({ user_id: resUsersId1, user_partner_id: resPartnerId1 });
pyEnv['m2x.avatar.employee'].create({ employee_id: hrEmployeePublicId1, employee_ids: [hrEmployeePublicId1] });
const views = {
'm2x.avatar.employee,false,kanban':
`<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="employee_id" widget="many2one_avatar_employee"/>
</div>
</t>
</templates>
</kanban>`,
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'm2x.avatar.employee',
views: [[false, 'kanban']],
});
assert.strictEqual(document.querySelector('.o_kanban_record').innerText.trim(), '');
assert.containsOnce(document.body, '.o_m2o_avatar');
assert.strictEqual(document.querySelector('.o_m2o_avatar > img').getAttribute('data-src'), `/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`);
});
QUnit.test('many2one_avatar_employee: click on an employee not associated with a user', async function (assert) {
assert.expect(6);
const pyEnv = await startServer();
const hrEmployeePublicId1 = pyEnv['hr.employee.public'].create({ name: 'Mario' });
const m2xHrAvatarUserId1 = pyEnv['m2x.avatar.employee'].create({ employee_id: hrEmployeePublicId1 });
const views = {
'm2x.avatar.employee,false,form': '<form><field name="employee_id" widget="many2one_avatar_employee"/></form>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
services: {
notification: makeFakeNotificationService(message => {
assert.ok(
true,
"should display a toast notification after failing to open chat"
);
assert.strictEqual(
message,
"You can only chat with employees that have a dedicated user.",
"should display the correct information in the notification"
);
}),
},
});
await openView({
res_model: 'm2x.avatar.employee',
res_id: m2xHrAvatarUserId1,
views: [[false, 'form']],
});
assert.strictEqual(document.querySelector('.o_field_widget[name=employee_id] input').value.trim(), 'Mario');
await dom.click(document.querySelector('.o_m2o_avatar > img'));
assert.verifySteps([
`read m2x.avatar.employee ${m2xHrAvatarUserId1}`,
`read hr.employee.public ${hrEmployeePublicId1}`,
]);
});
QUnit.test('many2many_avatar_employee widget in form view', async function (assert) {
assert.expect(8);
const pyEnv = await startServer();
const [resPartnerId1, resPartnerId2] = pyEnv['res.partner'].create([{}, {}]);
const [resUsersId1, resUsersId2] = pyEnv['res.users'].create([{}, {}]);
const [hrEmployeePublicId1, hrEmployeePublicId2] = pyEnv['hr.employee.public'].create([
{ user_id: resUsersId1, user_partner_id: resPartnerId1 },
{ user_id: resUsersId2, user_partner_id: resPartnerId2 },
]);
const m2xAvatarEmployeeId1 = pyEnv['m2x.avatar.employee'].create(
{ employee_ids: [hrEmployeePublicId1, hrEmployeePublicId2] },
);
const views = {
'm2x.avatar.employee,false,form': '<form><field name="employee_ids" widget="many2many_avatar_employee"/></form>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
});
await openView({
res_model: 'm2x.avatar.employee',
res_id: m2xAvatarEmployeeId1,
views: [[false, 'form']],
});
assert.containsN(document.body, '.o_field_many2many_avatar_employee .badge', 2,
"should have 2 records");
assert.strictEqual(document.querySelector('.o_field_many2many_avatar_employee .badge img').getAttribute('data-src'),
`/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`,
"should have correct avatar image");
await dom.click(document.querySelector('.o_field_many2many_avatar_employee .badge .o_m2m_avatar'));
await dom.click(document.querySelectorAll('.o_field_many2many_avatar_employee .badge .o_m2m_avatar')[1]);
assert.verifySteps([
`read m2x.avatar.employee ${m2xAvatarEmployeeId1}`,
`read hr.employee.public ${hrEmployeePublicId1},${hrEmployeePublicId2}`,
`read hr.employee.public ${hrEmployeePublicId1}`,
`read hr.employee.public ${hrEmployeePublicId2}`,
]);
assert.containsN(
document.body,
'.o_ChatWindowHeader_name',
2,
"should have 2 chat windows"
);
});
QUnit.test('many2many_avatar_employee widget in list view', async function (assert) {
assert.expect(10);
const pyEnv = await startServer();
const [resPartnerId1, resPartnerId2] = pyEnv['res.partner'].create([
{ name: "Mario" },
{ name: "Yoshi" },
]);
const [resUsersId1, resUsersId2] = pyEnv['res.users'].create([{}, {}]);
const [hrEmployeePublicId1, hrEmployeePublicId2] = pyEnv['hr.employee.public'].create([
{ user_id: resUsersId1, user_partner_id: resPartnerId1 },
{ user_id: resUsersId2, user_partner_id: resPartnerId2 },
]);
pyEnv['m2x.avatar.employee'].create(
{ employee_ids: [hrEmployeePublicId1, hrEmployeePublicId2] },
);
const views = {
'm2x.avatar.employee,false,list': '<tree><field name="employee_ids" widget="many2many_avatar_employee"/></tree>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
});
await openView({
res_model: 'm2x.avatar.employee',
views: [[false, 'list']],
});
assert.containsN(document.body, '.o_data_cell:first .o_field_many2many_avatar_employee > div > span', 2,
"should have two avatar");
// click on first employee badge
await afterNextRender(() =>
dom.click(document.querySelector('.o_data_cell .o_m2m_avatar'))
);
assert.verifySteps(
[`read hr.employee.public ${hrEmployeePublicId1},${hrEmployeePublicId2}`, `read hr.employee.public ${hrEmployeePublicId1}`],
"first employee should have been read to find its partner"
);
assert.containsOnce(
document.body,
'.o_ChatWindowHeader_name',
'should have opened chat window'
);
assert.strictEqual(
document.querySelector('.o_ChatWindowHeader_name').textContent,
"Mario",
'chat window should be with clicked employee'
);
// click on second employee
await afterNextRender(() =>
dom.click(document.querySelectorAll('.o_data_cell .o_m2m_avatar')[1])
);
assert.verifySteps(
[`read hr.employee.public ${hrEmployeePublicId2}`],
"second employee should have been read to find its partner"
);
assert.containsN(
document.body,
'.o_ChatWindowHeader_name',
2,
'should have opened second chat window'
);
assert.strictEqual(
document.querySelectorAll('.o_ChatWindowHeader_name')[1].textContent,
"Yoshi",
'chat window should be with clicked employee'
);
});
QUnit.test('many2many_avatar_employee widget in kanban view', async function (assert) {
assert.expect(7);
const pyEnv = await startServer();
const [resPartnerId1, resPartnerId2] = pyEnv['res.partner'].create([{}, {}]);
const [resUsersId1, resUsersId2] = pyEnv['res.users'].create([{}, {}]);
const [hrEmployeePublicId1, hrEmployeePublicId2] = pyEnv['hr.employee.public'].create([
{ user_id: resUsersId1, user_partner_id: resPartnerId1 },
{ user_id: resUsersId2, user_partner_id: resPartnerId2 },
]);
pyEnv['m2x.avatar.employee'].create(
{ employee_ids: [hrEmployeePublicId1, hrEmployeePublicId2] },
);
const views = {
'm2x.avatar.employee,false,kanban':
`<kanban>
<templates>
<t t-name="kanban-box">
<div>
<div class="oe_kanban_footer">
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_right">
<field name="employee_ids" widget="many2many_avatar_employee"/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>`,
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
});
await openView({
res_model: 'm2x.avatar.employee',
views: [[false, 'kanban']],
});
assert.containsN(document.body, '.o_kanban_record:first .o_field_many2many_avatar_employee img.o_m2m_avatar', 2,
"should have 2 avatar images");
assert.strictEqual(document.querySelector('.o_kanban_record .o_field_many2many_avatar_employee img.o_m2m_avatar').getAttribute('data-src'),
`/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`,
"should have correct avatar image");
assert.strictEqual(document.querySelectorAll('.o_kanban_record .o_field_many2many_avatar_employee img.o_m2m_avatar')[1].getAttribute('data-src'),
`/web/image/hr.employee.public/${hrEmployeePublicId2}/avatar_128`,
"should have correct avatar image");
await dom.click(document.querySelector('.o_kanban_record .o_m2m_avatar'));
await dom.click(document.querySelectorAll('.o_kanban_record .o_m2m_avatar')[1]);
assert.verifySteps([
`read hr.employee.public ${hrEmployeePublicId1},${hrEmployeePublicId2}`,
`read hr.employee.public ${hrEmployeePublicId1}`,
`read hr.employee.public ${hrEmployeePublicId2}`
]);
});
QUnit.test('many2many_avatar_employee: click on an employee not associated with a user', async function (assert) {
assert.expect(10);
const pyEnv = await startServer();
const resPartnerId1 = pyEnv['res.partner'].create({});
const resUsersId1 = pyEnv['res.users'].create({});
const [hrEmployeePublicId1, hrEmployeePublicId2] = pyEnv['hr.employee.public'].create([
{},
{ user_id: resUsersId1, user_partner_id: resPartnerId1 },
]);
const m2xAvatarEmployeeId1 = pyEnv['m2x.avatar.employee'].create(
{ employee_ids: [hrEmployeePublicId1, hrEmployeePublicId2] },
);
const views = {
'm2x.avatar.employee,false,form': '<form><field name="employee_ids" widget="many2many_avatar_employee"/></form>',
};
const { openView } = await start({
mockRPC(route, args) {
if (args.method === 'read') {
assert.step(`read ${args.model} ${args.args[0]}`);
}
},
serverData: { views },
services: {
notification: makeFakeNotificationService(message => {
assert.ok(
true,
"should display a toast notification after failing to open chat"
);
assert.strictEqual(
message,
"You can only chat with employees that have a dedicated user.",
"should display the correct information in the notification"
);
}),
},
});
await openView({
res_model: 'm2x.avatar.employee',
res_id: m2xAvatarEmployeeId1,
views: [[false, 'form']],
});
assert.containsN(document.body, '.o_field_many2many_avatar_employee .badge', 2,
"should have 2 records");
assert.strictEqual(document.querySelector('.o_field_many2many_avatar_employee .badge img').getAttribute('data-src'),
`/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`,
"should have correct avatar image");
await dom.click(document.querySelector('.o_field_many2many_avatar_employee .badge .o_m2m_avatar'));
await dom.click(document.querySelectorAll('.o_field_many2many_avatar_employee .badge .o_m2m_avatar')[1]);
assert.verifySteps([
`read m2x.avatar.employee ${hrEmployeePublicId1}`,
`read hr.employee.public ${hrEmployeePublicId1},${hrEmployeePublicId2}`,
`read hr.employee.public ${hrEmployeePublicId1}`,
`read hr.employee.public ${hrEmployeePublicId2}`
]);
assert.containsOnce(document.body, '.o_ChatWindowHeader_name',
"should have 1 chat window");
});
});

View file

@ -0,0 +1,131 @@
/** @odoo-module **/
import AbstractRendererOwl from 'web.AbstractRendererOwl';
import BasicView from "web.BasicView";
import BasicRenderer from "web.BasicRenderer";
import RendererWrapper from 'web.RendererWrapper';
import { createView } from 'web.test_utils';
import StandaloneM2OAvatarEmployee from '@hr/js/standalone_m2o_avatar_employee';
const { xml } = owl;
function getHtmlRenderer(html) {
return BasicRenderer.extend({
start: function () {
this.$el.html(html);
return this._super.apply(this, arguments);
}
});
}
function getOwlView(owlRenderer, viewType) {
viewType = viewType || "test";
return BasicView.extend({
viewType: viewType,
config: Object.assign({}, BasicView.prototype.config, {
Renderer: owlRenderer,
}),
getRenderer() {
return new RendererWrapper(null, this.config.Renderer, {});
}
});
}
function getHtmlView(html, viewType) {
viewType = viewType || "test";
return BasicView.extend({
viewType: viewType,
config: Object.assign({}, BasicView.prototype.config, {
Renderer: getHtmlRenderer(html)
})
});
}
QUnit.module('hr', {}, function () {
QUnit.module('StandaloneM2OEmployeeTests', {
beforeEach: function () {
this.data = {
'foo': {
fields: {
employee_id: {string: "Employee", type: 'many2one', relation: 'hr.employee'},
},
records: [],
},
'hr.employee': {
fields: {},
records: [
{id: 10, name: "Mario"},
{id: 20, name: "Luigi"},
{id: 30, name: "Yoshi"}
],
},
'hr.employee.public': {
fields: {},
records: [
{id: 10, name: "Mario"},
{id: 20, name: "Luigi"},
{id: 30, name: "Yoshi"}
],
},
};
},
});
QUnit.test('standalone_m2o_avatar_employee: legacy view', async function (assert) {
assert.expect(1);
const html = "<div class='coucou_test'></div>";
const view = await createView({
View: getHtmlView(html, "test"),
data: this.data,
model: "foo",
arch: "<test/>"
});
const avatar10 = new StandaloneM2OAvatarEmployee(view, 10);
const avatar20 = new StandaloneM2OAvatarEmployee(view, 20);
const avatar30 = new StandaloneM2OAvatarEmployee(view, [30, 'Bowser']);
await avatar10.appendTo(view.el.querySelector('.coucou_test'));
await avatar20.appendTo(view.el.querySelector('.coucou_test'));
await avatar30.appendTo(view.el.querySelector('.coucou_test'));
assert.deepEqual(
[...view.el.querySelectorAll('.o_field_many2one_avatar span')].map(el => el.innerText),
["Mario", "Luigi", "Bowser"]
);
view.destroy();
});
QUnit.test('standalone_m2o_avatar_employee: Owl view', async function (assert) {
assert.expect(1);
class Renderer extends AbstractRendererOwl { }
Renderer.template = xml`<div class='coucou_test'></div>`;
const view = await createView({
View: getOwlView(Renderer, "test"),
data: this.data,
model: "foo",
arch: "<test/>"
});
const avatar10 = new StandaloneM2OAvatarEmployee(view, 10);
const avatar20 = new StandaloneM2OAvatarEmployee(view, 20);
const avatar30 = new StandaloneM2OAvatarEmployee(view, [30, 'Bowser']);
await avatar10.appendTo(view.el.querySelector('.coucou_test'));
await avatar20.appendTo(view.el.querySelector('.coucou_test'));
await avatar30.appendTo(view.el.querySelector('.coucou_test'));
assert.deepEqual(
[...view.el.querySelectorAll('.o_field_many2one_avatar span')].map(el => el.innerText),
["Mario", "Luigi", "Bowser"]
);
view.destroy();
});
});

View file

@ -0,0 +1,29 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('hr_employee_tour', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(),
{
content: "Open Employees app",
trigger: ".o_app[data-menu-xmlid='hr.menu_hr_root']",
run: 'click',
},
{
content: "Open an Employee Profile",
trigger: ".o_kanban_record_title:contains('Johnny H.')",
run: 'click',
},
{
content: "Open user account menu",
trigger: ".o_user_menu .oe_topbar_name",
run: 'click',
}, {
content: "Open My Profile",
trigger: "[data-menu=settings]",
run: 'click',
},
]);

View file

@ -0,0 +1,19 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
/**
* As 'hr' changes the flow a bit and displays the user preferences form in a full view instead of
* a modal, we adapt the steps of the original tour accordingly.
*/
tour.tours['mail/static/tests/tours/user_modify_own_profile_tour.js'].steps = [{
content: 'Open user account menu',
trigger: '.o_user_menu button',
}, {
content: "Open preferences / profile screen",
trigger: '[data-menu=settings]',
}, {
content: "Update the email address",
trigger: 'div[name="email"] input',
run: 'text updatedemail@example.com',
}, ...tour.stepUtils.saveForm()];

Binary file not shown.