[REF] project: convert project sharing form view in OWL
Before this commit, the form view in project sharing was always in legacy. Moreover, in that view, the chatter used is the portal one and so it is also a legacy widget. This commit converts the form view and then convert the chatter in OWL to be able to correctly compile and add the chatter in the form view. Also, some styling has been done to remove the overflow. Only the overflow on the y axis in the chatter can be scrolled on the page. task-2947516 X-original-commit: 175430f3593337ce582cbec644b9d630894c074b
This commit is contained in:
@@ -79,6 +79,7 @@
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'project/static/src/scss/portal_rating.scss',
|
||||
'project/static/src/scss/project_sharing_frontend.scss',
|
||||
'project/static/src/js/portal_rating.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
@@ -95,18 +96,12 @@
|
||||
|
||||
('remove', 'mail/static/src/components/dialog_manager/dialog_manager.js'),
|
||||
('remove', 'mail/static/src/components/chat_window_manager/chat_window_manager.js'),
|
||||
('remove', 'mail/static/src/views/form/*'),
|
||||
|
||||
'web/static/src/legacy/js/public/public_widget.js',
|
||||
'portal/static/src/js/portal_chatter.js',
|
||||
'portal/static/src/js/portal_composer.js',
|
||||
'project/static/src/project_sharing/search/favorite_menu/custom_favorite_item.xml',
|
||||
'project/static/src/project_sharing/**/*.js',
|
||||
'project/static/src/scss/project_sharing/*',
|
||||
'project/static/src/project_sharing/**/*',
|
||||
'web/static/src/start.js',
|
||||
'web/static/src/legacy/legacy_setup.js',
|
||||
|
||||
'project/static/src/project_sharing/**/*.xml',
|
||||
'portal/static/src/xml/portal_chatter.xml',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { PortalChatter } from 'portal.chatter';
|
||||
import Composer from './composer';
|
||||
|
||||
export default PortalChatter.extend({
|
||||
messageFetch(domain) {
|
||||
if (!this.options.res_id) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
_chatterInit() {
|
||||
if (!this.options.res_id) {
|
||||
this.result = { messages: [] };
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
_messageFetchPrepareParams() {
|
||||
const data = this._super.apply(this, arguments);
|
||||
if (this.options.project_sharing_id) {
|
||||
data.project_sharing_id = this.options.project_sharing_id;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
_createComposerWidget() {
|
||||
return new Composer(this, this.options);
|
||||
},
|
||||
update(props) {
|
||||
this._setOptions(props);
|
||||
this._reloadChatterContent();
|
||||
},
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-extend="portal.chatter_messages">
|
||||
<t t-jquery="t[t-as='message']" t-operation="attributes">
|
||||
<attribute name="t-foreach">!!widget.options.res_id && widget.get('messages') || []</attribute>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
+4
-3
@@ -19,11 +19,12 @@
|
||||
.o_portal_chatter_attachments {
|
||||
margin-bottom: 1rem;
|
||||
.o_portal_chatter_attachment {
|
||||
.o_portal_chatter_attachment_delete {
|
||||
> button {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
opacity: 0;
|
||||
}
|
||||
&:hover .o_portal_chatter_attachment_delete {
|
||||
|
||||
&:hover > button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +56,7 @@
|
||||
.o_portal_chatter {
|
||||
.o_portal_chatter_header {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 2px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.o_portal_chatter_composer, .o_portal_chatter_messages {
|
||||
+15
@@ -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 () => {},
|
||||
};
|
||||
+28
@@ -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&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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
if (nextProps.resId) {
|
||||
this.initChatter(this.messagesParams(nextProps));
|
||||
} else {
|
||||
this.state.messages = [];
|
||||
this.options = {};
|
||||
}
|
||||
}
|
||||
|
||||
async onChangePage(page) {
|
||||
this.state.currentPage = page;
|
||||
await this.fetchMessages();
|
||||
}
|
||||
|
||||
async initChatter(params) {
|
||||
const chatterData = await this.rpc(
|
||||
'/mail/chatter_init',
|
||||
params,
|
||||
);
|
||||
this.state.messages = this.preprocessMessages(chatterData.messages);
|
||||
this.options = chatterData.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';
|
||||
@@ -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 {{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>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/** @odoo-module */
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ChatterMessageCounter extends Component { }
|
||||
|
||||
ChatterMessageCounter.props = {
|
||||
count: Number,
|
||||
};
|
||||
ChatterMessageCounter.template = 'project.ChatterMessageCounter';
|
||||
+16
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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 > 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>
|
||||
@@ -1,17 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { PortalComposer } from 'portal.composer';
|
||||
|
||||
export default PortalComposer.extend({
|
||||
_prepareAttachmentData() {
|
||||
const data = this._super.apply(this, arguments);
|
||||
const newData = {};
|
||||
if (this.options.display_composer && typeof this.options.display_composer == 'string') {
|
||||
// then we should have the access_token of the task
|
||||
newData.access_token = this.options.display_composer;
|
||||
} else {
|
||||
newData.project_sharing_id = this.options.project_sharing_id;
|
||||
}
|
||||
return Object.assign(data, newData);
|
||||
},
|
||||
});
|
||||
+33
@@ -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,
|
||||
};
|
||||
+21
@@ -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>
|
||||
+46
@@ -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: '',
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import FormController from 'web.FormController';
|
||||
import BasicController from 'web.BasicController';
|
||||
|
||||
export default FormController.extend({
|
||||
_getActionMenuItems: BasicController.prototype._getActionMenuItems,
|
||||
});
|
||||
+105
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
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 = document.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 {};
|
||||
}
|
||||
}
|
||||
|
||||
ProjectSharingFormController.components = {
|
||||
...FormController.components,
|
||||
ChatterContainer,
|
||||
}
|
||||
+12
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,52 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import '@mail/widgets/form_renderer/form_renderer'; // ensure mail overrides are applied first
|
||||
|
||||
import FormRenderer from 'web.FormRenderer';
|
||||
import Chatter from '@project/project_sharing/components/chatter';
|
||||
import { session } from '@web/session';
|
||||
|
||||
export default FormRenderer.extend({
|
||||
/**
|
||||
* Creates the portal chatter instead of the standard chatter.
|
||||
*
|
||||
* This overridden method is expected to be called from proper lifecycle
|
||||
* method of mail form renderer, when `this._chatterContainerTarget` is set.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
initChatter() {
|
||||
const options = this.makePortalChatterOptions();
|
||||
this.portalChatter = new Chatter(this, options);
|
||||
this.portalChatter.appendTo(this._chatterContainerTarget);
|
||||
},
|
||||
/**
|
||||
* Updates the options of the portal chatter instead of the props of the
|
||||
* standard chatter.
|
||||
*
|
||||
* This overridden method is expected to be called from proper lifecycle
|
||||
* method of mail form renderer.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_updateChatterContainerComponent() {
|
||||
const options = this.makePortalChatterOptions();
|
||||
this.portalChatter.update(options);
|
||||
},
|
||||
makePortalChatterOptions() {
|
||||
// FIXME: perhaps check if we have a parent in the window ?
|
||||
// Call the parent window to get the url displayed in the browser since we are in an iframe
|
||||
const query = new URLSearchParams(window.parent.location.search);
|
||||
return {
|
||||
token: query.get('access_token') || '',
|
||||
res_model: 'project.task',
|
||||
pid: '',
|
||||
hash: '',
|
||||
res_id: this.state.res_id,
|
||||
pager_step: 10,
|
||||
allow_composer: !!this.state.res_id,
|
||||
two_columns: false,
|
||||
project_sharing_id: session.project_id,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import FormView from 'web.FormView';
|
||||
import Controller from './controller';
|
||||
import Renderer from './renderer';
|
||||
|
||||
export default FormView.extend({
|
||||
config: _.extend({}, FormView.prototype.config, {
|
||||
Controller,
|
||||
Renderer,
|
||||
}),
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import ViewRegistry from 'web.view_registry';
|
||||
import { registry } from '@web/core/registry';
|
||||
// FIXME: make sure the form view are define before we remove them below
|
||||
import "@web/views/form/form_view";
|
||||
|
||||
import FormView from './form/view';
|
||||
|
||||
// FIXME: remove new views to force the use of the overriden legacy views when
|
||||
// project sharing form is converted to new views.
|
||||
registry.category("views").remove("form");
|
||||
|
||||
ViewRegistry
|
||||
.add('form', FormView);
|
||||
@@ -0,0 +1,3 @@
|
||||
.o_project_sharing_container > main {
|
||||
display: flex;
|
||||
}
|
||||
@@ -49,14 +49,14 @@ const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_p
|
||||
trigger: '.o_kanban_quick_create .o_kanban_edit',
|
||||
content: 'Go to the form view of this new task',
|
||||
}, {
|
||||
trigger: 'div.o_statusbar_status[name="stage_id"] button[aria-checked="false"]:contains(Done)',
|
||||
trigger: 'div[name="stage_id"] div.o_statusbar_status button[aria-checked="false"]:contains(Done)',
|
||||
content: 'Change the stage of the task.',
|
||||
}, {
|
||||
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body textarea[name="message"]',
|
||||
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body textarea',
|
||||
content: 'Write a message in the chatter of the task',
|
||||
run: 'text I create a new task for testing purpose.',
|
||||
}, {
|
||||
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body button[data-action="/mail/chatter_post"]',
|
||||
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body button[name="send_message"]',
|
||||
content: 'Send the message',
|
||||
}, {
|
||||
trigger: 'ol.breadcrumb > li.o_back_button > a:contains(Project Sharing)',
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestProjectSharingUi(HttpCase):
|
||||
|
||||
def test_01_project_sharing(self):
|
||||
""" Test Project Sharing UI with an internal user """
|
||||
self.start_tour("/web?debug=assets", 'project_sharing_tour', login="admin")
|
||||
self.start_tour("/web", 'project_sharing_tour', login="admin")
|
||||
|
||||
def test_02_project_sharing(self):
|
||||
""" Test project sharing ui with a portal user.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
<template id="project_sharing_portal" name="Project Sharing View in Portal">
|
||||
<t t-call="portal.frontend_layout">
|
||||
<!-- To add the class on div#wrapwrap to remove the overflow -->
|
||||
<t t-set="pageName" t-value="'o_project_sharing_container'"/>
|
||||
<t t-set="no_footer" t-value="true"/>
|
||||
<t t-call="project.project_sharing"/>
|
||||
</t>
|
||||
@@ -10,7 +12,7 @@
|
||||
|
||||
<template id="project_sharing" name="Project Sharing View">
|
||||
<!-- We need to forward the request lang to ensure that the lang set on the portal match the lang delivered -->
|
||||
<iframe width="100%" height="100%" frameborder="0" t-attf-src="/{{ request.context['lang'] }}/my/projects/{{ str(project_id) }}/project_sharing{{ '?task_id=' + task_id if task_id else '' }}"/>
|
||||
<iframe class="flex-grow-1" frameborder="0" t-attf-src="/{{ request.context['lang'] }}/my/projects/{{ str(project_id) }}/project_sharing{{ '?task_id=' + task_id if task_id else '' }}"/>
|
||||
</template>
|
||||
|
||||
<template id="project_sharing_embed" name="Project Sharing View Embed">
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_body">
|
||||
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
|
||||
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}" context="{'project_id': project_id}"/>
|
||||
<div t-if="record.date_deadline.raw_value" name="date_deadline" attrs="{'invisible': [('is_closed', '=', True)]}">
|
||||
<field name="date_deadline" widget="remaining_days"/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user