19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:56 +01:00
parent a2f74aefd8
commit 4a4d12c333
844 changed files with 212348 additions and 270090 deletions

View file

@ -0,0 +1,39 @@
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { useSubEnv } from "@odoo/owl";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(Chatter.prototype, {
setup() {
super.setup(...arguments);
Object.assign(this.state, {
isFollower: this.props.isFollower,
});
this.orm = useService("orm");
useSubEnv({
// 'inFrontendPortalChatter' is specific to the frontend portal chatters
// and should not be set to 'true' in the project sharing chatter environment.
projectSharingId: this.props.projectSharingId,
});
},
async toggleIsFollower() {
this.state.isFollower = await this.orm.call(
this.props.threadModel,
"project_sharing_toggle_is_follower",
[this.props.threadId]
);
},
onPostCallback() {
super.onPostCallback();
this.state.isFollower = true;
},
});
Chatter.props = [
...Chatter.props,
"token",
"projectSharingId",
"isFollower",
"displayFollowButton",
];

View file

@ -0,0 +1,12 @@
import { ComposerAction } from "@mail/core/common/composer_actions";
import { patch } from "@web/core/utils/patch";
patch(ComposerAction.prototype, {
_condition({ owner }) {
if (this.id === "open-full-composer" && owner.env.projectSharingId) {
return false;
}
return super._condition(...arguments);
},
});

View file

@ -0,0 +1,41 @@
import { Composer } from "@mail/core/common/composer";
import { patch } from "@web/core/utils/patch";
import { onWillStart } from "@odoo/owl";
patch(Composer.prototype, {
setup() {
super.setup();
onWillStart(() => {
if (!this.thread.id) {
this.state.active = false;
}
});
},
get extraData() {
const extraData = super.extraData;
if (this.env.projectSharingId) {
extraData.project_sharing_id = this.env.projectSharingId;
}
return extraData;
},
get isSendButtonDisabled() {
if (this.thread && !this.thread.id) {
return true;
}
return super.isSendButtonDisabled;
},
get allowUpload() {
if (this.thread && !this.thread.id) {
return false;
}
return super.allowUpload;
},
get shouldHideFromMessageListOnDelete() {
return true;
}
});

View file

@ -0,0 +1,9 @@
import { Message } from "@mail/core/common/message";
import { patch } from "@web/core/utils/patch";
patch(Message.prototype, {
get shouldHideFromMessageListOnDelete() {
return true;
},
});

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="portal.Chatter" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o-mail-Chatter-top')]" position="before">
<div class="d-flex justify-content-end">
<button t-if="props.displayFollowButton and props.threadId" class="btn btn-link w-auto" t-on-click="toggleIsFollower">
<t t-if="state.isFollower">
Unfollow
</t>
<t t-else="">
Follow
</t>
</button>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,21 @@
import { SuggestionService } from "@mail/core/common/suggestion_service";
import { patch } from "@web/core/utils/patch";
patch(SuggestionService.prototype, {
async fetchPartnersRoles(term, thread, { abortSignal } = {}) {
if (thread.model === "project.task") {
this.store.insert(
await this.makeOrmCall(
"project.task",
"get_mention_suggestions",
[thread.id],
{ search: term },
{ abortSignal }
)
);
return;
}
return super.fetchPartnersRoles(...arguments);
},
});

View file

@ -1,69 +0,0 @@
.o_FormRenderer_chatterContainer {
display: flex;
background-color: $white;
border-color: $border-color;
.o_portal_chatter {
width: 100%;
.o_portal_chatter_header {
text-align: center;
vertical-align: middle;
}
div.o_portal_chatter_composer,
div.o_portal_chatter_messages {
div.d-flex {
gap: 10px;
.o_portal_chatter_attachments {
margin-bottom: 1rem;
.o_portal_chatter_attachment {
> button {
@include o-position-absolute($top: 0, $right: 0);
opacity: 0;
}
&:hover > button {
opacity: 1;
}
}
}
}
}
.o_portal_chatter_avatar {
width: 45px;
height: 45px;
}
.o_portal_message_internal_off {
.btn-danger {
display: none;
}
}
.o_portal_message_internal_on {
.btn-success {
display: none;
}
}
}
&.o-aside {
flex-direction: column;
.o_portal_chatter {
.o_portal_chatter_header {
padding-top: 1rem;
padding-bottom: 1px;
}
.o_portal_chatter_composer, .o_portal_chatter_messages {
margin-left: 1rem;
margin-right: 1rem;
}
}
}
}

View file

@ -1,15 +0,0 @@
/** @odoo-module */
const { Component } = owl;
export class ChatterAttachmentsViewer extends Component {}
ChatterAttachmentsViewer.template = 'project.ChatterAttachmentsViewer';
ChatterAttachmentsViewer.props = {
attachments: Array,
canDelete: { type: Boolean, optional: true },
delete: { type: Function, optional: true },
};
ChatterAttachmentsViewer.defaultProps = {
delete: async () => {},
};

View file

@ -1,28 +0,0 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterAttachmentsViewer" owl="1">
<div class="o_portal_chatter_attachments mt-3">
<div t-if="props.attachments.length" class="row">
<div t-foreach="props.attachments" t-as="attachment" t-key="attachment.id" class="col-lg-3 col-md-4 col-sm-6">
<div class="o_portal_chatter_attachment mb-2 position-relative text-center">
<button
t-if="props.canDelete and attachment.state == 'pending'"
class="btn btn-sm btn-outline-danger"
title="Delete"
t-on-click="() => props.delete(attachment)"
>
<i class="fa fa-times"/>
</button>
<a t-attf-href="/web/content/#{attachment.id}?download=true&amp;access_token=#{attachment.access_token}" target="_blank">
<div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype"/>
<div class='o_portal_chatter_attachment_name'>
<t t-out='attachment.filename'/>
</div>
</a>
</div>
</div>
</div>
</div>
</t>
</templates>

View file

@ -1,134 +0,0 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { TextField } from '@web/views/fields/text/text_field';
import { PortalAttachDocument } from '../portal_attach_document/portal_attach_document';
import { ChatterAttachmentsViewer } from './chatter_attachments_viewer';
const { Component, useState, onWillUpdateProps } = owl;
export class ChatterComposer extends Component {
setup() {
this.rpc = useService('rpc');
this.state = useState({
displayError: false,
attachments: this.props.attachments.map(file => file.state === 'done'),
message: '',
loading: false,
});
onWillUpdateProps(this.onWillUpdateProps);
}
onWillUpdateProps(nextProps) {
this.clearErrors();
this.state.message = '';
this.state.attachments = nextProps.attachments.map(file => file.state === 'done');
}
get discussionUrl() {
return `${window.location.href.split('#')[0]}#discussion`;
}
update(change) {
this.clearErrors();
this.state.message = change;
}
prepareMessageData() {
const attachment_ids = [];
const attachment_tokens = [];
for (const attachment of this.state.attachments) {
attachment_ids.push(attachment.id);
attachment_tokens.push(attachment.access_token);
}
return {
message: this.state.message,
attachment_ids,
attachment_tokens,
res_model: this.props.resModel,
res_id: this.props.resId,
project_sharing_id: this.props.projectSharingId,
};
}
async sendMessage() {
this.clearErrors();
if (!this.state.message && !this.state.attachments.length) {
this.state.displayError = true;
return;
}
await this.rpc(
"/mail/chatter_post",
this.prepareMessageData(),
);
this.props.postProcessMessageSent();
this.state.message = "";
this.state.attachments = [];
}
clearErrors() {
this.state.displayError = false;
}
async beforeUploadFile() {
this.state.loading = true;
return true;
}
onFileUpload(files) {
this.state.loading = false;
this.clearErrors();
for (const file of files) {
file.state = 'pending';
this.state.attachments.push(file);
}
}
async deleteAttachment(attachment) {
this.clearErrors();
try {
await this.rpc(
'/portal/attachment/remove',
{
attachment_id: attachment.id,
access_token: attachment.access_token,
},
);
} catch (err) {
console.error(err);
this.state.displayError = true;
}
this.state.attachments = this.state.attachments.filter(a => a.id !== attachment.id);
}
}
ChatterComposer.components = {
ChatterAttachmentsViewer,
PortalAttachDocument,
TextField,
};
ChatterComposer.props = {
resModel: String,
projectSharingId: Number,
resId: { type: Number, optional: true },
allowComposer: { type: Boolean, optional: true },
displayComposer: { type: Boolean, optional: true },
token: { type: String, optional: true },
messageCount: { type: Number, optional: true },
isUserPublic: { type: Boolean, optional: true },
partnerId: { type: Number, optional: true },
postProcessMessageSent: { type: Function, optional: true },
attachments: { type: Array, optional: true },
};
ChatterComposer.defaultProps = {
allowComposer: true,
displayComposer: false,
isUserPublic: true,
token: '',
attachments: [],
};
ChatterComposer.template = 'project.ChatterComposer';

View file

@ -1,61 +0,0 @@
<templates id="template" xml:space="preserve">
<!-- Widget PortalComposer (standalone)
required many options: token, res_model, res_id, ...
-->
<t t-name="project.ChatterComposer" owl="1">
<div t-if="props.allowComposer" class="o_portal_chatter_composer">
<t t-if="props.displayComposer">
<div t-if="state.displayError" class="alert alert-danger mb8 o_portal_chatter_composer_error" role="alert">
Oops! Something went wrong. Try to reload the page and log in.
</div>
<div class="d-flex">
<img t-if="!props.isUserPublic or props.token"
alt="Avatar"
class="o_portal_chatter_avatar o_object_fit_cover align-self-start"
t-attf-src="/web/image/res.partner/{{ props.partnerId }}/avatar_128"
/>
<div class="flex-grow-1">
<div class="o_portal_chatter_composer_input">
<div class="o_portal_chatter_composer_body mb32">
<TextField
rowCount="4"
placeholder="'Write a message...'"
value="state.message"
update.bind="update"
/>
<ChatterAttachmentsViewer
attachments="state.attachments"
canDelete="true"
delete.bind="deleteAttachment"
/>
<div class="mt8">
<button name="send_message" t-on-click="sendMessage" class="btn btn-primary me-1" type="submit" t-att-disabled="state.loading">
Send
</button>
<PortalAttachDocument
resModel="props.resModel"
resId="props.resId"
token="props.token"
multiUpload="true"
onUpload.bind="onFileUpload"
beforeOpen.bind="beforeUploadFile"
>
<i class="fa fa-paperclip"/>
</PortalAttachDocument>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-else="">
<h4>Leave a comment</h4>
<p>You must be <a t-attf-href="/web/login?redirect={{ discussionUrl }}">logged in</a> to post a comment.</p>
</t>
</div>
</t>
</templates>

View file

@ -1,172 +0,0 @@
/** @odoo-module */
import { formatDateTime, parseDateTime } from "@web/core/l10n/dates";
import { useService } from "@web/core/utils/hooks";
import { sprintf } from '@web/core/utils/strings';
import { ChatterComposer } from "./chatter_composer";
import { ChatterMessageCounter } from "./chatter_message_counter";
import { ChatterMessages } from "./chatter_messages";
import { ChatterPager } from "./chatter_pager";
const { Component, markup, onWillStart, useState, onWillUpdateProps } = owl;
export class ChatterContainer extends Component {
setup() {
this.rpc = useService('rpc');
this.state = useState({
currentPage: this.props.pagerStart,
messages: [],
options: this.defaultOptions,
});
onWillStart(this.onWillStart);
onWillUpdateProps(this.onWillUpdateProps);
}
get defaultOptions() {
return {
message_count: 0,
is_user_public: true,
is_user_employee: false,
is_user_published: false,
display_composer: Boolean(this.props.resId),
partner_id: null,
pager_scope: 4,
pager_step: 10,
};
}
get options() {
return this.state.options;
}
set options(options) {
this.state.options = {
...this.defaultOptions,
...options,
display_composer: !!options.display_composer,
access_token: typeof options.display_composer === 'string' ? options.display_composer : '',
};
}
get composerProps() {
return {
allowComposer: Boolean(this.props.resId),
displayComposer: this.state.options.display_composer,
partnerId: this.state.options.partner_id || undefined,
token: this.state.options.access_token,
resModel: this.props.resModel,
resId: this.props.resId,
projectSharingId: this.props.projectSharingId,
postProcessMessageSent: async () => {
this.state.currentPage = 1;
await this.fetchMessages();
},
attachments: this.state.options.default_attachment_ids,
};
}
onWillStart() {
this.initChatter(this.messagesParams(this.props));
}
onWillUpdateProps(nextProps) {
this.initChatter(this.messagesParams(nextProps));
}
async onChangePage(page) {
this.state.currentPage = page;
await this.fetchMessages();
}
async initChatter(params) {
if (params.res_id && params.res_model) {
const chatterData = await this.rpc(
'/mail/chatter_init',
params,
);
this.state.messages = this.preprocessMessages(chatterData.messages);
this.options = chatterData.options;
} else {
this.state.messages = [];
this.options = {};
}
}
async fetchMessages() {
const result = await this.rpc(
'/mail/chatter_fetch',
this.messagesParams(this.props),
);
this.state.messages = this.preprocessMessages(result.messages);
this.state.options.message_count = result.message_count;
return result;
}
messagesParams(props) {
const params = {
res_model: props.resModel,
res_id: props.resId,
limit: this.state.options.pager_step,
offset: (this.state.currentPage - 1) * this.state.options.pager_step,
allow_composer: Boolean(props.resId),
project_sharing_id: props.projectSharingId,
};
if (props.token) {
params.token = props.token;
}
if (props.domain) {
params.domain = props.domain;
}
return params;
}
preprocessMessages(messages) {
return messages.map(m => ({
...m,
author_avatar_url: sprintf('/web/image/mail.message/%s/author_avatar/50x50', m.id),
published_date_str: sprintf(
this.env._t('Published on %s'),
formatDateTime(
parseDateTime(
m.date,
{ format: 'MM-dd-yyy HH:mm:ss' },
),
)
),
body: markup(m.body),
}));
}
updateMessage(message_id, changes) {
Object.assign(
this.state.messages.find(m => m.id === message_id),
changes,
);
}
}
ChatterContainer.components = {
ChatterComposer,
ChatterMessageCounter,
ChatterMessages,
ChatterPager,
};
ChatterContainer.props = {
token: { type: String, optional: true },
resModel: String,
resId: { type: Number, optional: true },
pid: { type: String, optional: true },
hash: { type: String, optional: true },
pagerStart: { type: Number, optional: true },
twoColumns: { type: Boolean, optional: true },
projectSharingId: Number,
};
ChatterContainer.defaultProps = {
token: '',
pid: '',
hash: '',
pagerStart: 1,
};
ChatterContainer.template = 'project.ChatterContainer';

View file

@ -1,27 +0,0 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterContainer" owl="1">
<div t-attf-class="o_portal_chatter p-0 container {{props.twoColumns ? 'row' : ''}}">
<div t-attf-class="{{props.twoColumns ? 'col-lg-5' : props.resId ? 'border-bottom' : ''}}">
<div class="o_portal_chatter_header">
<ChatterMessageCounter count="state.options.message_count"/>
</div>
<hr/>
<ChatterComposer t-props="composerProps"/>
</div>
<div t-attf-class="{{props.twoColumns ? 'offset-lg-1 col-lg-6' : 'pt-4'}}">
<ChatterMessages messages="props.resId ? state.messages : []" isUserEmployee="state.options.is_user_employee" update.bind="updateMessage" />
<div class="o_portal_chatter_footer">
<ChatterPager
page="this.state.currentPage || 1"
messageCount="this.state.options.message_count"
pagerScope="this.state.options.pager_scope"
pagerStep="this.state.options.pager_step"
changePage.bind="onChangePage"
/>
</div>
</div>
</div>
</t>
</templates>

View file

@ -1,10 +0,0 @@
/** @odoo-module */
const { Component } = owl;
export class ChatterMessageCounter extends Component { }
ChatterMessageCounter.props = {
count: Number,
};
ChatterMessageCounter.template = 'project.ChatterMessageCounter';

View file

@ -1,16 +0,0 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterMessageCounter" owl="1">
<div class="o_message_counter">
<t t-if="props.count">
<span class="fa fa-comments" />
<span class="o_message_count"> <t t-esc="props.count"/> </span>
comments
</t>
<t t-else="">
There are no comments for now.
</t>
</div>
</t>
</templates>

View file

@ -1,37 +0,0 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { ChatterAttachmentsViewer } from "./chatter_attachments_viewer";
const { Component } = owl;
export class ChatterMessages extends Component {
setup() {
this.rpc = useService('rpc');
}
/**
* Toggle the visibility of the message.
*
* @param {Object} message message to change the visibility
*/
async toggleMessageVisibility(message) {
const result = await this.rpc(
'/mail/update_is_internal',
{ message_id: message.id, is_internal: !message.is_internal },
);
this.props.update(message.id, { is_internal: result });
}
}
ChatterMessages.template = 'project.ChatterMessages';
ChatterMessages.props = {
messages: Array,
isUserEmployee: { type: Boolean, optional: true },
update: { type: Function, optional: true },
};
ChatterMessages.defaultProps = {
update: (message_id, changes) => {},
};
ChatterMessages.components = { ChatterAttachmentsViewer };

View file

@ -1,43 +0,0 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterMessages" owl="1">
<div class="o_portal_chatter_messages">
<t t-foreach="props.messages" t-as="message" t-key="message.id">
<div class="d-flex o_portal_chatter_message">
<img class="o_portal_chatter_avatar" t-att-src="message.author_avatar_url" alt="avatar"/>
<div class="flex-grow-1">
<t t-if="props.isUserEmployee">
<div t-if="message.is_message_subtype_note" class="float-end">
<button class="btn btn-secondary" title="Internal notes are only displayed to internal users." disabled="true">Internal Note</button>
</div>
<div t-else=""
t-attf-class="float-end {{message.is_internal ? 'o_portal_message_internal_on' : 'o_portal_message_internal_off'}}"
t-on-click="() => this.toggleMessageVisibility(message)"
>
<button class="btn btn-danger"
title="Currently restricted to internal employees, click to make it available to everyone viewing this document."
>
Employees Only
</button>
<button class="btn btn-success"
title="Currently available to everyone viewing this document, click to restrict to internal employees."
>
Visible
</button>
</div>
</t>
<div class="o_portal_chatter_message_title">
<h5 class='mb-1'><t t-out="message.author_id[1]"/></h5>
<p class="o_portal_chatter_puslished_date"><t t-out="message.published_date_str"/></p>
</div>
<t t-out="message.body"/>
<div class="o_portal_chatter_attachments">
<ChatterAttachmentsViewer attachments="message.attachment_ids"/>
</div>
</div>
</div>
</t>
</div>
</t>
</templates>

View file

@ -1,64 +0,0 @@
/** @odoo-module */
const { Component, useState, onWillUpdateProps } = owl;
export class ChatterPager extends Component {
setup() {
this.state = useState({
disabledButtons: false,
pageCount: 1,
pageStart: 1,
pageEnd: 1,
pagePrevious: 1,
pageNext: 1,
pages: [1],
offset: 0,
});
this.computePagerState(this.props);
onWillUpdateProps(this.onWillUpdateProps);
}
computePagerState(props) {
let page = props.page || 1;
let scope = props.pagerScope;
const step = props.pagerStep;
// Compute Pager
this.state.messageCount = Math.ceil(parseFloat(props.messageCount) / step);
page = Math.max(1, Math.min(page, this.state.messageCount));
const pageStart = Math.max(page - parseInt(Math.floor(scope / 2)), 1);
this.state.pageEnd = Math.min(pageStart + scope, this.state.messageCount);
this.state.pageStart = Math.max(this.state.pageEnd - scope, 1);
this.state.pages = Array.from(
{length: this.state.pageEnd - this.state.pageStart + 1},
(_, i) => i + this.state.pageStart,
);
this.state.pagePrevious = Math.max(this.state.pageStart, page - 1);
this.state.pageNext = Math.min(this.state.pageEnd, page + 1);
}
onWillUpdateProps(nextProps) {
this.computePagerState(nextProps);
}
async onPageChanged(page) {
this.state.disabledButtons = true;
await this.props.changePage(page);
this.state.disabledButtons = false;
}
}
ChatterPager.props = {
pagerScope: Number,
pagerStep: Number,
page: Number,
messageCount: Number,
changePage: Function,
};
ChatterPager.template = 'project.ChatterPager';

View file

@ -1,21 +0,0 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterPager" owl="1">
<div class="d-flex justify-content-center">
<ul class="pagination mb-0 pb-4" t-if="state.pages.length &gt; 1">
<li t-if="props.page != props.page_previous" t-att-data-page="state.pagePrevious" class="page-item o_portal_chatter_pager_btn">
<a t-on-click="() => this.onPageChanged(state.pagePrevious)" class="page-link"><i class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/></a>
</li>
<t t-foreach="state.pages" t-as="page" t-key="page_index">
<li t-att-data-page="page" t-attf-class="page-item #{page == props.page ? 'o_portal_chatter_pager_btn active' : 'o_portal_chatter_pager_btn'}">
<a t-on-click="() => this.onPageChanged(page)" t-att-disabled="page == props.page" class="page-link"><t t-esc="page"/></a>
</li>
</t>
<li t-if="props.page != state.pageNext" t-att-data-page="state.pageNext" class="page-item o_portal_chatter_pager_btn">
<a t-on-click="() => this.onPageChanged(state.pageNext)" class="page-link"><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/></a>
</li>
</ul>
</div>
</t>
</templates>

View file

@ -0,0 +1,10 @@
import { ListRenderer } from "@web/views/list/list_renderer";
export class DependOnIdsListRenderer extends ListRenderer {
get nbHiddenRecords() {
const { context, records } = this.props.list;
return context.depend_on_count - records.length;
}
}
DependOnIdsListRenderer.rowsTemplate = "project.DependOnIdsListRowsRenderer";

View file

@ -0,0 +1,13 @@
<templates>
<t t-name="project.DependOnIdsListRowsRenderer" t-inherit="web.ListRenderer.Rows" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-foreach='list.records']" position="after">
<tr class="o_data_row">
<td t-if="nbHiddenRecords" t-att-colspan="nbCols" style="text-align:center;">
<i class="text-muted">
This task is currently blocked by <t t-out="nbHiddenRecords"/> (other) tasks to which you do not have access.
</i>
</td>
</tr>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,18 @@
import { registry } from "@web/core/registry";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { DependOnIdsListRenderer } from "./depend_on_ids_list_renderer";
export class DependOnIdsOne2ManyField extends X2ManyField {
static components = {
...X2ManyField.components,
ListRenderer: DependOnIdsListRenderer,
};
}
export const dependOnIdsOne2ManyField = {
...x2ManyField,
component: DependOnIdsOne2ManyField,
};
registry.category("fields").add("depend_on_ids_one2many", dependOnIdsOne2ManyField);

View file

@ -1,35 +0,0 @@
/** @odoo-module **/
import FavoriteMenuLegacy from 'web.FavoriteMenu';
import CustomFavoriteItemLegacy from 'web.CustomFavoriteItem';
import { registry } from "@web/core/registry";
/**
* Remove all components contained in the favorite menu registry except the CustomFavoriteItem
* component for only the project sharing feature.
*/
export function prepareFavoriteMenuRegister() {
let customFavoriteItemKey = 'favorite-generator-menu';
const keys = FavoriteMenuLegacy.registry.keys().filter(key => key !== customFavoriteItemKey);
FavoriteMenuLegacy.registry = Object.assign(FavoriteMenuLegacy.registry, {
map: {},
_scoreMapping: {},
_sortedKeys: null,
});
FavoriteMenuLegacy.registry.add(customFavoriteItemKey, CustomFavoriteItemLegacy, 0);
// notify the listeners, we keep only one key in this registry.
for (const key of keys) {
for (const callback of FavoriteMenuLegacy.registry.listeners) {
callback(key, undefined);
}
}
customFavoriteItemKey = 'custom-favorite-item';
const favoriteMenuRegistry = registry.category("favoriteMenu");
for (const [key] of favoriteMenuRegistry.getEntries()) {
if (key !== customFavoriteItemKey) {
favoriteMenuRegistry.remove(key);
}
}
}

View file

@ -1,33 +0,0 @@
/** @odoo-module */
import { PortalFileInput } from '../portal_file_input/portal_file_input';
const { Component } = owl;
export class PortalAttachDocument extends Component {}
PortalAttachDocument.template = 'project.PortalAttachDocument';
PortalAttachDocument.components = { PortalFileInput };
PortalAttachDocument.props = {
highlight: { type: Boolean, optional: true },
onUpload: { type: Function, optional: true },
beforeOpen: { type: Function, optional: true },
slots: {
type: Object,
shape: {
default: Object,
},
},
resId: { type: Number, optional: true },
resModel: { type: String, optional: true },
multiUpload: { type: Boolean, optional: true },
hidden: { type: Boolean, optional: true },
acceptedFileExtensions: { type: String, optional: true },
token: { type: String, optional: true },
};
PortalAttachDocument.defaultProps = {
acceptedFileExtensions: "*",
onUpload: () => {},
route: "/portal/attachment/add",
beforeOpen: async () => true,
};

View file

@ -1,21 +0,0 @@
<templates id="template" xml:space="preserve">
<t t-name="project.PortalAttachDocument" owl="1">
<button t-attf-class="btn o_attachment_button #{props.highlight ? 'btn-primary' : 'btn-secondary'}">
<PortalFileInput
onUpload="props.onUpload"
beforeOpen="props.beforeOpen"
multiUpload="props.multiUpload"
resModel="props.resModel"
resId="props.resId"
route="props.route"
accessToken="props.token"
>
<t t-set-slot="default">
<i class="fa fa-paperclip"/>
</t>
</PortalFileInput>
</button>
</t>
</templates>

View file

@ -1,46 +0,0 @@
/** @odoo-module */
import { FileInput } from '@web/core/file_input/file_input';
export class PortalFileInput extends FileInput {
/**
* @override
*/
get httpParams() {
const {
model: res_model,
id: res_id,
...otherParams
} = super.httpParams;
return {
res_model,
res_id,
access_token: this.props.accessToken,
...otherParams,
}
}
async uploadFiles(params) {
const { ufile: files, ...otherParams } = params;
const filesData = await Promise.all(
files.map(
(file) =>
super.uploadFiles({
file,
name: file.name,
...otherParams,
})
)
);
return filesData;
}
}
PortalFileInput.props = {
...FileInput.props,
accessToken: { type: String, optional: true },
};
PortalFileInput.defaultProps = {
...FileInput.defaultProps,
accessToken: '',
};

View file

@ -0,0 +1,45 @@
import { ImageCropPlugin } from "@html_editor/main/media/image_crop_plugin";
import { ImageSavePlugin } from "@html_editor/main/media/image_save_plugin";
import { MediaPlugin } from "@html_editor/main/media/media_plugin";
import { MAIN_PLUGINS } from "@html_editor/plugin_sets";
export class ProjectSharingMediaPlugin extends MediaPlugin {
resources = {
...this.resources,
toolbar_items: this.resources.toolbar_items.filter(
item => item.id !== "replace_image"
),
}
}
export class ProjectSharingImageSavePlugin extends ImageSavePlugin {
async createAttachment({ el, imageData, resId }) {
const response = JSON.parse(
await this.services.http.post(
"/project_sharing/attachment/add_image",
{
name: el.dataset.fileName || "",
data: imageData,
res_id: resId,
access_token: "",
csrf_token: odoo.csrf_token,
},
"text"
)
);
if (response.error) {
this.services.notification.add(response.error, { type: "danger" });
el.remove();
}
const attachment = response;
attachment.image_src = "/web/image/" + attachment.id + "-" + attachment.name;
return attachment;
}
}
MAIN_PLUGINS.splice(MAIN_PLUGINS.indexOf(MediaPlugin), 1);
MAIN_PLUGINS.push(ProjectSharingMediaPlugin);
MAIN_PLUGINS.splice(MAIN_PLUGINS.indexOf(ImageSavePlugin), 1);
MAIN_PLUGINS.push(ProjectSharingImageSavePlugin);
MAIN_PLUGINS.splice(MAIN_PLUGINS.indexOf(ImageCropPlugin), 1);

View file

@ -1,7 +1,4 @@
/** @odoo-module **/
import { startWebClient } from '@web/start';
import { ProjectSharingWebClient } from './project_sharing';
import { prepareFavoriteMenuRegister } from './components/favorite_menu_registry';
prepareFavoriteMenuRegister();
startWebClient(ProjectSharingWebClient);

View file

@ -1,19 +1,17 @@
/** @odoo-module **/
import { useBus, useService } from '@web/core/utils/hooks';
import { ActionContainer } from '@web/webclient/actions/action_container';
import { browser } from "@web/core/browser/browser";
import { useBus, useService } from "@web/core/utils/hooks";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useOwnDebugContext } from "@web/core/debug/debug_context";
import { session } from '@web/session';
const { Component, useEffect, useExternalListener, useState } = owl;
import { ActionContainer } from "@web/webclient/actions/action_container";
import { Component, onMounted, useExternalListener, useState } from "@odoo/owl";
export class ProjectSharingWebClient extends Component {
static props = {};
static components = { ActionContainer, MainComponentsContainer };
static template = "project.ProjectSharingWebClient";
setup() {
window.parent.document.body.style.margin = "0"; // remove the margin in the parent body
this.actionService = useService('action');
this.user = useService("user");
useService("legacy_service_provider");
this.actionService = useService("action");
useOwnDebugContext({ categories: ["default"] });
this.state = useState({
fullscreen: false,
@ -23,28 +21,31 @@ export class ProjectSharingWebClient extends Component {
this.state.fullscreen = mode === "fullscreen";
}
});
useEffect(
() => {
this._showView();
},
() => []
);
onMounted(() => {
this.loadRouterState();
// the chat window and dialog services listen to 'web_client_ready' event in
// order to initialize themselves:
this.env.bus.trigger("WEB_CLIENT_READY");
});
useExternalListener(window, "click", this.onGlobalClick, { capture: true });
}
async _showView() {
const { action_name, project_id, open_task_action } = session;
await this.actionService.doAction(
action_name,
{
clearBreadcrumbs: true,
additionalContext: {
active_id: project_id,
async loadRouterState() {
// ** url-retrocompatibility **
const stateLoaded = await this.actionService.loadState();
// Scroll to anchor after the state is loaded
if (stateLoaded) {
if (browser.location.hash !== "") {
try {
const el = document.querySelector(browser.location.hash);
if (el !== null) {
el.scrollIntoView(true);
}
} catch {
// do nothing if the hash is not a correct selector.
}
}
);
if (open_task_action) {
await this.actionService.doAction(open_task_action);
}
}
@ -56,7 +57,8 @@ export class ProjectSharingWebClient extends Component {
// we let the browser do the default behavior and
// we do not want any other listener to execute.
if (
ev.ctrlKey &&
(ev.ctrlKey || ev.metaKey) &&
!ev.target.isContentEditable &&
((ev.target instanceof HTMLAnchorElement && ev.target.href) ||
(ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])")))
) {
@ -65,6 +67,3 @@ export class ProjectSharingWebClient extends Component {
}
}
}
ProjectSharingWebClient.components = { ActionContainer, MainComponentsContainer };
ProjectSharingWebClient.template = 'project.ProjectSharingWebClient';

View file

@ -0,0 +1,4 @@
.o_project_sharing .o_control_panel {
// to be able to vertically center the content of the control panel since there is no menu header displayed in project sharing
padding-top: 16px !important;
}

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="project.ProjectSharingWebClient" owl="1">
<t t-name="project.ProjectSharingWebClient">
<ActionContainer />
<MainComponentsContainer/>
</t>

View file

@ -0,0 +1,30 @@
import { browser } from "@web/core/browser/browser";
import { startUrl, router } from "@web/core/browser/router";
import { patch } from "@web/core/utils/patch";
patch(router, {
/**
* @param {{ [key: string]: any }} state
* @returns {string}
*/
stateToUrl(state) {
const url = super.stateToUrl(state);
return url.replace(startUrl(), "my/projects");
},
urlToState(urlObj) {
const { pathname } = urlObj;
urlObj.pathname = pathname.replace(
/\/my\/projects\/([1234567890]+)\/project_sharing/,
"/odoo/project.project/$1/project_sharing"
);
const state = super.urlToState(urlObj);
if (state.actionStack?.length) {
state.actionStack.shift();
}
return state;
},
});
// Since the patch for `stateToUrl` and `urlToState` is executed
// after the router state was already initialized, it has to be replaced.
router.replaceState(router.urlToState(new URL(browser.location)));

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="project.ProjectTaskControlPanel" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_control_panel_main_buttons')]" position="before">
<t t-call="project.ProjectSharingControlPanelNavigationButton" />
</xpath>
</t>
<t t-inherit="web.ControlPanel" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_control_panel_main_buttons')]" position="before">
<t t-call="project.ProjectSharingControlPanelNavigationButton" />
</xpath>
</t>
<t t-name="project.ProjectSharingControlPanelNavigationButton">
<a class="btn btn-link" href="/my/projects" title="Go back to 'My Projects'"><i class="fa fa-arrow-left"/></a>
</t>
</templates>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-inherit="web.CustomFavoriteItem" t-inherit-mode="extension">
<xpath expr="//CheckBox[@value='state.isShared']" position="replace"/>
</t>
</templates>

View file

@ -1,9 +1,6 @@
/** @odoo-module */
import { append, createElement, setAttributes } from "@web/core/utils/xml";
import { registry } from "@web/core/registry";
import { SIZES } from "@web/core/ui/ui_service";
import { getModifier, ViewCompiler } from "@web/views/view_compiler";
import { patch } from "@web/core/utils/patch";
import { FormCompiler } from "@web/views/form/form_compiler";
@ -15,91 +12,58 @@ import { FormCompiler } from "@web/views/form/form_compiler";
* @returns
*/
function compileChatter(node, params) {
const chatterContainerXml = createElement('ChatterContainer');
const chatterContainerXml = createElement("Chatter");
const parentURLQuery = new URLSearchParams(window.parent.location.search);
setAttributes(chatterContainerXml, {
token: `'${parentURLQuery.get('access_token')}'` || '',
resModel: params.resModel,
resId: params.resId,
token: `'${parentURLQuery.get("access_token")}'` || "",
threadModel: params.resModel,
threadId: params.resId,
projectSharingId: params.projectSharingId,
isFollower: params.isFollower,
displayFollowButton: params.displayFollowButton,
});
const chatterContainerHookXml = createElement('div');
chatterContainerHookXml.classList.add('o_FormRenderer_chatterContainer');
const chatterContainerHookXml = createElement("div");
chatterContainerHookXml.classList.add("o-mail-ChatterContainer", "o-mail-Form-chatter", "pt-2");
setAttributes(chatterContainerHookXml, { "t-if": "!__comp__.env.inDialog" });
append(chatterContainerHookXml, chatterContainerXml);
return chatterContainerHookXml;
}
export class ProjectSharingChatterCompiler extends ViewCompiler {
setup() {
this.compilers.push({ selector: "t", fn: this.compileT });
this.compilers.push({ selector: 'div.oe_chatter', fn: this.compileChatter });
}
compile(node, params) {
const res = super.compile(node, params).children[0];
const chatterContainerHookXml = res.querySelector(".o_FormRenderer_chatterContainer");
if (chatterContainerHookXml) {
setAttributes(chatterContainerHookXml, {
"t-if": `uiService.size >= ${SIZES.XXL}`,
});
chatterContainerHookXml.classList.add('overflow-x-hidden', 'overflow-y-auto', 'o-aside', 'h-100');
}
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, {
resId: 'model.root.resId or undefined',
resModel: 'model.root.resModel',
projectSharingId: 'model.root.context.active_id_chatter',
});
}
}
registry.category("form_compilers").add("portal_chatter_compiler", {
selector: "div.oe_chatter",
selector: "chatter",
fn: (node) =>
compileChatter(node, {
resId: "props.record.resId or undefined",
resModel: "props.record.resModel",
projectSharingId: "props.record.context.active_id_chatter",
resId: "__comp__.props.record.resId or undefined",
resModel: "__comp__.props.record.resModel",
projectSharingId: "__comp__.props.record.context.active_id_chatter",
isFollower: "__comp__.props.record.data.message_is_follower",
displayFollowButton: "__comp__.props.record.data.display_follow_button",
}),
});
patch(FormCompiler.prototype, 'project_sharing_chatter', {
patch(FormCompiler.prototype, {
compile(node, params) {
const res = this._super(node, params);
const chatterContainerHookXml = res.querySelector('.o_FormRenderer_chatterContainer');
const res = super.compile(node, params);
const chatterContainerHookXml = res.querySelector(".o-mail-Form-chatter");
if (!chatterContainerHookXml) {
return res; // no chatter, keep the result as it is
}
if (chatterContainerHookXml.parentNode.classList.contains('o_form_sheet')) {
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 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
}
// after sheet bg (standard position, below form)
setAttributes(chatterContainerHookXml, {
't-if': `uiService.size < ${SIZES.XXL}`,
"t-att-class": `{
"overflow-x-hidden overflow-y-auto o-aside h-100": __comp__.uiService.size >= ${SIZES.XXL},
"px-3 py-0": __comp__.uiService.size < ${SIZES.XXL},
}`,
});
append(parentXml, chatterContainerHookXml);
return res;
}
},
});

View file

@ -1,36 +1,55 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { createElement } from "@web/core/utils/xml";
import { _t } from "@web/core/l10n/translation";
import { FormController } from '@web/views/form/form_controller';
import { useViewCompiler } from '@web/views/view_compiler';
import { ProjectSharingChatterCompiler } from './project_sharing_form_compiler';
import { ChatterContainer } from '../../components/chatter/chatter_container';
import { useService } from '@web/core/utils/hooks';
import { useExternalListener } from "@odoo/owl";
export class ProjectSharingFormController extends FormController {
static components = {
...FormController.components,
};
setup() {
super.setup();
this.uiService = useService('ui');
const { arch, xmlDoc } = this.archInfo;
const template = createElement('t');
const xmlDocChatter = xmlDoc.querySelector("div.oe_chatter");
if (xmlDocChatter && xmlDocChatter.parentNode.nodeName === "form") {
template.appendChild(xmlDocChatter.cloneNode(true));
}
const mailTemplates = useViewCompiler(ProjectSharingChatterCompiler, arch, { Mail: template }, {});
this.mailTemplate = mailTemplates.Mail;
this.notification = useService('notification');
useExternalListener(window, "paste", this.onGlobalPaste, { capture: true });
useExternalListener(window, "drop", this.onGlobalDrop, { capture: true });
}
getActionMenuItems() {
get actionMenuItems() {
return {};
}
get translateAlert() {
return null;
}
}
ProjectSharingFormController.components = {
...FormController.components,
ChatterContainer,
onGlobalPaste(ev) {
if (ev.target.closest('.o_field_widget[name="description"]')) {
ev.preventDefault();
const items = ev.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1 && !this.model.root.resId) {
this.notification.add(
_t("Save the task to be able to paste images in description"),
{ type: 'warning' },
)
ev.stopImmediatePropagation();
return;
}
}
}
}
onGlobalDrop(ev) {
if (ev.target.closest('.o_field_widget[name="description"]')) {
ev.preventDefault();
if(ev.dataTransfer.files.length > 0 && !this.model.root.resId){
this.notification.add(
_t("Save the task to be able to drag images in description"),
{ type: 'warning' },
)
ev.stopImmediatePropagation();
}
}
}
}

View file

@ -1,12 +0,0 @@
<?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>
</t>
</templates>

View file

@ -1,10 +1,10 @@
/** @odoo-module */
import { FormRenderer } from "@web/views/form/form_renderer";
import { ChatterContainer } from '../../components/chatter/chatter_container';
import { FormRenderer } from '@web/views/form/form_renderer';
import { Chatter } from "@mail/chatter/web_portal/chatter";
export class ProjectSharingFormRenderer extends FormRenderer { }
ProjectSharingFormRenderer.components = {
...FormRenderer.components,
ChatterContainer,
};
export class ProjectSharingFormRenderer extends FormRenderer {
static components = {
...FormRenderer.components,
Chatter,
};
}

View file

@ -1,5 +1,3 @@
/** @odoo-module */
import { formView } from '@web/views/form/form_view';
import { ProjectSharingFormController } from './project_sharing_form_controller';
import { ProjectSharingFormRenderer } from './project_sharing_form_renderer';

View file

@ -1,19 +1,16 @@
/** @odoo-module */
import { kanbanView } from "@web/views/kanban/kanban_view";
import { KanbanDynamicGroupList, KanbanModel } from "@web/views/kanban/kanban_model";
import { ProjectTaskRelationalModel } from "@project/views/project_task_relational_model";
import { ProjectTaskControlPanel } from "@project/views/project_task_control_panel/project_task_control_panel";
export class ProjectSharingTaskKanbanDynamicGroupList extends KanbanDynamicGroupList {
get context() {
return {
...super.context,
export class ProjectSharingTaskKanbanModel extends ProjectTaskRelationalModel {
async _webReadGroup(config) {
config.context = {
...config.context,
project_kanban: true,
};
return super._webReadGroup(...arguments);
}
}
export class ProjectSharingTaskKanbanModel extends KanbanModel {}
ProjectSharingTaskKanbanModel.DynamicGroupList = ProjectSharingTaskKanbanDynamicGroupList;
kanbanView.ControlPanel = ProjectTaskControlPanel;
kanbanView.Model = ProjectSharingTaskKanbanModel;

View file

@ -1,41 +0,0 @@
/** @odoo-module */
import { ListRenderer } from "@web/views/list/list_renderer";
import { evalDomain } from "@web/views/utils";
const { onWillUpdateProps } = owl;
export class ProjectSharingListRenderer extends ListRenderer {
setup() {
super.setup(...arguments);
this.setColumns(this.allColumns);
onWillUpdateProps((nextProps) => {
this.setColumns(nextProps.archInfo.columns);
});
}
setColumns(columns) {
if (this.props.list.records.length) {
const allColumns = [];
const firstRecord = this.props.list.records[0];
for (const column of columns) {
if (
column.modifiers.column_invisible &&
column.modifiers.column_invisible instanceof Array
) {
const result = evalDomain(column.modifiers.column_invisible, firstRecord.evalContext);
if (result) {
continue;
}
}
allColumns.push(column);
}
this.allColumns = allColumns;
} else {
this.allColumns = columns;
}
this.state.columns = this.allColumns.filter(
(col) => !col.optional || this.optionalActiveFields[col.name]
);
}
}

View file

@ -1,8 +1,6 @@
/** @odoo-module */
import { listView } from "@web/views/list/list_view";
import { ProjectSharingListRenderer } from "./list_renderer";
import { ProjectTaskControlPanel } from "@project/views/project_task_control_panel/project_task_control_panel";
import { ProjectTaskRelationalModel } from "@project/views/project_task_relational_model";
const props = listView.props;
listView.props = function (genericProps, view) {
@ -12,4 +10,6 @@ listView.props = function (genericProps, view) {
allowSelectors: false,
};
};
listView.Renderer = ProjectSharingListRenderer;
listView.Model = ProjectTaskRelationalModel;
listView.ControlPanel = ProjectTaskControlPanel;

View file

@ -0,0 +1,18 @@
import { patch } from "@web/core/utils/patch";
import { router } from "@web/core/browser/router";
import { session } from "@web/session";
import { View } from "@web/views/view";
/** Hack to display the project name when we load project sharing */
patch(View.prototype, {
setup() {
super.setup();
if (
router.current.action === "project_sharing" &&
!router.current.resId &&
router.current.active_id === session.project_id
) {
this.env.config.setDisplayName(session.project_name);
}
},
});