Initial commit: Project packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit 89613c97b0
753 changed files with 496325 additions and 0 deletions

View file

@ -0,0 +1,69 @@
.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

@ -0,0 +1,15 @@
/** @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

@ -0,0 +1,28 @@
<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

@ -0,0 +1,134 @@
/** @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

@ -0,0 +1,61 @@
<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

@ -0,0 +1,172 @@
/** @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

@ -0,0 +1,27 @@
<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

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

View file

@ -0,0 +1,16 @@
<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

@ -0,0 +1,37 @@
/** @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

@ -0,0 +1,43 @@
<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

@ -0,0 +1,64 @@
/** @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

@ -0,0 +1,21 @@
<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,35 @@
/** @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

@ -0,0 +1,33 @@
/** @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

@ -0,0 +1,21 @@
<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

@ -0,0 +1,46 @@
/** @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,7 @@
/** @odoo-module **/
import { startWebClient } from '@web/start';
import { ProjectSharingWebClient } from './project_sharing';
import { prepareFavoriteMenuRegister } from './components/favorite_menu_registry';
prepareFavoriteMenuRegister();
startWebClient(ProjectSharingWebClient);

View file

@ -0,0 +1,70 @@
/** @odoo-module **/
import { useBus, useService } from '@web/core/utils/hooks';
import { ActionContainer } from '@web/webclient/actions/action_container';
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;
export class ProjectSharingWebClient extends Component {
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");
useOwnDebugContext({ categories: ["default"] });
this.state = useState({
fullscreen: false,
});
useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", (mode) => {
if (mode !== "new") {
this.state.fullscreen = mode === "fullscreen";
}
});
useEffect(
() => {
this._showView();
},
() => []
);
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,
}
}
);
if (open_task_action) {
await this.actionService.doAction(open_task_action);
}
}
/**
* @param {MouseEvent} ev
*/
onGlobalClick(ev) {
// When a ctrl-click occurs inside an <a href/> element
// we let the browser do the default behavior and
// we do not want any other listener to execute.
if (
ev.ctrlKey &&
((ev.target instanceof HTMLAnchorElement && ev.target.href) ||
(ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])")))
) {
ev.stopImmediatePropagation();
return;
}
}
}
ProjectSharingWebClient.components = { ActionContainer, MainComponentsContainer };
ProjectSharingWebClient.template = 'project.ProjectSharingWebClient';

View file

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

View file

@ -0,0 +1,8 @@
<?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

@ -0,0 +1,105 @@
/** @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";
/**
* Compiler the portal chatter in project sharing.
*
* @param {HTMLElement} node
* @param {Object} params
* @returns
*/
function compileChatter(node, params) {
const chatterContainerXml = createElement('ChatterContainer');
const parentURLQuery = new URLSearchParams(window.parent.location.search);
setAttributes(chatterContainerXml, {
token: `'${parentURLQuery.get('access_token')}'` || '',
resModel: params.resModel,
resId: params.resId,
projectSharingId: params.projectSharingId,
});
const chatterContainerHookXml = createElement('div');
chatterContainerHookXml.classList.add('o_FormRenderer_chatterContainer');
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",
fn: (node) =>
compileChatter(node, {
resId: "props.record.resId or undefined",
resModel: "props.record.resModel",
projectSharingId: "props.record.context.active_id_chatter",
}),
});
patch(FormCompiler.prototype, 'project_sharing_chatter', {
compile(node, params) {
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
}
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
}
// after sheet bg (standard position, below form)
setAttributes(chatterContainerHookXml, {
't-if': `uiService.size < ${SIZES.XXL}`,
});
append(parentXml, chatterContainerHookXml);
return res;
}
});

View file

@ -0,0 +1,36 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { createElement } from "@web/core/utils/xml";
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';
export class ProjectSharingFormController extends FormController {
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;
}
getActionMenuItems() {
return {};
}
get translateAlert() {
return null;
}
}
ProjectSharingFormController.components = {
...FormController.components,
ChatterContainer,
}

View file

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

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

View file

@ -0,0 +1,8 @@
/** @odoo-module */
import { formView } from '@web/views/form/form_view';
import { ProjectSharingFormController } from './project_sharing_form_controller';
import { ProjectSharingFormRenderer } from './project_sharing_form_renderer';
formView.Controller = ProjectSharingFormController;
formView.Renderer = ProjectSharingFormRenderer;

View file

@ -0,0 +1,19 @@
/** @odoo-module */
import { kanbanView } from "@web/views/kanban/kanban_view";
import { KanbanDynamicGroupList, KanbanModel } from "@web/views/kanban/kanban_model";
export class ProjectSharingTaskKanbanDynamicGroupList extends KanbanDynamicGroupList {
get context() {
return {
...super.context,
project_kanban: true,
};
}
}
export class ProjectSharingTaskKanbanModel extends KanbanModel {}
ProjectSharingTaskKanbanModel.DynamicGroupList = ProjectSharingTaskKanbanDynamicGroupList;
kanbanView.Model = ProjectSharingTaskKanbanModel;

View file

@ -0,0 +1,41 @@
/** @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

@ -0,0 +1,15 @@
/** @odoo-module */
import { listView } from "@web/views/list/list_view";
import { ProjectSharingListRenderer } from "./list_renderer";
const props = listView.props;
listView.props = function (genericProps, view) {
const result = props(genericProps, view);
return {
...result,
allowSelectors: false,
};
};
listView.Renderer = ProjectSharingListRenderer;