[IMP] mail: introduce WebClientViewAttachmentView

This will allow using the component directly into the new Owl form.

task-2886642

closes odoo/odoo#95436

Related: odoo/enterprise#29182
Signed-off-by: Louis Wicket (wil) <wil@odoo.com>
This commit is contained in:
Sébastien Theys
2022-07-08 19:52:31 +02:00
parent 26b2472f49
commit 602ca8435b
14 changed files with 336 additions and 241 deletions
+3 -1
View File
@@ -77,6 +77,9 @@
'web/static/src/webclient/webclient.scss',
'web/static/src/webclient/webclient_extra.scss',
'web/static/src/core/utils/*.scss',
# depends on BS variables, can't be loaded in assets_primary or assets_secondary
'mail/static/src/scss/variables/derived_variables.scss',
'mail/static/src/scss/composer.scss',
# Dependency of notification_group, notification_request, thread_needaction_preview and thread_preview
'mail/static/src/components/notification_list/notification_list_item.scss',
'mail/static/src/component_hooks/*.js',
@@ -162,7 +165,6 @@
'web.assets_qweb': [
'mail/static/src/xml/*.xml',
'mail/static/src/components/*/*.xml',
'mail/static/src/widgets/*/*.xml',
],
},
'license': 'LGPL-3',
@@ -2,7 +2,6 @@
import { useComponentToModel } from '@mail/component_hooks/use_component_to_model';
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model';
import { useUpdate } from '@mail/component_hooks/use_update';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
import { LegacyComponent } from '@web/legacy/legacy_component';
@@ -13,15 +12,10 @@ export class Chatter extends LegacyComponent {
*/
setup() {
super.setup();
useUpdate({ func: () => this._update() });
useComponentToModel({ fieldName: 'component' });
useRefToModel({ fieldName: 'scrollPanelRef', refName: 'scrollPanel' });
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @returns {Chatter}
*/
@@ -29,26 +23,6 @@ export class Chatter extends LegacyComponent {
return this.props.record;
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_notifyRendered() {
this.trigger('o-chatter-rendered');
}
/**
* @private
*/
_update() {
if (this.chatter.thread) {
this._notifyRendered();
}
}
}
Object.assign(Chatter, {
@@ -0,0 +1,32 @@
/** @odoo-module **/
import { useComponentToModel } from '@mail/component_hooks/use_component_to_model';
import { useRefToModel } from '@mail/component_hooks/use_ref_to_model';
import { useUpdateToModel } from '@mail/component_hooks/use_update_to_model';
import { registerMessagingComponent } from '@mail/utils/messaging_component';
import { LegacyComponent } from '@web/legacy/legacy_component';
export class WebClientViewAttachmentView extends LegacyComponent {
/**
* @override
*/
setup() {
useComponentToModel({ fieldName: 'component' });
useRefToModel({ fieldName: 'iframeViewerPdfRef', refName: 'iframeViewerPdf' });
useUpdateToModel({ methodName: 'onComponentUpdate' });
}
/**
* @returns {WebClientViewAttachmentView}
*/
get webClientViewAttachmentView() {
return this.props.record;
}
}
Object.assign(WebClientViewAttachmentView, {
props: { record: Object },
template: 'mail.WebClientViewAttachmentView',
});
registerMessagingComponent(WebClientViewAttachmentView);
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mail.WebClientViewAttachmentView" owl="1">
<t t-if="webClientViewAttachmentView.thread.mainAttachment">
<t t-if="!webClientViewAttachmentView.thread.mainAttachment.isPdf">
<h3 class="mt0 mb8 ps-2 text-muted float-end"><t t-esc="webClientViewAttachmentView.thread.mainAttachment.filename"/></h3>
</t>
<div t-if="webClientViewAttachmentView.thread.mainAttachment.isImage" class="o_attachment_preview_img">
<img id="attachment_img" class="img img-fluid d-block" t-attf-src="/web/image/#{webClientViewAttachmentView.thread.mainAttachment.id}?unique=1"/>
</div>
<iframe t-if="webClientViewAttachmentView.thread.mainAttachment.isPdf" class="mb48" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{webClientViewAttachmentView.thread.mainAttachment.id}?filename%3D#{window.encodeURIComponent(webClientViewAttachmentView.thread.mainAttachment.name)}" t-ref="iframeViewerPdf"/>
<t t-if="webClientViewAttachmentView.thread.attachmentsInWebClientView.length > 1">
<a class="arrow o_move_previous text-center" href="#" t-on-click="webClientViewAttachmentView.onClickPrevious">
<span class="fa fa-chevron-left"/>
</a>
<a class="arrow o_move_next text-center" href="#" t-on-click="webClientViewAttachmentView.onClickNext">
<span class="fa fa-chevron-right"/>
</a>
</t>
</t>
</t>
</templates>
@@ -0,0 +1,91 @@
/** @odoo-module **/
import { useModels } from '@mail/component_hooks/use_models';
// ensure components are registered beforehand.
import '@mail/components/web_client_view_attachment_view/web_client_view_attachment_view';
import { replace } from '@mail/model/model_field_command';
import { getMessagingComponent } from '@mail/utils/messaging_component';
const { Component, onWillDestroy, onWillUpdateProps } = owl;
const getNextId = (function () {
let tmpId = 0;
return () => {
tmpId += 1;
return tmpId;
};
})();
/**
* This component abstracts attachment view component to its parent, so that it
* can be mounted and receive messaging data even when an attachment view
* component cannot be created. Indeed, in order to create an attachment view
* component, we must create an attachment view record, the latter requiring
* messaging to be initialized. The view may attempt to create an attachment
* view before messaging has been initialized, so this component delays the
* mounting of attachment view until it becomes initialized.
*/
export class WebClientViewAttachmentViewContainer extends Component {
/**
* @override
*/
setup() {
useModels();
super.setup();
this.webClientViewAttachmentView = undefined;
this.webClientViewAttachmentViewId = getNextId();
this._insertFromProps(this.props);
onWillUpdateProps(nextProps => this._insertFromProps(nextProps));
onWillDestroy(() => this._deleteRecord());
}
/**
* @private
*/
_deleteRecord() {
if (this.webClientViewAttachmentView) {
if (this.webClientViewAttachmentView.exists()) {
this.webClientViewAttachmentView.delete();
}
this.webClientViewAttachmentView = undefined;
}
}
/**
* @private
*/
async _insertFromProps(props) {
const messaging = await this.env.services.messaging.get();
if (this.__owl__.status === 5 /* destroyed */) {
this._deleteRecord();
return;
}
if (!props.threadId) {
this._deleteRecord();
return;
}
const thread = messaging.models['Thread'].insert({
id: props.threadId,
model: props.threadModel,
});
this.webClientViewAttachmentView = messaging.models['WebClientViewAttachmentView'].insert({
id: this.webClientViewAttachmentViewId,
thread: replace(thread),
});
this.render();
}
}
Object.assign(WebClientViewAttachmentViewContainer, {
components: { WebClientViewAttachmentView: getMessagingComponent('WebClientViewAttachmentView') },
props: {
threadId: {
type: Number,
optional: true,
},
threadModel: String,
},
template: 'mail.WebClientViewAttachmentViewContainer',
});
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.WebClientViewAttachmentViewContainer" owl="1">
<t t-if="webClientViewAttachmentView and webClientViewAttachmentView.thread.attachmentsInWebClientView.length > 0">
<div class="o_attachment_preview_container">
<WebClientViewAttachmentView record="webClientViewAttachmentView"/>
</div>
</t>
</t>
</templates>
+12
View File
@@ -579,6 +579,7 @@ registerModel({
});
}
this.update(values);
this.messaging.messagingBus.trigger('o-thread-loaded-data', { thread: this });
},
/**
* Add current user to provided thread's followers.
@@ -949,6 +950,17 @@ registerModel({
kwargs: { name: newName },
});
},
/**
* @param {Attachment} attachment
*/
async setMainAttachment(attachment) {
this.update({ mainAttachment: replace(attachment) });
await this.messaging.rpc({
model: 'ir.attachment',
method: 'register_as_main_attachment',
args: [[this.mainAttachment.id]],
});
},
/**
* Unfollow current partner from this thread.
*/
@@ -0,0 +1,64 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
import { OnChange } from '@mail/model/model_onchange';
import { hidePDFJSButtons } from '@web/legacy/js/libs/pdfjs';
registerModel({
name: 'WebClientViewAttachmentView',
identifyingFields: ['id'],
recordMethods: {
/**
* @param {MouseEvent} ev
*/
onClickNext(ev) {
ev.preventDefault();
const index = this.thread.attachmentsInWebClientView.findIndex(attachment => attachment === this.thread.mainAttachment);
this.setMainAttachmentFromIndex(index === this.thread.attachmentsInWebClientView.length - 1 ? 0 : index + 1);
},
/**
* @param {MouseEvent} ev
*/
onClickPrevious(ev) {
ev.preventDefault();
const index = this.thread.attachmentsInWebClientView.findIndex(attachment => attachment === this.thread.mainAttachment);
this.setMainAttachmentFromIndex(index === 0 ? this.thread.attachmentsInWebClientView.length - 1 : index - 1);
},
onComponentUpdate() {
if (this.iframeViewerPdfRef.el) {
hidePDFJSButtons(this.iframeViewerPdfRef.el);
}
this.component.trigger('preview_attachment_validation');
},
async setMainAttachmentFromIndex(index) {
await this.thread.setMainAttachment(this.thread.attachmentsInWebClientView[index]);
},
/**
* @private
*/
_onChangeThreadAttachmentsInWebClientView() {
if (!this.thread.mainAttachment && this.thread.attachmentsInWebClientView.length > 0) {
this.setMainAttachmentFromIndex(0);
}
},
},
fields: {
component: attr(),
id: attr({
readonly: true,
required: true,
}),
iframeViewerPdfRef: attr(),
thread: one('Thread', {
required: true,
}),
},
onChanges: [
new OnChange({
dependencies: ['thread.attachmentsInWebClientView'],
methodName: '_onChangeThreadAttachmentsInWebClientView',
}),
],
});
@@ -1,123 +0,0 @@
/** @odoo-module **/
import { replace } from '@mail/model/model_field_command';
import core from 'web.core';
import Widget from 'web.Widget';
import { hidePDFJSButtons } from '@web/legacy/js/libs/pdfjs';
const QWeb = core.qweb;
export const AttachmentViewer = Widget.extend({
className: 'o_attachment_preview_container',
events: {
'click .arrow.o_move_next': '_onClickNext',
'click .arrow.o_move_previous': '_onClickPrevious',
},
/**
* @constructor
* @override
* @param {Widget} parent
* @param {Thread} thread
*/
init(parent, thread) {
this._super.apply(this, arguments);
this.thread = thread;
this.attachments = this.thread.attachmentsInWebClientView;
this._setActive();
},
/**
* Render attachment.
*
* @override
*/
start() {
this._renderAttachment();
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Update attachments list and activeAttachment.
* @param {Thread} thread
*/
updateContents(thread) {
this.thread = thread;
this.attachments = this.thread.attachmentsInWebClientView;
this._setActive();
this._renderAttachment();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Render template
*
* @private
*/
_renderAttachment() {
this.$el.empty();
this.$el.append(QWeb.render('mail.widgets.AttachmentViewer', { thread: this.thread }));
if (this.thread.mainAttachment && this.thread.mainAttachment.isPdf) {
hidePDFJSButtons(this.el);
}
},
/**
* @private
*/
_setActive() {
if (!this.thread.mainAttachment && this.thread.attachmentsInWebClientView.length > 0) {
this._switch_main_attachment(0);
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Sets the attachment at position index as the new main attachment of
* the related model, and display it.
**/
async _switch_main_attachment(index) {
this.thread.update({ mainAttachment: replace(this.thread.attachmentsInWebClientView[index]) });
await this._rpc({
model: 'ir.attachment',
method: 'register_as_main_attachment',
args: [[this.thread.mainAttachment['id']]],
});
this._renderAttachment();
},
/**
* On click move to next attachment.
*
* @private
* @param {MouseEvent} ev
*/
_onClickNext(ev) {
ev.preventDefault();
var index = _.findIndex(this.thread.attachmentsInWebClientView, this.thread.mainAttachment);
index = index === this.thread.attachmentsInWebClientView.length - 1 ? 0 : index + 1;
this._switch_main_attachment(index);
},
/**
* On click move to previous attachment.
*
* @private
* @param {MouseEvent} ev
*/
_onClickPrevious(ev) {
ev.preventDefault();
var index = _.findIndex(this.thread.attachmentsInWebClientView, this.thread.mainAttachment);
index = index === 0 ? this.thread.attachmentsInWebClientView.length - 1 : index - 1;
this._switch_main_attachment(index);
},
});
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mail.widgets.AttachmentViewer">
<t t-if="thread.mainAttachment">
<t t-if="!thread.mainAttachment.isPdf">
<h3 class="mt0 mb8 ps-2 text-muted float-end"><t t-esc="thread.mainAttachment.filename"/></h3>
</t>
<div t-if="thread.mainAttachment.isImage" class="o_attachment_preview_img">
<img id="attachment_img" class="img img-fluid d-block" t-attf-src="/web/image/#{thread.mainAttachment.id}?unique=1"/>
</div>
<iframe t-if="thread.mainAttachment.isPdf" class="mb48" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{thread.mainAttachment.id}?filename%3D#{window.encodeURIComponent(thread.mainAttachment.name)}"/>
<t t-if="thread.attachmentsInWebClientView.length > 1">
<a class="arrow o_move_previous text-center" href="#">
<span class="fa fa-chevron-left"/>
</a>
<a class="arrow o_move_next text-center" href="#">
<span class="fa fa-chevron-right"/>
</a>
</t>
</t>
</t>
</templates>
@@ -1,7 +1,8 @@
/** @odoo-module **/
import { ChatterContainer } from '@mail/components/chatter_container/chatter_container';
import { AttachmentViewer } from '@mail/widgets/attachment_viewer/attachment_viewer';
import { WebClientViewAttachmentViewContainer } from '@mail/components/web_client_view_attachment_view_container/web_client_view_attachment_view_container';
import { Listener } from '@mail/model/model_listener';
import dom from 'web.dom';
import FormRenderer from 'web.FormRenderer';
@@ -23,6 +24,10 @@ FormRenderer.include({
*/
init(parent, state, params) {
this._super(...arguments);
this.modelsListener = new Listener({
name: 'interchangeChatter',
onChange: () => this._interchangeChatter(),
});
this.hasChatter = params.hasChatter && !params.isFromFormViewDialog;
this.isChatterInSheet = false;
this.hasAttachmentViewerFeature = params.hasAttachmentViewerFeature;
@@ -49,14 +54,15 @@ FormRenderer.include({
* when applying the rendering into the DOM.
*/
this.chatterContainerTargetPlaceholder = undefined;
this.attachmentViewer = undefined;
this.webClientViewAttachmentViewContainer = undefined;
this.attachmentViewerTarget = undefined;
if (this.hasAttachmentViewerFeature) {
this.attachmentViewerTarget = document.createElement("div");
this.attachmentViewerTarget.classList.add("o_attachment_preview");
this.webClientViewAttachmentViewContainer = new ComponentWrapper(this, WebClientViewAttachmentViewContainer, this._makeWebClientViewAttachmentViewContainerProps());
this.webClientViewAttachmentViewContainer.mount(this.attachmentViewerTarget);
}
this.attachmentViewerTargetPlaceholder = undefined;
this.on('o_chatter_rendered', this, ev => this._onChatterRendered(ev));
},
async initChatter() {
this._chatterContainerComponent = new ChatterContainerWrapperComponent(
@@ -99,6 +105,9 @@ FormRenderer.include({
if (this.hasChatter) {
await this._updateChatterContainerComponent();
}
if (this.hasAttachmentViewer()) {
await this._updateWebClientViewAttachmentViewContainer();
}
},
/**
* The last rendering of the form view has just been applied into the DOM,
@@ -135,8 +144,10 @@ FormRenderer.include({
*/
destroy() {
this._super(...arguments);
if (owl.Component.env.services.messaging) {
owl.Component.env.services.messaging.modelManager.removeListener(this.modelsListener);
}
this._chatterContainerComponent = undefined;
this.off('o_chatter_rendered', this);
},
//--------------------------------------------------------------------------
@@ -176,10 +187,13 @@ FormRenderer.include({
}
$(this._chatterContainerTarget).insertAfter($sheetBg);
}
if (this.hasAttachmentViewer()) {
$(this.attachmentViewerTarget).insertAfter($sheetBg);
} else {
dom.append($sheetBg, $(this.attachmentViewerTarget));
if (this.hasAttachmentViewerFeature) {
if (this.hasAttachmentViewer()) {
$(this.attachmentViewerTarget).insertAfter($sheetBg);
this._updateWebClientViewAttachmentViewContainer();
} else {
this.attachmentViewerTarget.remove();
}
}
this._updateChatterContainerComponent();
},
@@ -211,55 +225,24 @@ FormRenderer.include({
threadModel: this.state.model,
};
},
_makeWebClientViewAttachmentViewContainerProps() {
return {
threadId: this.state.res_id,
threadModel: this.state.model,
};
},
/**
* @private
*/
async _updateChatterContainerComponent() {
const props = this._makeChatterContainerProps();
try {
await this._chatterContainerComponent.update(props);
} catch (error) {
if (error.message !== "Mounting operation cancelled") {
throw error;
}
}
await this._chatterContainerComponent.update(props);
},
/**
* @private
* @param {OdooEvent} ev
* @param {Object} ev.data
* @param {Attachment[]} ev.data.attachments
* @param {Thread} ev.data.thread
*/
_onChatterRendered(ev) {
if (!this.hasAttachmentViewer()) {
if (this.attachmentViewer) {
this.attachmentViewer.destroy();
this.attachmentViewer = undefined;
this._interchangeChatter();
}
return;
}
var self = this;
const thread = this.messaging.models['Thread'].insert({
id: this.state.res_id,
model: this.state.model,
});
if (this.attachmentViewer) {
// FIXME should be improved : what if somehow an attachment is replaced in a thread ?
if (
this.attachmentViewer.thread !== thread ||
this.attachmentViewer.attachments.length !== thread.attachmentsInWebClientView.length
) {
this.attachmentViewer.updateContents(thread);
}
this.trigger_up('preview_attachment_validation');
} else {
this.attachmentViewer = new AttachmentViewer(this, thread);
this.attachmentViewer.appendTo($(this.attachmentViewerTarget)).then(function () {
self.trigger_up('preview_attachment_validation');
self._interchangeChatter();
});
}
async _updateWebClientViewAttachmentViewContainer() {
const props = this._makeWebClientViewAttachmentViewContainerProps();
await this.webClientViewAttachmentViewContainer.update(props);
},
});
@@ -125,6 +125,10 @@ patch(MockServer.prototype, 'mail', {
if (new RegExp('/mail/channel/\\d+/partner/\\d+/avatar_128').test(route)) {
return;
}
if (args.model === 'ir.attachment' && args.method === 'register_as_main_attachment') {
const ids = args.args[0];
return this._mockIrAttachmentRegisterAsMainAttachment(ids);
}
// mail.activity methods
if (args.model === 'mail.activity' && args.method === 'action_feedback') {
const ids = args.args[0];
@@ -528,7 +532,7 @@ patch(MockServer.prototype, 'mail', {
[['res_id', '=', thread.id], ['res_model', '=', thread_model]],
); // order not done for simplicity
res['attachments'] = this._mockIrAttachment_attachmentFormat(attachments.map(attachment => attachment.id));
res['mainAttachment'] = thread.message_main_attachment_id ? [['insert-and-replace', { 'id': thread.message_main_attachment_id }]] : [['clear']];
res['mainAttachment'] = thread.message_main_attachment_id ? [['insert-and-replace', { 'id': thread.message_main_attachment_id[0] }]] : [['clear']];
}
if (request_list.includes('followers')) {
const followers = this.pyEnv['mail.followers'].searchRead([['id', 'in', thread.message_follower_ids || []]]);
@@ -613,6 +617,31 @@ patch(MockServer.prototype, 'mail', {
return res;
});
},
/**
* Simulates `register_as_main_attachment` on `ir.attachment`.
*
* @private
* @param {integer} ids
* @param {boolean} [force=true]
* @returns {boolean} dummy value for mock server
*/
_mockIrAttachmentRegisterAsMainAttachment(ids, force = true) {
const [attachment] = this.getRecords('ir.attachment', [['id', 'in', ids]]);
if (!attachment.res_model) {
return true; // dummy value for mock server
}
if (!this.models[attachment.res_model].fields['message_main_attachment_id']) {
return true; // dummy value for mock server
}
const [record] = this.pyEnv[attachment.res_model].searchRead([['id', '=', attachment.res_id]]);
if (force || !record.message_main_attachment_id) {
this.pyEnv[attachment.res_model].write(
[record.id],
{ message_main_attachment_id: attachment.id },
);
}
return true; // dummy value for mock server
},
/**
* Simulates `_action_done` on `mail.activity`.
*
+36 -17
View File
@@ -446,25 +446,44 @@ function getOpenDiscuss(afterEvent, webClient, { context = {}, params, ...props
}
function getOpenFormView(afterEvent, openView) {
return async function openFormView(action, { props, waitUntilMessagesLoaded = true } = {}) {
return async function openFormView(action, { props, waitUntilDataLoaded = true, waitUntilMessagesLoaded = true } = {}) {
action['views'] = [[false, 'form']];
if (waitUntilMessagesLoaded) {
return afterNextRender(() => afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => openView(action, props),
message: "should wait until chatter loaded its messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer &&
threadViewer.thread.model === action.res_model &&
threadViewer.thread.id === action.res_id
)
},
}));
const func = () => openView(action, props);
const waitData = func => afterNextRender(() => afterEvent({
eventName: 'o-thread-loaded-data',
func,
message: "should wait until chatter loaded its data",
predicate: ({ thread }) => {
return (
thread.model === action.res_model &&
thread.id === action.res_id
);
},
}));
const waitMessages = func => afterNextRender(() => afterEvent({
eventName: 'o-thread-view-hint-processed',
func,
message: "should wait until chatter loaded its messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer &&
threadViewer.thread.model === action.res_model &&
threadViewer.thread.id === action.res_id
);
},
}));
if (waitUntilDataLoaded && waitUntilMessagesLoaded) {
return waitData(() => waitMessages(func));
}
return openView(action, props);
}
if (waitUntilDataLoaded) {
return waitData(func);
}
if (waitUntilMessagesLoaded) {
return waitMessages(func);
}
return func();
};
}
//------------------------------------------------------------------------------