Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,122 @@
/** @odoo-module **/
import { useCommand } from "@web/core/commands/command_hook";
import { useService } from "@web/core/utils/hooks";
import { Domain } from "@web/core/domain";
const { useComponent, useEnv } = owl;
/**
* Use this hook to add "Assign to.." and "Assign/Unassign me" to the command palette.
*/
export function useAssignUserCommand() {
const component = useComponent();
const env = useEnv();
const orm = useService("orm");
const user = useService("user");
if (
component.props.relation !== "res.users" ||
component.props.record.activeFields[component.props.name].viewType !== "form"
) {
return;
}
const getCurrentIds = () => {
if (component.props.type === "many2one" && component.props.value) {
return [component.props.value[0]];
} else if (component.props.type === "many2many") {
return component.props.value.currentIds;
}
return [];
};
const add = async (record) => {
if (component.props.type === "many2one") {
component.props.update(record);
} else if (component.props.type === "many2many") {
component.props.update({
operation: "REPLACE_WITH",
resIds: [...getCurrentIds(), record[0]],
});
}
};
const remove = async (record) => {
if (component.props.type === "many2one") {
component.props.update([]);
} else if (component.props.type === "many2many") {
component.props.update({
operation: "REPLACE_WITH",
resIds: getCurrentIds().filter((id) => id !== record[0]),
});
}
};
const provide = async (env, options) => {
const value = options.searchValue.trim();
let domain = component.props.record.getFieldDomain(component.props.name);
const context = component.props.record.getFieldContext(component.props.name);
if (component.props.type === "many2many") {
const selectedUserIds = getCurrentIds();
if (selectedUserIds.length) {
domain = Domain.and([domain, [["id", "not in", selectedUserIds]]]);
}
}
if (component._pendingRpc) {
component._pendingRpc.abort(false);
}
component._pendingRpc = orm.call(component.props.relation, "name_search", [], {
name: value,
args: domain.toList(),
operator: "ilike",
limit: 80,
context,
});
const searchResult = await component._pendingRpc;
component._pendingRpc = null;
return searchResult.map((record) => ({
name: record[1],
action: add.bind(null, record),
}));
};
useCommand(
env._t("Assign to ..."),
() => ({
configByNameSpace: {
default: {
emptyMessage: env._t("No users found"),
},
},
placeholder: env._t("Select a user..."),
providers: [
{
provide,
},
],
}),
{
category: "smart_action",
hotkey: "alt+i",
global: true,
}
);
useCommand(
env._t("Assign/Unassign to me"),
() => {
const record = [user.userId, user.name];
if (getCurrentIds().includes(user.userId)) {
remove(record);
} else {
add(record);
}
},
{
category: "smart_action",
hotkey: "alt+shift+i",
global: true,
}
);
}

View file

@ -0,0 +1,31 @@
/** @odoo-module **/
import { CharField } from "@web/views/fields/char/char_field";
import { patch } from "@web/core/utils/patch";
import MailEmojisMixin from '@mail/js/emojis_mixin';
import { EmojisDropdown } from '@mail/js/emojis_dropdown';
import { EmojisFieldCommon } from '@mail/views/fields/emojis_field_common';
import { registry } from "@web/core/registry";
const { useRef } = owl;
/**
* Extension of the FieldChar that will add emojis support
*/
export class EmojisCharField extends CharField {
setup() {
super.setup();
this.targetEditElement = useRef('input');
this._setupOverride();
}
};
EmojisCharField.extractProps = ({ attrs, field }) => {
return {...CharField.extractProps({attrs, field}), shouldTrim: false};
};
patch(EmojisCharField.prototype, 'emojis_char_field_mail_mixin', MailEmojisMixin);
patch(EmojisCharField.prototype, 'emojis_char_field_field_mixin', EmojisFieldCommon);
EmojisCharField.template = 'mail.EmojisCharField';
EmojisCharField.components = { ...CharField.components, EmojisDropdown };
EmojisCharField.additionalClasses = [...(CharField.additionalClasses || []), 'o_field_text'];
registry.category("fields").add("char_emojis", EmojisCharField);

View file

@ -0,0 +1,3 @@
.o_field_widget.o_field_char_emojis input {
padding-right: 40px; // Avoid overlapping sub
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.EmojisCharField" t-inherit="web.CharField" t-inherit-mode="primary" owl="1">
<xpath expr="//span[1]" position="attributes">
<attribute name="t-ref">targetReadonlyElement</attribute>
</xpath>
<xpath expr="/*[last()]/*[last()]" position="after">
<EmojisDropdown onEmojiClick="onEmojiClick" readonly="props.readonly" type="'char'"/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,39 @@
/** @odoo-module **/
import MailEmojisMixin from '@mail/js/emojis_mixin';
const _onEmojiClickMixin = MailEmojisMixin.onEmojiClick;
const { useRef, onMounted } = owl;
/*
* Common code for EmojisTextField and EmojisCharField
*/
export const EmojisFieldCommon = {
_setupOverride() {
this.targetReadonlyElement = useRef('targetReadonlyElement');
this.emojisDropdown = useRef('emojisDropdown');
if (this.props.readonly) {
onMounted(() => {
this.targetReadonlyElement.el.innerHTML = this._formatText(this.targetReadonlyElement.el.textContent);
});
}
this.onEmojiClick = this._onEmojiClick.bind(this);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_onEmojiClick() {
_onEmojiClickMixin.apply(this, arguments);
this.props.update(this._getTargetTextElement().value);
},
/**
* Used by MailEmojisMixin, check its document for more info.
*
* @private
*/
_getTargetTextElement() {
return this.props.readonly ? this.targetReadonlyElement.el : this.targetEditElement.el;
},
};

View file

@ -0,0 +1,26 @@
/** @odoo-module **/
import { TextField } from "@web/views/fields/text/text_field";
import { patch } from "@web/core/utils/patch";
import MailEmojisMixin from '@mail/js/emojis_mixin';
import { EmojisDropdown } from '@mail/js/emojis_dropdown';
import { EmojisFieldCommon } from '@mail/views/fields/emojis_field_common';
import { registry } from "@web/core/registry";
/**
* Extension of the FieldText that will add emojis support
*/
export class EmojisTextField extends TextField {
setup() {
super.setup();
this.targetEditElement = this.textareaRef;
this._setupOverride();
}
};
patch(EmojisTextField.prototype, 'emojis_text_field_mail_mixin', MailEmojisMixin);
patch(EmojisTextField.prototype, 'emojis_text_field_field_mixin', EmojisFieldCommon);
EmojisTextField.template = 'mail.EmojisTextField';
EmojisTextField.components = { ...TextField.components, EmojisDropdown };
EmojisTextField.additionalClasses = [...(TextField.additionalClasses || []), 'o_field_text'];
registry.category("fields").add("text_emojis", EmojisTextField);

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.EmojisTextField" t-inherit="web.TextField" t-inherit-mode="primary" owl="1">
<xpath expr="//span[1]" position="attributes">
<attribute name="t-ref">targetReadonlyElement</attribute>
</xpath>
<xpath expr="/*[last()]/*[last()]" position="after">
<EmojisDropdown onEmojiClick="onEmojiClick" type="'text'"
enable_emojis="props.enable_emojis" readonly="props.readonly"/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,69 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
import {
Many2ManyTagsAvatarField,
ListKanbanMany2ManyTagsAvatarField,
} from "@web/views/fields/many2many_tags_avatar/many2many_tags_avatar_field";
import { useOpenChat } from "@mail/views/open_chat_hook";
import { useAssignUserCommand } from "@mail/views/fields/assign_user_command_hook";
export class Many2ManyAvatarUserTagsList extends TagsList {}
Many2ManyAvatarUserTagsList.template = "mail.Many2ManyAvatarUserTagsList";
export class Many2ManyTagsAvatarUserField extends Many2ManyTagsAvatarField {
setup() {
super.setup();
this.openChat = useOpenChat(this.props.relation);
useAssignUserCommand();
}
get tags() {
return super.tags.map((tag) => ({
...tag,
onImageClicked: () => {
this.openChat(tag.resId);
},
}));
}
}
Many2ManyTagsAvatarUserField.components = {
...Many2ManyTagsAvatarField.components,
TagsList: Many2ManyAvatarUserTagsList,
};
Many2ManyTagsAvatarUserField.additionalClasses = ["o_field_many2many_tags_avatar"];
registry.category("fields").add("many2many_avatar_user", Many2ManyTagsAvatarUserField);
export class KanbanMany2ManyTagsAvatarUserField extends ListKanbanMany2ManyTagsAvatarField {
setup() {
super.setup();
this.openChat = useOpenChat(this.props.relation);
useAssignUserCommand();
}
get displayText() {
const isList = this.props.record.activeFields[this.props.name].viewType === "list";
return (isList && this.props.value.records.length === 1) || !this.props.readonly;
}
get tags() {
const recordFromId = (id) => this.props.value.records.find((rec) => rec.id === id);
return super.tags.map((tag) => ({
...tag,
onImageClicked: () => {
this.openChat(recordFromId(tag.id).resId);
},
}));
}
}
KanbanMany2ManyTagsAvatarUserField.template = "mail.KanbanMany2ManyTagsAvatarUserField";
KanbanMany2ManyTagsAvatarUserField.components = {
...ListKanbanMany2ManyTagsAvatarField.components,
TagsList: Many2ManyAvatarUserTagsList,
};
KanbanMany2ManyTagsAvatarUserField.additionalClasses = ["o_field_many2many_tags_avatar"];
registry.category("fields").add("kanban.many2many_avatar_user", KanbanMany2ManyTagsAvatarUserField);
registry.category("fields").add("list.many2many_avatar_user", KanbanMany2ManyTagsAvatarUserField);

View file

@ -0,0 +1,27 @@
.o_kanban_renderer, .o_list_renderer {
.o_field_many2many_avatar_user {
&:not(:has(.o_tags_input)) {
display: flex !important;
}
.o_field_tags:not(.o_tags_input) {
.o_tag {
background-color: transparent;
box-shadow: none;
color: inherit;
margin-right: 0px;
}
.o_m2m_avatar, .o_m2m_avatar_empty {
width: 20px;
height: 20px;
margin-left: 0px;
}
.o_m2m_avatar_empty {
background-color: $o-gray-300;
vertical-align: bottom;
}
}
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mail.Many2ManyAvatarUserTagsList" t-inherit="web.TagsList" t-inherit-mode="primary" owl="1">
<img position="attributes">
<attribute name="t-on-click.stop.prevent">tag.onImageClicked</attribute>
</img>
</t>
<t t-name="mail.KanbanMany2ManyTagsAvatarUserField" t-inherit="web.Many2ManyTagsAvatarField" t-inherit-mode="primary" owl="1">
<TagsList position="attributes">
<attribute name="displayBadge">!props.readonly</attribute>
<attribute name="displayText">displayText</attribute>
</TagsList>
</t>
</templates>

View file

@ -0,0 +1,94 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useOpenMany2XRecord } from "@web/views/fields/relational_utils";
import { sprintf } from "@web/core/utils/strings";
import { Many2ManyTagsField } from "@web/views/fields/many2many_tags/many2many_tags_field";
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
const { onMounted, onWillUpdateProps } = owl;
export class FieldMany2ManyTagsEmailTagsList extends TagsList {}
FieldMany2ManyTagsEmailTagsList.template = "FieldMany2ManyTagsEmailTagsList";
export class FieldMany2ManyTagsEmail extends Many2ManyTagsField {
setup() {
super.setup();
this.openedDialogs = 0;
this.recordsIdsToAdd = [];
this.openMany2xRecord = useOpenMany2XRecord({
resModel: this.props.relation,
activeActions: {
create: false,
createEdit: false,
write: true,
},
isToMany: true,
onRecordSaved: async (record) => {
if (record.data.email) {
this.recordsIdsToAdd.push(record.resId);
}
},
fieldString: this.props.string,
});
// Using onWillStart causes an infinite loop, onMounted will handle the initial
// check and onWillUpdateProps handles any addition to the field.
onMounted(this.checkEmails.bind(this, this.props));
onWillUpdateProps(this.checkEmails.bind(this));
}
async checkEmails(props) {
const invalidRecords = props.value.records.filter((record) => !record.data.email);
// Remove records with invalid data, open form view to edit those and readd them if they are updated correctly.
const dialogDefs = [];
for (const record of invalidRecords) {
dialogDefs.push(this.openMany2xRecord({
resId: record.resId,
context: props.record.getFieldContext(this.props.name),
title: sprintf(this.env._t("Edit: %s"), record.data.display_name),
}));
}
this.openedDialogs += invalidRecords.length;
const invalidRecordIds = invalidRecords.map(rec => rec.resId);
if (invalidRecordIds.length) {
this.props.value.replaceWith(props.value.currentIds.filter(id => !invalidRecordIds.includes(id)));
}
return Promise.all(dialogDefs).then(() => {
this.openedDialogs -= invalidRecords.length;
if (this.openedDialogs || !this.recordsIdsToAdd.length) {
return;
}
props.value.add(this.recordsIdsToAdd, { isM2M: true });
this.recordsIdsToAdd = [];
});
}
get tags() {
// Add email to our tags
const tags = super.tags;
const emailByResId = this.props.value.records.reduce((acc, record) => {
acc[record.resId] = record.data.email;
return acc;
}, {});
tags.forEach(tag => tag.email = emailByResId[tag.resId]);
return tags;
}
};
FieldMany2ManyTagsEmail.components = {
...FieldMany2ManyTagsEmail.components,
TagsList: FieldMany2ManyTagsEmailTagsList,
};
FieldMany2ManyTagsEmail.fieldsToFetch = Object.assign({},
Many2ManyTagsField.fieldsToFetch,
{email: {name: 'email', type: 'char'}}
);
FieldMany2ManyTagsEmail.additionalClasses = ["o_field_many2many_tags"];
registry.category("fields").add("many2many_tags_email", FieldMany2ManyTagsEmail);

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="FieldMany2ManyTagsEmailTagsList" t-inherit="web.TagsList" t-inherit-mode="primary" owl="1">
<xpath expr="//span[contains(@t-attf-class, 'badge')]" position="replace">
<div t-if="tag.email" t-attf-class="badge rounded-pill dropdown o_tag o_tag_color_0 #{tag.email.indexOf('@') &lt; 0 ? 'o_tag_error' : ''}" t-att-data-color="tag.colorIndex" t-att-data-index="tag_index" t-att-data-id="tag.id" t-att-title="tag.text">
<span class="o_badge_text" t-att-title="tag.email"><t t-esc="tag.text"/></span>
<a t-if="!readonly &amp;&amp; tag.onDelete" t-on-click.stop.prevent="tag.onDelete" href="#" class="fa fa-times o_delete" title="Delete" aria-label="Delete"/>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,46 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2OneAvatarField } from "@web/views/fields/many2one_avatar/many2one_avatar_field";
import { useOpenChat } from "@mail/views/open_chat_hook";
import { useAssignUserCommand } from "@mail/views/fields/assign_user_command_hook";
export class Many2OneAvatarUserField extends Many2OneAvatarField {
setup() {
super.setup();
this.openChat = useOpenChat(this.props.relation);
useAssignUserCommand();
}
onClickAvatar() {
this.openChat(this.props.value[0]);
}
}
Many2OneAvatarUserField.template = "mail.Many2OneAvatarUserField";
Many2OneAvatarUserField.additionalClasses = ["o_field_many2one_avatar"];
registry.category("fields").add("many2one_avatar_user", Many2OneAvatarUserField);
export class KanbanMany2OneAvatarUserField extends Many2OneAvatarUserField {
/**
* All props are normally passed to the Many2OneField however since
* we add a new one, we need to filter it out.
*/
get m2oFieldProps() {
return Object.fromEntries(Object.entries(this.props).filter(([key, _val]) => key in Many2OneAvatarField.props));
}
}
KanbanMany2OneAvatarUserField.template = "mail.KanbanMany2OneAvatarUserField";
KanbanMany2OneAvatarUserField.props = {
...Many2OneAvatarUserField.props,
displayAvatarName: { type: Boolean, optional: true },
};
KanbanMany2OneAvatarUserField.extractProps = ({ attrs, field }) => {
return {
...Many2OneAvatarUserField.extractProps({ attrs, field }),
displayAvatarName: attrs.options.display_avatar_name || false,
};
};
registry.category("fields").add("kanban.many2one_avatar_user", KanbanMany2OneAvatarUserField);
registry.category("fields").add("activity.many2one_avatar_user", KanbanMany2OneAvatarUserField);

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mail.Many2OneAvatarUserField" t-inherit="web.Many2OneAvatarField" t-inherit-mode="primary" owl="1">
<xpath expr="//span[hasclass('o_m2o_avatar')]" position="attributes">
<attribute name="t-on-click.stop.prevent">onClickAvatar</attribute>
</xpath>
</t>
<t t-name="mail.KanbanMany2OneAvatarUserField" t-inherit="mail.Many2OneAvatarUserField" t-inherit-mode="primary" owl="1">
<Many2OneField position="attributes">
<attribute name="t-if">props.readonly and props.displayAvatarName</attribute>
<attribute name="t-props">m2oFieldProps</attribute>
</Many2OneField>
</t>
</templates>

View file

@ -0,0 +1,195 @@
/** @odoo-module */
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { SIZES } from "@web/core/ui/ui_service";
import { patch } from "@web/core/utils/patch";
import { append, createElement, setAttributes } from "@web/core/utils/xml";
import { ViewCompiler, getModifier } from "@web/views/view_compiler";
import { FormCompiler } from "@web/views/form/form_compiler";
function compileChatter(node, params) {
let hasActivities = false;
let hasFollowers = false;
let hasMessageList = false;
let hasParentReloadOnAttachmentsChanged;
let hasParentReloadOnFollowersUpdate = false;
let hasParentReloadOnMessagePosted = false;
let isAttachmentBoxVisibleInitially = false;
for (const childNode of node.children) {
const options = evaluateExpr(childNode.getAttribute("options") || "{}");
switch (childNode.getAttribute('name')) {
case 'activity_ids':
hasActivities = true;
break;
case 'message_follower_ids':
hasFollowers = true;
hasParentReloadOnFollowersUpdate = Boolean(options['post_refresh']);
isAttachmentBoxVisibleInitially = isAttachmentBoxVisibleInitially || Boolean(options['open_attachments']);
break;
case 'message_ids':
hasMessageList = true;
hasParentReloadOnAttachmentsChanged = options['post_refresh'] === 'always';
hasParentReloadOnMessagePosted = Boolean(options['post_refresh']);
isAttachmentBoxVisibleInitially = isAttachmentBoxVisibleInitially || Boolean(options['open_attachments']);
break;
}
}
const chatterContainerXml = createElement("ChatterContainer");
setAttributes(chatterContainerXml, {
"chatter": params.chatter,
"hasActivities": hasActivities,
"hasFollowers": hasFollowers,
"hasMessageList": hasMessageList,
"hasParentReloadOnAttachmentsChanged": hasParentReloadOnAttachmentsChanged,
"hasParentReloadOnFollowersUpdate": hasParentReloadOnFollowersUpdate,
"hasParentReloadOnMessagePosted": hasParentReloadOnMessagePosted,
"isAttachmentBoxVisibleInitially": isAttachmentBoxVisibleInitially,
"threadId": params.threadId,
"threadModel": params.threadModel,
"webRecord": params.webRecord,
"saveRecord": "() => this.saveButtonClicked and this.saveButtonClicked()",
});
const chatterContainerHookXml = createElement("div");
chatterContainerHookXml.classList.add("o_FormRenderer_chatterContainer");
append(chatterContainerHookXml, chatterContainerXml);
return chatterContainerHookXml;
}
function compileAttachmentPreview(node, params) {
const webClientViewAttachmentViewContainerHookXml = createElement("div");
webClientViewAttachmentViewContainerHookXml.classList.add('o_attachment_preview');
const webClientViewAttachmentViewContainerXml = createElement("WebClientViewAttachmentViewContainer");
setAttributes(webClientViewAttachmentViewContainerXml, {
"threadId": params.threadId,
"threadModel": params.threadModel,
});
append(webClientViewAttachmentViewContainerHookXml, webClientViewAttachmentViewContainerXml);
return webClientViewAttachmentViewContainerHookXml;
}
export class MailFormCompiler extends ViewCompiler {
setup() {
this.compilers.push({ selector: "t", fn: this.compileT });
this.compilers.push({ selector: "div.oe_chatter", fn: this.compileChatter });
this.compilers.push({
selector: "div.o_attachment_preview",
fn: this.compileAttachmentPreview,
});
}
compile(node, params) {
const res = super.compile(node, params).children[0];
const chatterContainerHookXml = res.querySelector(".o_FormRenderer_chatterContainer");
if (chatterContainerHookXml) {
setAttributes(chatterContainerHookXml, {
"t-if": `!hasAttachmentViewer() and uiService.size >= ${SIZES.XXL}`,
"t-attf-class": "o-aside",
});
const chatterContainerXml = chatterContainerHookXml.querySelector('ChatterContainer');
setAttributes(chatterContainerXml, {
"hasExternalBorder": "false",
"hasMessageListScrollAdjust": "true",
"isInFormSheetBg": "false",
});
}
const attachmentViewHookXml = res.querySelector(".o_attachment_preview");
if (attachmentViewHookXml) {
setAttributes(attachmentViewHookXml, {
"t-if": `hasAttachmentViewer()`,
});
}
return res;
}
compileT(node, params) {
const compiledRoot = createElement("t");
for (const child of node.childNodes) {
const invisible = getModifier(child, "invisible");
let compiledChild = this.compileNode(child, params, false);
compiledChild = this.applyInvisible(invisible, compiledChild, {
...params,
recordExpr: "model.root",
});
append(compiledRoot, compiledChild);
}
return compiledRoot;
}
compileChatter(node) {
return compileChatter(node, {
chatter: "chatter",
threadId: "model.root.resId or undefined",
threadModel: "model.root.resModel",
webRecord: "model.root",
});
}
compileAttachmentPreview(node) {
return compileAttachmentPreview(node, {
threadId: "model.root.resId or undefined",
threadModel: "model.root.resModel",
});
}
}
registry.category("form_compilers").add("chatter_compiler", {
selector: "div.oe_chatter",
fn: (node) =>
compileChatter(node, {
chatter: "props.chatter",
threadId: "props.record.resId or undefined",
threadModel: "props.record.resModel",
webRecord: "props.record",
}),
});
registry.category("form_compilers").add("attachment_preview_compiler", {
selector: "div.o_attachment_preview",
fn: () => createElement("t"),
});
patch(FormCompiler.prototype, 'mail', {
compile(node, params) {
// TODO no chatter if in dialog?
const res = this._super(node, params);
const chatterContainerHookXml = res.querySelector('.o_FormRenderer_chatterContainer');
if (!chatterContainerHookXml) {
return res; // no chatter, keep the result as it is
}
const chatterContainerXml = chatterContainerHookXml.querySelector('ChatterContainer');
setAttributes(chatterContainerXml, {
"hasExternalBorder": "true",
"hasMessageListScrollAdjust": "false",
"isInFormSheetBg": "false",
"saveRecord": "this.props.saveButtonClicked",
});
if (chatterContainerHookXml.parentNode.classList.contains('o_form_sheet')) {
return res; // if chatter is inside sheet, keep it there
}
const formSheetBgXml = res.querySelector('.o_form_sheet_bg');
const parentXml = formSheetBgXml && formSheetBgXml.parentNode;
if (!parentXml) {
return res; // miss-config: a sheet-bg is required for the rest
}
if (params.hasAttachmentViewerInArch) {
// in sheet bg (attachment viewer present)
const sheetBgChatterContainerHookXml = chatterContainerHookXml.cloneNode(true);
sheetBgChatterContainerHookXml.classList.add('o-isInFormSheetBg');
setAttributes(sheetBgChatterContainerHookXml, {
't-if': `this.props.hasAttachmentViewer`,
});
append(formSheetBgXml, sheetBgChatterContainerHookXml);
const sheetBgChatterContainerXml = sheetBgChatterContainerHookXml.querySelector('ChatterContainer');
setAttributes(sheetBgChatterContainerXml, {
"isInFormSheetBg": "true",
});
}
// after sheet bg (standard position, below form)
setAttributes(chatterContainerHookXml, {
't-if': `!this.props.hasAttachmentViewer and uiService.size < ${SIZES.XXL}`,
});
append(parentXml, chatterContainerHookXml);
return res;
},
});

View file

@ -0,0 +1,102 @@
/** @odoo-module */
import { useModels } from "@mail/component_hooks/use_models";
import { ChatterContainer, getChatterNextTemporaryId } from "@mail/components/chatter_container/chatter_container";
import { WebClientViewAttachmentViewContainer } from "@mail/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container";
import { browser } from "@web/core/browser/browser";
import { useService } from "@web/core/utils/hooks";
import { createElement } from "@web/core/utils/xml";
import { SIZES } from "@web/core/ui/ui_service";
import { patch } from "@web/core/utils/patch";
import { useDebounced } from "@web/core/utils/timing";
import { FormController } from "@web/views/form/form_controller";
import { useViewCompiler } from "@web/views/view_compiler";
import { evalDomain } from "@web/views/utils";
import { MailFormCompiler } from "./form_compiler";
const { onMounted, onWillDestroy, onWillUnmount } = owl;
patch(FormController.prototype, "mail", {
setup() {
this._super();
this.uiService = useService("ui");
this.hasAttachmentViewerInArch = false;
this.chatter = undefined;
if (this.env.services.messaging) {
useModels();
this.env.services.messaging.modelManager.messagingCreatedPromise.then(() => {
if (owl.status(this) === "destroyed") {
return;
}
const messaging = this.env.services.messaging.modelManager.messaging;
this.chatter = messaging.models['Chatter'].insert({ id: getChatterNextTemporaryId() });
if (owl.status(this) === "destroyed") {
this.chatter.delete();
}
});
}
const { archInfo } = this.props;
const template = createElement("t");
const xmlDocAttachmentPreview = archInfo.xmlDoc.querySelector("div.o_attachment_preview");
if (xmlDocAttachmentPreview && xmlDocAttachmentPreview.parentNode.nodeName === "form") {
// TODO hasAttachmentViewer should also depend on the groups= and/or invisible modifier on o_attachment_preview (see invoice form)
template.appendChild(xmlDocAttachmentPreview);
this.hasAttachmentViewerInArch = true;
archInfo.arch = archInfo.xmlDoc.outerHTML;
}
const xmlDocChatter = archInfo.xmlDoc.querySelector("div.oe_chatter");
if (xmlDocChatter && xmlDocChatter.parentNode.nodeName === "form") {
template.appendChild(xmlDocChatter.cloneNode(true));
}
const mailTemplates = useViewCompiler(MailFormCompiler, archInfo.arch, { Mail: template }, {});
this.mailTemplate = mailTemplates.Mail;
this.onResize = useDebounced(this.render, 200);
onMounted(() => browser.addEventListener("resize", this.onResize));
onWillUnmount(() => browser.removeEventListener("resize", this.onResize));
onWillDestroy(() => {
if (this.chatter && this.chatter.exists()) {
this.chatter.delete();
}
});
},
/**
* @returns {Messaging|undefined}
*/
getMessaging() {
return this.env.services.messaging && this.env.services.messaging.modelManager.messaging;
},
/**
* @returns {boolean}
*/
hasAttachmentViewer() {
if (
this.uiService.size < SIZES.XXL ||
!this.hasAttachmentViewerInArch ||
!this.getMessaging() ||
!this.model.root.resId
) {
return false;
}
const thread = this.getMessaging().models['Thread'].insert({
id: this.model.root.resId,
model: this.model.root.resModel,
});
return thread.attachmentsInWebClientView.length > 0;
},
evalDomainFromRecord(record, expr) {
return evalDomain(expr, record.evalContext);
},
});
Object.assign(FormController.components, {
ChatterContainer,
WebClientViewAttachmentViewContainer,
});

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.FormView" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_form_view_container')]" position="after">
<t t-if="mailTemplate">
<t t-call="{{ mailTemplate }}" />
</t>
</xpath>
<xpath expr="//Layout/t[@t-component='props.Renderer']" position="attributes">
<attribute name="chatter">chatter</attribute>
<attribute name="hasAttachmentViewerInArch">hasAttachmentViewerInArch</attribute>
<attribute name="hasAttachmentViewer">hasAttachmentViewer()</attribute>
<attribute name="saveButtonClicked">() => this.saveButtonClicked()</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,22 @@
/** @odoo-module */
import { ChatterContainer } from "@mail/components/chatter_container/chatter_container";
import { WebClientViewAttachmentViewContainer } from "@mail/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container";
import { patch } from "@web/core/utils/patch";
import { FormRenderer } from "@web/views/form/form_renderer";
patch(FormRenderer.prototype, 'mail', {
get compileParams() {
return {
...this._super(),
hasAttachmentViewerInArch: this.props.hasAttachmentViewerInArch,
saveButtonClicked: this.props.saveButtonClicked,
};
},
});
Object.assign(FormRenderer.components, {
ChatterContainer,
WebClientViewAttachmentViewContainer,
});

View file

@ -0,0 +1,27 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
export const helpers = {
SUPPORTED_M2X_AVATAR_MODELS: ["res.users"],
buildOpenChatParams: (resModel, id) => {
if (resModel === "res.users") {
return { userId: id };
}
},
}
export function useOpenChat(resModel) {
const messagingService = useService("messaging");
if (!helpers.SUPPORTED_M2X_AVATAR_MODELS.includes(resModel)) {
throw new Error(
`This widget is only supported on many2one and many2many fields pointing to ${JSON.stringify(
helpers.SUPPORTED_M2X_AVATAR_MODELS
)}`
);
}
return async (id) => {
const messaging = await messagingService.get();
messaging.openChat(helpers.buildOpenChatParams(resModel, id));
};
}