diff options
| author | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
|---|---|---|
| committer | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
| commit | 3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch) | |
| tree | 5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src/components | |
| parent | 0a82848606151ef60eb6523e9c4c161171f214e7 (diff) | |
[REF] mail, *: refactor messaging with OWL
This commit is a significant rewriting of client-side discuss, chatter,
chat window, and messaging menu using OWL. The behavior should be broadly
the same, with some slight functional changes here and there.
From a technical standpoint, the code of messaging is mainly organized in 2
main groups of modules:
- models, which are logical entities that depict the client-side state of
messaging as a whole.
- components, which are in charge of displaying information from models.
This refactoring also introduces new JS guidelines regarding folder structure
(/static) and naming rules for JS modules.
Community PR: https://github.com/odoo/odoo/pull/39023
Enterprise PR: https://github.com/odoo/enterprise/pull/6249
Task-1914207
This PR is a collaborative work by Alexandre, Julien, Sébastien and Xavier,
with the precious help of Lucas to speed it up towards the end.
closes odoo/odoo#39023
Related: odoo/enterprise#6249
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Co-authored-by: Alexandre Kühn <aku@odoo.com>
Co-authored-by: Julien Giannone <jgi@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
Co-authored-by: Xavier Dubuc <xdu@odoo.com>
Diffstat (limited to 'addons/mail/static/src/components')
178 files changed, 30148 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/activity/activity.js b/addons/mail/static/src/components/activity/activity.js new file mode 100644 index 00000000000..6c70f876160 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.js @@ -0,0 +1,177 @@ +odoo.define('mail/static/src/components/activity/activity.js', function (require) { +'use strict'; + +const components = { + ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), + MailTemplate: require('mail/static/src/components/mail_template/mail_template.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { + auto_str_to_date, + getLangDateFormat, + getLangDatetimeFormat, +} = require('web.time'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +class Activity extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + areDetailsVisible: false, + }); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + assigneeNameOrDisplayName: ( + activity && + activity.assignee && + activity.assignee.nameOrDisplayName() + ), + }; + }); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {string} + */ + get assignedUserText() { + return _.str.sprintf(this.env._t("for %s"), this.activity.assignee.nameOrDisplayName()); + } + + /** + * @returns {string} + */ + get delayLabel() { + const today = moment().startOf('day'); + const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline)); + // true means no rounding + const diff = momentDeadlineDate.diff(today, 'days', true); + if (diff === 0) { + return this.env._t("Today:"); + } else if (diff === -1) { + return this.env._t("Yesterday:"); + } else if (diff < 0) { + return _.str.sprintf(this.env._t("%d days overdue:"), Math.abs(diff)); + } else if (diff === 1) { + return this.env._t("Tomorrow:"); + } else { + return _.str.sprintf(this.env._t("Due in %d days:"), Math.abs(diff)); + } + } + + /** + * @returns {string} + */ + get formattedCreateDatetime() { + const momentCreateDate = moment(auto_str_to_date(this.activity.dateCreate)); + const datetimeFormat = getLangDatetimeFormat(); + return momentCreateDate.format(datetimeFormat); + } + + /** + * @returns {string} + */ + get formattedDeadlineDate() { + const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline)); + const datetimeFormat = getLangDateFormat(); + return momentDeadlineDate.format(datetimeFormat); + } + + /** + * @returns {string} + */ + get MARK_DONE() { + return this.env._t("Mark Done"); + } + + /** + * @returns {string} + */ + get summary() { + return _.str.sprintf(this.env._t("“%s”"), this.activity.summary); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.attachment} ev.detail.attachment + */ + _onAttachmentCreated(ev) { + this.activity.markAsDone({ attachments: [ev.detail.attachment] }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickCancel(ev) { + ev.preventDefault(); + this.activity.deleteServerRecord(); + } + + /** + * @private + */ + _onClickDetailsButton() { + this.state.areDetailsVisible = !this.state.areDetailsVisible; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEdit(ev) { + this.activity.edit(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUploadDocument(ev) { + this._fileUploaderRef.comp.openBrowserFileUploader(); + } + +} + +Object.assign(Activity, { + components, + props: { + activityLocalId: String, + }, + template: 'mail.Activity', +}); + +return Activity; + +}); diff --git a/addons/mail/static/src/components/activity/activity.scss b/addons/mail/static/src/components/activity/activity.scss new file mode 100644 index 00000000000..96117afd9ea --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.scss @@ -0,0 +1,174 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Activity { + display: flex; + flex: 0 0 auto; + padding: map-get($spacers, 2); +} + +.o_Activity_detailsUserAvatar { + margin-inline-end: map-get($spacers, 2); + object-fit: cover; + height: 18px; + width: 18px; +} + +.o_Activity_dueDateText, .o_Activity_summary { + margin-inline-end: map-get($spacers, 2); +} + +.o_Activity_iconContainer { + @include o-position-absolute($top: auto, $left: auto, $bottom: -5px, $right: -5px); + display: flex; + align-items: center; + justify-content: center; + width: 25px; + height: 25px; + border-width: 2px; +} + +.o_Activity_info { + display: flex; + align-items: baseline; +} + +.o_Activity_sidebar { + display: flex; + flex: 0 0 36px; + margin-right: map-get($spacers, 3); + justify-content: center; +} + +.o_Activity_toolButton { + padding-top: map-get($spacers, 0); +} + +.o_Activity_tools { + display: flex; +} + +.o_Activity_user { + height: 36px; + margin-left: map-get($spacers, 2); + margin-right: map-get($spacers, 2); + position: relative; + width: 36px; +} + +.o_Activity_userAvatar { + height: map-get($sizes, 100); + width: map-get($sizes, 100); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +$o-mail-activity-default-color: gray('300') !default; +$o-mail-activity-overdue-color: darken(theme-color('danger'), 10%) !default; +$o-mail-activity-planned-color: darken(theme-color('success'), 10%) !default; +$o-mail-activity-today-color: darken(theme-color('warning'), 10%) !default; + + +.o_Activity_deadlineDateText { + &.o-default { + color: $o-mail-activity-default-color; + } + + &.o-overdue { + color: $o-mail-activity-overdue-color; + } + + &.o-planned { + color: $o-mail-activity-planned-color; + } + + &.o-today { + color: $o-mail-activity-today-color; + } +} + +.o_Activity_details { + color: gray('500'); +} + +.o_Activity_detailsCreatorAvatar { + margin-inline-start: map-get($spacers, 2); +} + +.o_Activity_detailsUserAvatar { + border-radius: 50%; +} + +.o_Activity_dueDateText { + font-weight: bolder; + + &.o-default { + color: $o-mail-activity-default-color; + } + + &.o-overdue { + color: $o-mail-activity-overdue-color; + } + + &.o-planned { + color: $o-mail-activity-planned-color; + } + + &.o-today { + color: $o-mail-activity-today-color; + } +} + +/* Needed specifity to counter default bootstrap style */ +a:not([href]):not([tabindex]).o_Activity_detailsButton { + background: transparent; + opacity: 0.5; + color: gray('500'); + + &:hover { + opacity: 1; + color: gray('600'); + } +} + +.o_Activity_detailsCreator { + font-weight: bold; +} + +.o_Activity_iconContainer { + color: white; + border-color: white; + border-radius: 100%; + border-style: solid; +} + +.o_Activity_sidebar { + font-size: smaller; +} + +.o_Activity_summary { + font-weight: bolder; + color: gray('900'); +} + +.o_Activity_toolButton { + opacity: 0.5; + color: gray('500'); + + &:hover { + opacity: 1; + color: gray('600'); + } +} + +.o_Activity_userAvatar { + border-radius: 50%; +} + +.o_Activity_userName { + color: gray('500'); +} + diff --git a/addons/mail/static/src/components/activity/activity.xml b/addons/mail/static/src/components/activity/activity.xml new file mode 100644 index 00000000000..22f4f9dddc7 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Activity" owl="1"> + <div class="o_Activity"> + <t t-if="activity"> + <div class="o_Activity_sidebar"> + <div class="o_Activity_user"> + <t t-if="activity.assignee"> + <img class="o_Activity_userAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-alt="activity.assignee.nameOrDisplayName()"/> + </t> + <div class="o_Activity_iconContainer" + t-att-class="{ + 'bg-success-full': activity.state === 'planned', + 'bg-warning-full': activity.state === 'today', + 'bg-danger-full': activity.state === 'overdue', + }" + > + <i class="o_Activity_icon fa" t-attf-class="{{ activity.icon }}"/> + </div> + </div> + </div> + <div class="o_Activity_core"> + <div class="o_Activity_info"> + <div class="o_Activity_dueDateText" + t-att-class="{ + 'o-default': activity.state === 'default', + 'o-overdue': activity.state === 'overdue', + 'o-planned': activity.state === 'planned', + 'o-today': activity.state === 'today', + }" + > + <t t-esc="delayLabel"/> + </div> + <t t-if="activity.summary"> + <div class="o_Activity_summary"> + <t t-esc="summary"/> + </div> + </t> + <t t-elif="activity.type"> + <div class="o_Activity_summary o_Activity_type"> + <t t-esc="activity.type.displayName"/> + </div> + </t> + <t t-if="activity.assignee"> + <div class="o_Activity_userName"> + <t t-esc="assignedUserText"/> + </div> + </t> + <a class="o_Activity_detailsButton btn btn-link" t-on-click="_onClickDetailsButton" role="button"> + <i class="fa fa-info-circle" role="img" title="Info"/> + </a> + </div> + + <div class="o_Activity_details" t-if="state.areDetailsVisible"> + <dl class="dl-horizontal"> + <t t-if="activity.type"> + <dt>Activity type</dt> + <dd class="o_Activity_type"> + <t t-esc="activity.type.displayName"/> + </dd> + </t> + <t t-if="activity.creator"> + <dt>Created</dt> + <dd class="o_Activity_detailsCreation"> + <t t-esc="formattedCreateDatetime"/> + <img class="o_Activity_detailsUserAvatar o_Activity_detailsCreatorAvatar" t-attf-src="/web/image/res.users/{{ activity.creator.id }}/image_128" t-att-title="activity.creator.nameOrDisplayName()" t-att-alt="activity.creator.nameOrDisplayName()"/> + <span class="o_Activity_detailsCreator"> + <t t-esc="activity.creator.nameOrDisplayName()"/> + </span> + </dd> + </t> + <t t-if="activity.assignee"> + <dt>Assigned to</dt> + <dd class="o_Activity_detailsAssignation"> + <img class="o_Activity_detailsUserAvatar o_Activity_detailsAssignationUserAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-title="activity.assignee.nameOrDisplayName()" t-att-alt="activity.assignee.nameOrDisplayName()"/> + <t t-esc="activity.assignee.nameOrDisplayName()"/> + </dd> + </t> + <dt>Due on</dt> + <dd class="o_Activity_detailsDueDate"> + <span class="o_Activity_deadlineDateText" + t-att-class="{ + 'o-default': activity.state === 'default', + 'o-overdue': activity.state === 'overdue', + 'o-planned': activity.state === 'planned', + 'o-today': activity.state === 'today', + }" + > + <t t-esc="formattedDeadlineDate"/> + </span> + </dd> + </dl> + </div> + + <div class="o_Activity_note" t-if="activity.note"> + <t t-raw="activity.note"/> + </div> + + <t t-if="activity.mailTemplates.length > 0"> + <div class="o_Activity_mailTemplates"> + <t t-foreach="activity.mailTemplates" t-as="mailTemplate" t-key="mailTemplate.localId"> + <MailTemplate + class="o_Activity_mailTemplate" + activityLocalId="activity.localId" + mailTemplateLocalId="mailTemplate.localId" + /> + </t> + </div> + </t> + + <t t-if="activity.canWrite"> + <div name="tools" class="o_Activity_tools"> + <t t-if="activity.category !== 'upload_file'"> + <Popover position="'right'" title="MARK_DONE"> + <button class="o_Activity_toolButton o_Activity_markDoneButton btn btn-link" t-att-title="MARK_DONE"> + <i class="fa fa-check"/> Mark Done + </button> + <t t-set="opened"> + <ActivityMarkDonePopover activityLocalId="props.activityLocalId"/> + </t> + </Popover> + </t> + <t t-else=""> + <button class="o_Activity_toolButton o_Activity_uploadButton btn btn-link" t-on-click="_onClickUploadDocument"> + <i class="fa fa-upload"/> Upload Document + </button> + <FileUploader + attachmentLocalIds="activity.attachments.map(attachment => attachment.localId)" + uploadId="activity.res_id" + uploadModel="activity.res_model" + t-on-o-attachment-created="_onAttachmentCreated" + t-ref="fileUploader" + /> + </t> + <button class="o_Activity_toolButton o_Activity_editButton btn btn-link" t-on-click="_onClickEdit"> + <i class="fa fa-pencil"/> Edit + </button> + <button class="o_Activity_toolButton o_Activity_cancelButton btn btn-link" t-on-click="_onClickCancel" > + <i class="fa fa-times"/> Cancel + </button> + </div> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity/activity_tests.js b/addons/mail/static/src/components/activity/activity_tests.js new file mode 100644 index 00000000000..714f0d593eb --- /dev/null +++ b/addons/mail/static/src/components/activity/activity_tests.js @@ -0,0 +1,981 @@ +odoo.define('mail/static/src/components/activity/activity_tests.js', function (require) { +'use strict'; + +const components = { + Activity: require('mail/static/src/components/activity/activity.js'), +}; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Bus = require('web.Bus'); +const { date_to_str } = require('web.time'); + +const { Component, tags: { xml } } = owl; + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('activity', {}, function () { +QUnit.module('activity_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createActivityComponent = async function (activity) { + const ActivityComponent = components.Activity; + ActivityComponent.env = this.env; + this.component = new ActivityComponent(null, { + activityLocalId: activity.localId, + }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + }, +}); + +QUnit.test('activity simplest layout', async function (assert) { + assert.expect(12); + + await this.start(); + const activity = this.env.models['mail.activity'].create(); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_sidebar').length, + 1, + "should have activity sidebar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_core').length, + 1, + "should have activity core" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_user').length, + 1, + "should have activity user" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_info').length, + 1, + "should have activity info" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_note').length, + 0, + "should not have activity note" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "should not have activity details" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplates').length, + 0, + "should not have activity mail templates" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_editButton').length, + 0, + "should not have activity Edit button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_cancelButton').length, + 0, + "should not have activity Cancel button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_markDoneButton').length, + 0, + "should not have activity Mark as Done button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_uploadButton').length, + 0, + "should not have activity Upload button" + ); +}); + +QUnit.test('activity with note layout', async function (assert) { + assert.expect(3); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + note: 'There is no good or bad note' + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_note').length, + 1, + "should have activity note" + ); + assert.strictEqual( + document.querySelector('.o_Activity_note').textContent, + "There is no good or bad note", + "activity note should be 'There is no good or bad note'" + ); +}); + +QUnit.test('activity info layout when planned after tomorrow', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const fiveDaysFromNow = new Date(); + fiveDaysFromNow.setDate(today.getDate() + 5); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(fiveDaysFromNow), + state: 'planned', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'), + "activity delay should have the right color modifier class (planned)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Due in 5 days:", + "activity delay should have 'Due in 5 days:' as label" + ); +}); + +QUnit.test('activity info layout when planned tomorrow', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(tomorrow), + state: 'planned', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'), + "activity delay should have the right color modifier class (planned)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + 'Tomorrow:', + "activity delay should have 'Tomorrow:' as label" + ); +}); + +QUnit.test('activity info layout when planned today', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(today), + state: 'today', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-today'), + "activity delay should have the right color modifier class (today)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Today:", + "activity delay should have 'Today:' as label" + ); +}); + +QUnit.test('activity info layout when planned yesterday', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(yesterday), + state: 'overdue', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'), + "activity delay should have the right color modifier class (overdue)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Yesterday:", + "activity delay should have 'Yesterday:' as label" + ); +}); + +QUnit.test('activity info layout when planned before yesterday', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const fiveDaysBeforeNow = new Date(); + fiveDaysBeforeNow.setDate(today.getDate() - 5); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(fiveDaysBeforeNow), + state: 'overdue', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'), + "activity delay should have the right color modifier class (overdue)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "5 days overdue:", + "activity delay should have '5 days overdue:' as label" + ); +}); + +QUnit.test('activity with a summary layout', async function (assert) { + assert.expect(4); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + summary: 'test summary', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary').length, + 1, + "should have activity summary" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_type').length, + 0, + "should not have the activity type as summary" + ); + assert.strictEqual( + document.querySelector('.o_Activity_summary').textContent.trim(), + "“test summary”", + "should have the specific activity summary in activity summary" + ); +}); + +QUnit.test('activity without summary layout', async function (assert) { + assert.expect(5); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_type').length, + 1, + "activity details should have an activity type section" + ); + assert.strictEqual( + document.querySelector('.o_Activity_type').textContent.trim(), + "Fake type", + "activity details should have the activity type display name in type section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary.o_Activity_type').length, + 1, + "should have activity type as summary" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary:not(.o_Activity_type)').length, + 0, + "should not have a specific summary" + ); +}); + +QUnit.test('activity details toggle', async function (assert) { + assert.expect(5); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + state: 'planned', + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "activity details should not be visible by default" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsButton').length, + 1, + "activity should have a details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 1, + "activity details should be visible after clicking on details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "activity details should no longer be visible after clicking again on details button" + ); +}); + +QUnit.test('activity details layout', async function (assert) { + assert.expect(11); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + assignee: [['insert', { id: 10, partnerDisplayName: "Pauvre pomme" }]], + creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + state: 'planned', + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_userAvatar').length, + 1, + "should have activity user avatar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsButton').length, + 1, + "activity should have a details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 1, + "activity details should be visible after clicking on details button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details .o_Activity_type').length, + 1, + "activity details should have type" + ); + assert.strictEqual( + document.querySelector('.o_Activity_details .o_Activity_type').textContent, + "Fake type", + "activity details type should be 'Fake type'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsCreation').length, + 1, + "activity details should have creation date " + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsCreator').length, + 1, + "activity details should have creator" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsAssignation').length, + 1, + "activity details should have assignation information" + ); + assert.strictEqual( + document.querySelector('.o_Activity_detailsAssignation').textContent.indexOf('Pauvre pomme'), + 0, + "activity details assignation information should contain creator display name" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsAssignationUserAvatar').length, + 1, + "activity details should have user avatar" + ); +}); + +QUnit.test('activity with mail template layout', async function (assert) { + assert.expect(8); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_sidebar').length, + 1, + "should have activity sidebar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplates').length, + 1, + "should have activity mail templates" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplate').length, + 1, + "should have activity mail template" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_name').length, + 1, + "should have activity mail template name" + ); + assert.strictEqual( + document.querySelector('.o_MailTemplate_name').textContent, + "Dummy mail template", + "should have activity mail template name" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_preview').length, + 1, + "should have activity mail template name preview button" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_send').length, + 1, + "should have activity mail template name send button" + ); +}); + +QUnit.test('activity with mail template: preview mail', async function (assert) { + assert.expect(10); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_model, + 'res.partner', + 'Action should have the activity res model as default model in context' + ); + assert.ok( + payload.action.context.default_use_template, + 'Action should have true as default use_template in context' + ); + assert.strictEqual( + payload.action.context.default_template_id, + 1, + 'Action should have the selected mail template id as default template id in context' + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + "mail.compose.message", + 'Action should have "mail.compose.message" as res_model' + ); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + res_id: 42, + res_model: 'res.partner', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_preview').length, + 1, + "should have activity mail template name preview button" + ); + + document.querySelector('.o_MailTemplate_preview').click(); + assert.verifySteps( + ['do_action'], + "should have called 'compose email' action correctly" + ); +}); + +QUnit.test('activity with mail template: send mail', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'activity_send_mail') { + assert.step('activity_send_mail'); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 42); + assert.strictEqual(args.args[1], 1); + return; + } else { + return this._super(...arguments); + } + }, + }); + const activity = this.env.models['mail.activity'].create({ + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + res_id: 42, + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_send').length, + 1, + "should have activity mail template name send button" + ); + + document.querySelector('.o_MailTemplate_send').click(); + assert.verifySteps( + ['activity_send_mail'], + "should have called activity_send_mail rpc" + ); +}); + +QUnit.test('activity upload document is available', async function (assert) { + assert.expect(3); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'upload_file', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_uploadButton').length, + 1, + "should have activity upload button" + ); + assert.strictEqual( + document.querySelectorAll('.o_FileUploader').length, + 1, + "should have a file uploader" + ); +}); + +QUnit.test('activity click on mark as done', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + }); + await this.createActivityComponent(activity); + + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_markDoneButton').length, + 1, + "should have activity Mark as Done button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ActivityMarkDonePopover').length, + 1, + "should have opened the mark done popover" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ActivityMarkDonePopover').length, + 0, + "should have closed the mark done popover" + ); +}); + +QUnit.test('activity click on edit', async function (assert) { + assert.expect(9); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + 'Action should have the activity res model as default res model in context' + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + "mail.activity", + 'Action should have "mail.activity" as res_model' + ); + assert.strictEqual( + payload.action.res_id, + 12, + 'Action should have activity id as res_id' + ); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + id: 12, + mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]], + res_id: 42, + res_model: 'res.partner', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_editButton').length, + 1, + "should have activity edit button" + ); + + document.querySelector('.o_Activity_editButton').click(); + assert.verifySteps( + ['do_action'], + "should have called 'schedule activity' action correctly" + ); +}); + +QUnit.test('activity edition', async function (assert) { + assert.expect(14); + + this.data['mail.activity'].records = [{ + can_write: true, + icon: 'fa-times', + id: 12, + res_id: 42, + res_model: 'res.partner', + }]; + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + 'Action should have the activity res model as default res model in context' + ); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + 'mail.activity', + 'Action should have "mail.activity" as res_model' + ); + assert.strictEqual( + payload.action.res_id, + 12, + 'Action should have activity id as res_id' + ); + this.data['mail.activity'].records[0].icon = 'fa-check'; + payload.options.on_close(); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create( + this.env.models['mail.activity'].convertData( + this.data['mail.activity'].records[0] + ) + ); + await this.createActivityComponent(activity); + + assert.containsOnce( + document.body, + '.o_Activity', + "should have activity component" + ); + assert.containsOnce( + document.body, + '.o_Activity_editButton', + "should have activity edit button" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon', + "should have activity icon" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon.fa-times', + "should have initial activity icon" + ); + assert.containsNone( + document.body, + '.o_Activity_icon.fa-check', + "should not have new activity icon when not edited yet" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_editButton').click(); + }); + assert.verifySteps( + ['do_action'], + "should have called 'schedule activity' action correctly" + ); + assert.containsNone( + document.body, + '.o_Activity_icon.fa-times', + "should no more have initial activity icon once edited" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon.fa-check', + "should now have new activity icon once edited" + ); +}); + +QUnit.test('activity click on cancel', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + assert.step('unlink'); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + return; + } else { + return this._super(...arguments); + } + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + id: 12, + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + res_id: 42, + res_model: 'res.partner', + }); + + // Create a parent component to surround the Activity component in order to be able + // to check that activity component has been destroyed + class ParentComponent extends Component { + constructor(...args) { + super(... args); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + }; + }); + } + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + } + ParentComponent.env = this.env; + Object.assign(ParentComponent, { + components, + props: { activityLocalId: String }, + template: xml` + <div> + <p>parent</p> + <t t-if="activity"> + <Activity activityLocalId="activity.localId"/> + </t> + </div> + `, + }); + this.component = new ParentComponent(null, { activityLocalId: activity.localId }); + await this.component.mount(this.widget.el); + + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_cancelButton').length, + 1, + "should have activity cancel button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_cancelButton').click() + ); + assert.verifySteps( + ['unlink'], + "should have called unlink rpc after clicking on cancel" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 0, + "should no longer display activity after clicking on cancel" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/activity_box/activity_box.js b/addons/mail/static/src/components/activity_box/activity_box.js new file mode 100644 index 00000000000..bfc624bea19 --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.js @@ -0,0 +1,60 @@ +odoo.define('mail/static/src/components/activity_box/activity_box.js', function (require) { +'use strict'; + +const components = { + Activity: require('mail/static/src/components/activity/activity.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ActivityBox extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + return { + chatter: chatter ? chatter.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickTitle() { + this.chatter.toggleActivityBoxVisibility(); + } + +} + +Object.assign(ActivityBox, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.ActivityBox', +}); + +return ActivityBox; + +}); diff --git a/addons/mail/static/src/components/activity_box/activity_box.scss b/addons/mail/static/src/components/activity_box/activity_box.scss new file mode 100644 index 00000000000..056f624c04f --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.scss @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ActivityBox_title { + display: flex; + align-items: center; + flex: 0 0 auto; + margin-top: map-get($spacers, 4); + margin-bottom: map-get($spacers, 4); +} + +.o_ActivityBox_titleBadge { + padding: map-get($spacers, 0) map-get($spacers, 2); +} + +.o_ActivityBox_titleLine { + flex: 1 1 auto; + width: auto; +} + +.o_ActivityBox_titleText { + padding: map-get($spacers, 0) map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ActivityBox_title { + font-weight: bold; +} + +.o_ActivityBox_titleBadge { + font-size: 11px; +} + +.o_ActivityBox_titleLine { + border-color: gray('400'); + border-style: dashed; +} diff --git a/addons/mail/static/src/components/activity_box/activity_box.xml b/addons/mail/static/src/components/activity_box/activity_box.xml new file mode 100644 index 00000000000..2538ac9a462 --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ActivityBox" owl="1"> + <div class="o_ActivityBox"> + <t t-if="chatter"> + <a role="button" class="o_ActivityBox_title btn" t-on-click="_onClickTitle"> + <hr class="o_ActivityBox_titleLine" /> + <span class="o_ActivityBox_titleText"> + <i class="fa fa-fw" t-att-class="chatter.isActivityBoxVisible ? 'fa-caret-right' : 'fa-caret-down'"/> + Planned activities + </span> + <t t-if="!chatter.isActivityBoxVisible"> + <span class="o_ActivityBox_titleBadges"> + <t t-if="chatter.overdueActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-danger"> + <t t-esc="chatter.overdueActivities.length"/> + </span> + </t> + <t t-if="chatter.todayActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-warning"> + <t t-esc="chatter.todayActivities.length"/> + </span> + </t> + <t t-if="chatter.futureActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-success"> + <t t-esc="chatter.futureActivities.length"/> + </span> + </t> + </span> + </t> + <hr class="o_ActivityBox_titleLine" /> + </a> + <t t-if="chatter.isActivityBoxVisible"> + <div class="o_ActivityList"> + <t t-foreach="chatter.activities" t-as="activity" t-key="activity.localId"> + <Activity class="o_ActivityBox_activity" activityLocalId="activity.localId"/> + </t> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js new file mode 100644 index 00000000000..03e8f2ea54e --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js @@ -0,0 +1,83 @@ +odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ActivityMarkDonePopover extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + }; + }); + this._feedbackTextareaRef = useRef('feedbackTextarea'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {string} + */ + get DONE_AND_SCHEDULE_NEXT() { + return this.env._t("Done & Schedule Next"); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickDiscard() { + this.trigger('o-popover-close'); + } + + /** + * @private + */ + _onClickDone() { + this.activity.markAsDone({ + feedback: this._feedbackTextareaRef.el.value, + }); + } + + /** + * @private + */ + _onClickDoneAndScheduleNext() { + this.activity.markAsDoneAndScheduleNext({ + feedback: this._feedbackTextareaRef.el.value, + }); + } + +} + +Object.assign(ActivityMarkDonePopover, { + props: { + activityLocalId: String, + }, + template: 'mail.ActivityMarkDonePopover', +}); + +return ActivityMarkDonePopover; + +}); diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss new file mode 100644 index 00000000000..1e15fa086c1 --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ActivityMarkDonePopover { + align-content: space-between; + display: flex; + flex-flow: row wrap; + min-height: 100px; +} + +.o_ActivityMarkDonePopover_buttons { + display: flex; + flex: 1; + justify-content: space-between; + margin-top: map-get($spacers, 2); +} + +.o_ActivityMarkDonePopover_feedback { + min-height: 70px; +} + diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml new file mode 100644 index 00000000000..2cd22e993ec --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ActivityMarkDonePopover" owl="1"> + <div class="o_ActivityMarkDonePopover"> + <t t-if="activity"> + <textarea class="form-control o_ActivityMarkDonePopover_feedback" rows="3" placeholder="Write Feedback" t-ref="feedbackTextarea"/> + <div class="o_ActivityMarkDonePopover_buttons"> + <button type="button" class="o_ActivityMarkDonePopover_doneScheduleNextButton btn btn-sm btn-primary" t-on-click="_onClickDoneAndScheduleNext" t-esc="DONE_AND_SCHEDULE_NEXT"/> + <t t-if="!activity.force_next"> + <button type="button" class="o_ActivityMarkDonePopover_doneButton btn btn-sm btn-primary" t-on-click="_onClickDone"> + Done + </button> + <button type="button" class="o_ActivityMarkDonePopover_discardButton btn btn-sm btn-link" t-on-click="_onClickDiscard"> + Discard + </button> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js new file mode 100644 index 00000000000..0c04e492f24 --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js @@ -0,0 +1,282 @@ +odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js', function (require) { +'use strict'; + +const components = { + ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'), +}; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('activity_mark_done_popover', {}, function () { +QUnit.module('activity_mark_done_popover_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createActivityMarkDonePopoverComponent = async activity => { + const ActivityMarkDonePopoverComponent = components.ActivityMarkDonePopover; + ActivityMarkDonePopoverComponent.env = this.env; + this.component = new ActivityMarkDonePopoverComponent(null, { + activityLocalId: activity.localId, + }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + }, +}); + +QUnit.test('activity mark done popover simplest layout', async function (assert) { + assert.expect(6); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_feedback', + "Popover component should contain the feedback textarea" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_buttons', + "Popover component should contain the action buttons" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneScheduleNextButton', + "Popover component should contain the done & schedule next button" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneButton', + "Popover component should contain the done button" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should contain the discard button" + ); +}); + +QUnit.test('activity with force next mark done popover simplest layout', async function (assert) { + assert.expect(6); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + force_next: true, + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_feedback', + "Popover component should contain the feedback textarea" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_buttons', + "Popover component should contain the action buttons" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneScheduleNextButton', + "Popover component should contain the done & schedule next button" + ); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover_doneButton', + "Popover component should NOT contain the done button" + ); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should NOT contain the discard button" + ); +}); + +QUnit.test('activity mark done popover click on discard', async function (assert) { + assert.expect(4); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + function onPopoverClose(ev) { + assert.step('event_triggered'); + } + document.addEventListener('o-popover-close', onPopoverClose); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should contain the discard button" + ); + document.querySelector('.o_ActivityMarkDonePopover_discardButton').click(); + assert.verifySteps(['event_triggered'], 'Discard clicked should trigger the right event'); + + document.removeEventListener('o-popover-close', onPopoverClose); +}); + +QUnit.test('activity mark done popover mark done without feedback', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { + assert.step('action_feedback'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.attachment_ids.length, 0); + assert.notOk(args.kwargs.feedback); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + document.querySelector('.o_ActivityMarkDonePopover_doneButton').click(); + assert.verifySteps( + ['action_feedback'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +QUnit.test('activity mark done popover mark done with feedback', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { + assert.step('action_feedback'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.attachment_ids.length, 0); + assert.strictEqual(args.kwargs.feedback, 'This task is done'); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback'); + feedbackTextarea.focus(); + document.execCommand('insertText', false, 'This task is done'); + document.querySelector('.o_ActivityMarkDonePopover_doneButton').click(); + assert.verifySteps( + ['action_feedback'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +QUnit.test('activity mark done popover mark done and schedule next', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') { + assert.step('action_feedback_schedule_next'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.feedback, 'This task is done'); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback'); + feedbackTextarea.focus(); + document.execCommand('insertText', false, 'This task is done'); + document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click(); + assert.verifySteps( + ['action_feedback_schedule_next'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment/attachment.js b/addons/mail/static/src/components/attachment/attachment.js new file mode 100644 index 00000000000..76922ed7a3c --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.js @@ -0,0 +1,162 @@ +odoo.define('mail/static/src/components/attachment/attachment.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class Attachment extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId); + return { + attachment: attachment ? attachment.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment} + */ + get attachment() { + return this.env.models['mail.attachment'].get(this.props.attachmentLocalId); + } + + /** + * Return the url of the attachment. Temporary attachments, a.k.a. uploading + * attachments, do not have an url. + * + * @returns {string} + */ + get attachmentUrl() { + if (this.attachment.isTemporary) { + return ''; + } + return this.env.session.url('/web/content', { + id: this.attachment.id, + download: true, + }); + } + + /** + * Get the details mode after auto mode is computed + * + * @returns {string} 'card', 'hover' or 'none' + */ + get detailsMode() { + if (this.props.detailsMode !== 'auto') { + return this.props.detailsMode; + } + if (this.attachment.fileType !== 'image') { + return 'card'; + } + return 'hover'; + } + + /** + * Get the attachment representation style to be applied + * + * @returns {string} + */ + get imageStyle() { + if (this.attachment.fileType !== 'image') { + return ''; + } + let size; + if (this.detailsMode === 'card') { + size = '38x38'; + } else { + size = '160x160'; + } + return `background-image:url(/web/image/${this.attachment.id}/${size}/?crop=true);`; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Download the attachment when clicking on donwload icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDownload(ev) { + ev.stopPropagation(); + window.location = `/web/content/ir.attachment/${this.attachment.id}/datas?download=true`; + } + + /** + * Open the attachment viewer when clicking on viewable attachment. + * + * @private + * @param {MouseEvent} ev + */ + _onClickImage(ev) { + if (!this.attachment.isViewable) { + return; + } + this.env.models['mail.attachment'].view({ + attachment: this.attachment, + attachments: this.props.attachmentLocalIds.map( + attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId) + ), + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnlink(ev) { + ev.stopPropagation(); + this.attachment.remove(); + this.trigger('o-attachment-removed'); + } + +} + +Object.assign(Attachment, { + defaultProps: { + attachmentLocalIds: [], + detailsMode: 'auto', + imageSize: 'medium', + isDownloadable: false, + isEditable: true, + showExtension: true, + showFilename: true, + }, + props: { + attachmentLocalId: String, + attachmentLocalIds: { + type: Array, + element: String, + }, + detailsMode: { + type: String, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + imageSize: { + type: String, + validate: prop => ['small', 'medium', 'large'].includes(prop), + }, + isDownloadable: Boolean, + isEditable: Boolean, + showExtension: Boolean, + showFilename: Boolean, + }, + template: 'mail.Attachment', +}); + +return Attachment; + +}); diff --git a/addons/mail/static/src/components/attachment/attachment.scss b/addons/mail/static/src/components/attachment/attachment.scss new file mode 100644 index 00000000000..aebe44c82ba --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.scss @@ -0,0 +1,196 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Attachment { + display: flex; + + &:hover .o_Attachment_asideItemUnlink.o-pretty { + transform: translateX(0); + } +} + +.o_Attachment_action { + min-width: 20px; +} + +.o_Attachment_actions { + justify-content: space-between; + display: flex; + flex-direction: column; +} + +.o_Attachment_aside { + position: relative; + overflow: hidden; + + &:not(.o-has-multiple-action) { + min-width: 50px; + } + + &.o-has-multiple-action { + min-width: 30px; + display: flex; + flex-direction: column; + } +} + +.o_Attachment_asideItem { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; +} + +.o_Attachment_asideItemUnlink.o-pretty { + position: absolute; + top: 0; + transform: translateX(100%); +} + +.o_Attachment_details { + min-width: 0; /* This allows the text ellipsis in the flex element */ +} + +.o_Attachment_filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_Attachment_image { + margin: 3px; + + &.o-details-overlay { + position: relative; + + &.o-small { + min-width: 100px; + min-height: 100px; + } + &.o-medium { + min-width: 200px; + min-height: 200px; + } + &.o-large { + min-width: 400px; + min-height: 400px; + } + + &:hover { + .o_Attachment_imageOverlay { + opacity: 1; + } + } + } +} + +.o_Attachment_imageOverlay { + bottom: 0; + display:flex; + flex-direction: row; + justify-content: flex-end; + left: 0; + padding: 10px; + position: absolute; + right: 0; + top: 0; +} + +.o_Attachment_imageOverlayDetails { + display: flex; + flex-direction: column; + justify-content: flex-end; + margin: 3px; + width: 200px; +} + + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Attachment { + &.o-has-card-details { + background-color: gray('300'); + border-radius: 5px; + } +} + +.o_Attachment_action { + border-radius: 10px; + cursor: pointer; + text-align: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +} + +.o_Attachment_aside { + border-radius: 0 5px 5px 0; +} + +.o_Attachment_asideItemDownload { + cursor: pointer; + + &:hover { + background-color: gray('400'); + } +} + +.o_Attachment_asideItemUnlink { + cursor: pointer; + + &:not(.o-pretty):hover { + background-color: gray('400'); + } + + &.o-pretty { + color: white; + background-color: $o-brand-primary; + + &:hover { + background-color: darken($o-brand-primary, 10%); + } + } + +} + +.o_Attachment_asideItemUploaded { + color: $o-brand-primary; +} + +.o_Attachment_extension { + text-transform: uppercase; + font-size: 80%; + font-weight: 400; +} + +.o_Attachment_image.o-attachment-viewable { + cursor: zoom-in; + + &:not(.o-details-overlay):hover { + opacity: 0.7; + } +} + +.o_Attachment_imageOverlay { + background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.9)); + border-radius: 5px; + color: white; + opacity: 0; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_Attachment_asideItemUnlink.o-pretty { + transition: transform 0.3s ease 0s; +} + +.o_Attachment_imageOverlay { + transition: all 0.3s ease 0s; +} diff --git a/addons/mail/static/src/components/attachment/attachment.xml b/addons/mail/static/src/components/attachment/attachment.xml new file mode 100644 index 00000000000..8af00288b8b --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.xml @@ -0,0 +1,109 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Attachment" owl="1"> + <div class="o_Attachment" + t-att-class="{ + 'o-downloadable': props.isDownloadable, + 'o-editable': props.isEditable, + 'o-has-card-details': detailsMode === 'card', + 'o-temporary': attachment and attachment.isTemporary, + 'o-viewable': attachment and attachment.isViewable, + }" t-att-title="attachment ? attachment.displayName : undefined" t-att-data-attachment-local-id="attachment ? attachment.localId : undefined" + > + <t t-if="attachment"> + <!-- Image style--> + <!-- o_image from mimetype.scss --> + <div class="o_Attachment_image o_image" t-on-click="_onClickImage" + t-att-class="{ + 'o-attachment-viewable': attachment.isViewable, + 'o-details-overlay': detailsMode !== 'card', + 'o-large': props.imageSize === 'large', + 'o-medium': props.imageSize === 'medium', + 'o-small': props.imageSize === 'small', + }" t-att-href="attachmentUrl" t-att-style="imageStyle" t-att-data-mimetype="attachment.mimetype" + > + <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'hover'"> + <div class="o_Attachment_imageOverlay"> + <div class="o_Attachment_details o_Attachment_imageOverlayDetails"> + <t t-if="props.showFilename"> + <div class="o_Attachment_filename"> + <t t-esc="attachment.displayName"/> + </div> + </t> + <t t-if="props.showExtension"> + <div class="o_Attachment_extension"> + <t t-esc="attachment.extension"/> + </div> + </t> + </div> + <div class="o_Attachment_actions"> + <!-- Remove button --> + <t t-if="props.isEditable and !attachment.isTemporary" t-key="'unlink'"> + <div class="o_Attachment_action o_Attachment_actionUnlink" + t-att-class="{ + 'o-pretty': attachment.isLinkedToComposer, + }" t-on-click="_onClickUnlink" title="Remove" + > + <i class="fa fa-times"/> + </div> + </t> + <!-- Download button --> + <t t-if="props.isDownloadable and !attachment.isTemporary" t-key="'download'"> + <div class="o_Attachment_action o_Attachment_actionDownload" t-on-click="_onClickDownload" title="Download"> + <i class="fa fa-download"/> + </div> + </t> + </div> + </div> + </t> + </div> + <!-- Attachment details --> + <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'card'"> + <div class="o_Attachment_details"> + <t t-if="props.showFilename"> + <div class="o_Attachment_filename"> + <t t-esc="attachment.displayName"/> + </div> + </t> + <t t-if="props.showExtension"> + <div class="o_Attachment_extension"> + <t t-esc="attachment.extension"/> + </div> + </t> + </div> + </t> + <!-- Attachment aside --> + <t t-if="detailsMode !== 'hover' and (props.isDownloadable or props.isEditable)"> + <div class="o_Attachment_aside" t-att-class="{ 'o-has-multiple-action': !attachment.isTemporary and props.isDownloadable and props.isEditable }"> + <!-- Uploading icon --> + <t t-if="attachment.isTemporary and attachment.isLinkedToComposer"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUploading" title="Uploading"> + <i class="fa fa-spin fa-spinner"/> + </div> + </t> + <!-- Uploaded icon --> + <t t-if="!attachment.isTemporary and attachment.isLinkedToComposer"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUploaded" title="Uploaded"> + <i class="fa fa-check"/> + </div> + </t> + <!-- Remove button --> + <t t-if="props.isEditable and !attachment.isTemporary"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUnlink" t-att-class="{ 'o-pretty': attachment.isLinkedToComposer }" t-on-click="_onClickUnlink" title="Remove"> + <i class="fa fa-times"/> + </div> + </t> + <!-- Download button --> + <t t-if="props.isDownloadable and !attachment.isTemporary"> + <div class="o_Attachment_asideItem o_Attachment_asideItemDownload" t-on-click="_onClickDownload" title="Download"> + <i class="fa fa-download"/> + </div> + </t> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment/attachment_tests.js b/addons/mail/static/src/components/attachment/attachment_tests.js new file mode 100644 index 00000000000..224a80a30e7 --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment_tests.js @@ -0,0 +1,545 @@ +odoo.define('mail/static/src/components/attachment/attachment_tests.js', function (require) { +'use strict'; + +const components = { + Attachment: require('mail/static/src/components/attachment/attachment.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('attachment', {}, function () { +QUnit.module('attachment_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createAttachmentComponent = async (attachment, otherProps) => { + const AttachmentComponent = components.Attachment; + AttachmentComponent.env = this.env; + this.component = new AttachmentComponent(null, Object.assign({ + attachmentLocalId: attachment.localId, + }, otherProps)); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.Attachment.env; + }, +}); + +QUnit.test('simplest layout', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + const attachmentEl = document.querySelector('.o_Attachment'); + assert.strictEqual( + attachmentEl.dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 750).localId, + "attachment component should be linked to attachment store model" + ); + assert.strictEqual( + attachmentEl.title, + "test.txt", + "attachment should have filename as title attribute" + ); + assert.strictEqual( + attachmentEl.querySelectorAll(`:scope .o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + const attachmentImage = document.querySelector(`.o_Attachment_image`); + assert.ok( + attachmentImage.classList.contains('o_image'), + "attachment should have o_image classname (required for mimetype.scss style)" + ); + assert.strictEqual( + attachmentImage.dataset.mimetype, + 'text/plain', + "attachment should have data-mimetype set (required for mimetype.scss style)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside part" + ); +}); + +QUnit.test('simplest layout + deletable', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/750')) { + assert.ok( + route.includes('/160x160'), + "should fetch image with 160x160 pixels ratio"); + assert.step('fetch_image'); + return; + } + return this._super(...arguments); + }, + }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: false, + isEditable: true, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 1, + "attachment should have only one aside item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length, + 1, + "attachment should have a delete button" + ); +}); + +QUnit.test('simplest layout + downloadable', async function (assert) { + assert.expect(6); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: true, + isEditable: false, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 1, + "attachment should have only one aside item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemDownload`).length, + 1, + "attachment should have a download button" + ); +}); + +QUnit.test('simplest layout + deletable + downloadable', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: true, + isEditable: true, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.ok( + document.querySelector(`.o_Attachment_aside`).classList.contains('o-has-multiple-action'), + "attachment aside should contain multiple actions" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 2, + "attachment should have only two aside items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemDownload`).length, + 1, + "attachment should have a download button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length, + 1, + "attachment should have a delete button" + ); +}); + +QUnit.test('layout with card details', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside part" + ); +}); + +QUnit.test('layout with card details and filename', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should not have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 0, + "attachment should have its extension shown" + ); +}); + +QUnit.test('layout with card details and extension', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 0, + "attachment should not have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); +}); + +QUnit.test('layout with card details and filename and extension', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); +}); + +QUnit.test('simplest layout with hover details and filename and extension', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'hover', + isDownloadable: true, + isEditable: true, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Attachment_details:not(.o_Attachment_imageOverlayDetails) + `).length, + 0, + "attachment should not have a details part directly" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length, + 1, + "attachment should have a details part in the overlay" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlay`).length, + 1, + "attachment should have an image overlay part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_actions`).length, + 1, + "attachment should have an actions part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside element" + ); +}); + +QUnit.test('auto layout with image', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/750')) { + return; + } + return this._super(...arguments); + }, + }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.png", + id: 750, + mimetype: 'image/png', + name: "test.png", + }); + + await this.createAttachmentComponent(attachment, { + detailsMode: 'auto', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Attachment_details:not(.o_Attachment_imageOverlayDetails) + `).length, + 0, + "attachment should not have a details part directly" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length, + 1, + "attachment should have a details part in the overlay" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlay`).length, + 1, + "attachment should have an image overlay part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside element" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.js b/addons/mail/static/src/components/attachment_box/attachment_box.js new file mode 100644 index 00000000000..6e0c9d7c55e --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.js @@ -0,0 +1,118 @@ +odoo.define('mail/static/src/components/attachment_box/attachment_box.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), +}; +const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class AttachmentBox extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.isDropZoneVisible = useDragVisibleDropZone(); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + attachments: thread + ? thread.allAttachments.map(attachment => attachment.__state) + : [], + thread: thread ? thread.__state : undefined, + }; + }); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get an object which is passed to FileUploader component to be used when + * creating attachment. + * + * @returns {Object} + */ + get newAttachmentExtraData() { + return { + originThread: [['link', this.thread]], + }; + } + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onAttachmentCreated(ev) { + // FIXME Could be changed by spying attachments count (task-2252858) + this.trigger('o-attachments-changed'); + } + + /** + * @private + * @param {Event} ev + */ + _onAttachmentRemoved(ev) { + // FIXME Could be changed by spying attachments count (task-2252858) + this.trigger('o-attachments-changed'); + } + + /** + * @private + * @param {Event} ev + */ + _onClickAdd(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this._fileUploaderRef.comp.openBrowserFileUploader(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {FileList} ev.detail.files + */ + async _onDropZoneFilesDropped(ev) { + ev.stopPropagation(); + await this._fileUploaderRef.comp.uploadFiles(ev.detail.files); + this.isDropZoneVisible.value = false; + } + +} + +Object.assign(AttachmentBox, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.AttachmentBox', +}); + +return AttachmentBox; + +}); diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.scss b/addons/mail/static/src/components/attachment_box/attachment_box.scss new file mode 100644 index 00000000000..d51cca9c31c --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.scss @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentBox { + position: relative; +} + +.o_AttachmentBox_buttonAdd { + align-self: center; +} + +.o_AttachmentBox_content { + display: flex; + flex-direction: column; +} + +.o_AttachmentBox_dashedLine { + flex-grow: 1; +} + +.o_AttachmentBox_fileInput { + display: none; +} + +.o_AttachmentBox_title { + display: flex; + align-items: center; +} + +.o_AttachmentBox_titleText { + padding: map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_AttachmentBox_dashedLine { + border-style: dashed; + border-color: gray('300'); +} + +.o_AttachmentBox_title { + font-weight: bold; +} diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.xml b/addons/mail/static/src/components/attachment_box/attachment_box.xml new file mode 100644 index 00000000000..9cd3e713401 --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentBox" owl="1"> + <div class="o_AttachmentBox"> + <div class="o_AttachmentBox_title"> + <hr class="o_AttachmentBox_dashedLine"/> + <span class="o_AttachmentBox_titleText"> + Attachments + </span> + <hr class="o_AttachmentBox_dashedLine"/> + </div> + <div class="o_AttachmentBox_content"> + <t t-if="isDropZoneVisible.value"> + <DropZone + class="o_AttachmentBox_dropZone" + t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped" + t-ref="dropzone" + /> + </t> + <t t-if="thread and thread.allAttachments.length > 0"> + <AttachmentList + class="o_attachmentBox_attachmentList" + areAttachmentsDownloadable="true" + attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="'hover'" + attachmentsImageSize="'small'" + showAttachmentsFilenames="true" + t-on-o-attachment-removed="_onAttachmentRemoved" + /> + </t> + <button class="o_AttachmentBox_buttonAdd btn btn-link" type="button" t-on-click="_onClickAdd"> + <i class="fa fa-plus-square"/> + Add attachments + </button> + </div> + <t t-if="thread"> + <FileUploader + attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)" + newAttachmentExtraData="newAttachmentExtraData" + uploadModel="thread.model" + uploadId="thread.id" + t-on-o-attachment-created="_onAttachmentCreated" + t-ref="fileUploader" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment_box/attachment_box_tests.js b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js new file mode 100644 index 00000000000..93690791d10 --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js @@ -0,0 +1,228 @@ +odoo.define('mail/static/src/components/attachment_box/attachment_box_tests.js', function (require) { +"use strict"; + +const components = { + AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + dragenterFiles, + dropFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('attachment_box', {}, function () { +QUnit.module('attachment_box_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createAttachmentBoxComponent = async (thread, otherProps) => { + const AttachmentBoxComponent = components.AttachmentBox; + AttachmentBoxComponent.env = this.env; + this.component = new AttachmentBoxComponent(null, Object.assign({ + threadLocalId: thread.localId, + }, otherProps)); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.AttachmentBox.env; + this.env = undefined; + }, +}); + +QUnit.test('base empty rendering', async function (assert) { + assert.expect(4); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createAttachmentBoxComponent(thread); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox`).length, + 1, + "should have an attachment box" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length, + 1, + "should have a button add" + ); + assert.strictEqual( + document.querySelectorAll(`.o_FileUploader_input`).length, + 1, + "should have a file input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 0, + "should not have any attachment" + ); +}); + +QUnit.test('base non-empty rendering', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + assert.step('ir.attachment/search_read'); + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await thread.fetchAttachments(); + await this.createAttachmentBoxComponent(thread); + assert.verifySteps( + ['ir.attachment/search_read'], + "should have fetched attachments" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox`).length, + 1, + "should have an attachment box" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length, + 1, + "should have a button add" + ); + assert.strictEqual( + document.querySelectorAll(`.o_FileUploader_input`).length, + 1, + "should have a file input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_attachmentBox_attachmentList`).length, + 1, + "should have an attachment list" + ); +}); + +QUnit.test('attachment box: drop attachments', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + return []; + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await thread.fetchAttachments(); + await this.createAttachmentBoxComponent(thread); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }), + ]; + assert.strictEqual( + document.querySelectorAll('.o_AttachmentBox').length, + 1, + "should have an attachment box" + ); + + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_AttachmentBox')) + ); + assert.ok( + document.querySelector('.o_AttachmentBox_dropZone'), + "should have a drop zone" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 0, + "should have no attachment before files are dropped" + ); + + await afterNextRender(() => + dropFiles( + document.querySelector('.o_AttachmentBox_dropZone'), + files + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 1, + "should have 1 attachment in the box after files dropped" + ); + + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_AttachmentBox')) + ); + const file1 = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text2.txt', + }); + const file2 = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text3.txt', + }); + await afterNextRender(() => + dropFiles( + document.querySelector('.o_AttachmentBox_dropZone'), + [file1, file2] + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 3, + "should have 3 attachments in the box after files dropped" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.js b/addons/mail/static/src/components/attachment_list/attachment_list.js new file mode 100644 index 00000000000..d097e5d705d --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.js @@ -0,0 +1,54 @@ +odoo.define('mail/static/src/components/attachment_list/attachment_list.js', function (require) { +'use strict'; + +const components = { + Attachment: require('mail/static/src/components/attachment/attachment.js'), +}; + +const { Component } = owl; + +class AttachmentList extends Component {} + +Object.assign(AttachmentList, { + components, + defaultProps: { + attachmentLocalIds: [], + }, + props: { + areAttachmentsDownloadable: { + type: Boolean, + optional: true, + }, + areAttachmentsEditable: { + type: Boolean, + optional: true, + }, + attachmentLocalIds: { + type: Array, + element: String, + }, + attachmentsDetailsMode: { + type: String, + optional: true, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + attachmentsImageSize: { + type: String, + optional: true, + validate: prop => ['small', 'medium', 'large'].includes(prop), + }, + showAttachmentsExtensions: { + type: Boolean, + optional: true, + }, + showAttachmentsFilenames: { + type: Boolean, + optional: true, + }, + }, + template: 'mail.AttachmentList', +}); + +return AttachmentList; + +}); diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.scss b/addons/mail/static/src/components/attachment_list/attachment_list.scss new file mode 100644 index 00000000000..af4954818e0 --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.scss @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentList { + display: flex; + flex-flow: wrap; + justify-content: flex-start; +} + +.o_AttachmentList_attachment { + margin: map-get($spacers, 1); +} diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.xml b/addons/mail/static/src/components/attachment_list/attachment_list.xml new file mode 100644 index 00000000000..452419105ff --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentList" owl="1"> + <div class="o_AttachmentList"> + <t t-foreach="props.attachmentLocalIds" t-as="attachmentLocalId" t-key="attachmentLocalId"> + <Attachment + class="o_AttachmentList_attachment" + attachmentLocalId="attachmentLocalId" + attachmentLocalIds="props.attachmentLocalIds" + detailsMode="props.attachmentsDetailsMode" + imageSize="props.attachmentsImageSize" + isDownloadable="props.areAttachmentsDownloadable" + isEditable="props.areAttachmentsEditable" + showExtension="props.showAttachmentsExtensions" + showFilename="props.showAttachmentsFilenames" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js new file mode 100644 index 00000000000..54f5f383d3b --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js @@ -0,0 +1,593 @@ +odoo.define('mail/static/src/components/attachment_viewer/attachment_viewer.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component, QWeb } = owl; +const { useRef } = owl.hooks; + +const MIN_SCALE = 0.5; +const SCROLL_ZOOM_STEP = 0.1; +const ZOOM_STEP = 0.5; + +class AttachmentViewer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.MIN_SCALE = MIN_SCALE; + useStore(props => { + const attachmentViewer = this.env.messaging.attachmentViewer; + return { + attachment: attachmentViewer && attachmentViewer.attachment + ? attachmentViewer.attachment.__state + : undefined, + attachments: attachmentViewer + ? attachmentViewer.attachments.map(attachment => attachment.__state) + : [], + attachmentViewer: attachmentViewer ? attachmentViewer.__state : undefined, + }; + }); + /** + * Determine whether the user is currently dragging the image. + * This is useful to determine whether a click outside of the image + * should close the attachment viewer or not. + */ + this._isDragging = false; + /** + * Reference to the image node. Useful in the computation of the zoomer + * style (based on user zoom in/out interactions). + */ + this._imageRef = useRef('image'); + /** + * Reference of the zoomer node. Useful to apply translate + * transformation on image visualisation. + */ + this._zoomerRef = useRef('zoomer'); + /** + * Tracked translate transformations on image visualisation. This is + * not observed with `useStore` because they are used to compute zoomer + * style, and this is changed directly on zoomer for performance + * reasons (overhead of making vdom is too significant for each mouse + * position changes while dragging) + */ + this._translate = { x: 0, y: 0, dx: 0, dy: 0 }; + /** + * Tracked last rendered attachment. Useful to detect a new image is + * loading, in order to display spinner until it is fully loaded. + */ + this._renderedAttachment = undefined; + this._onClickGlobal = this._onClickGlobal.bind(this); + } + + mounted() { + this.el.focus(); + this._handleImageLoad(); + this._renderedAttachment = this.attachmentViewer.attachment; + document.addEventListener('click', this._onClickGlobal); + } + + /** + * When a new image is displayed, show a spinner until it is loaded. + */ + patched() { + this._handleImageLoad(); + this._renderedAttachment = this.attachmentViewer.attachment; + } + + willUnmount() { + document.removeEventListener('click', this._onClickGlobal); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment_viewer} + */ + get attachmentViewer() { + return this.env.messaging.attachmentViewer; + } + + /** + * Compute the style of the image (scale + rotation). + * + * @returns {string} + */ + get imageStyle() { + const attachmentViewer = this.attachmentViewer; + let style = `transform: ` + + `scale3d(${attachmentViewer.scale}, ${attachmentViewer.scale}, 1) ` + + `rotate(${attachmentViewer.angle}deg);`; + + if (attachmentViewer.angle % 180 !== 0) { + style += `` + + `max-height: ${window.innerWidth}px; ` + + `max-width: ${window.innerHeight}px;`; + } else { + style += `` + + `max-height: 100%; ` + + `max-width: 100%;`; + } + return style; + } + + /** + * Mandatory method for dialog components. + * Prevent closing the dialog when clicking on the mask when the user is + * currently dragging the image. + * + * @returns {boolean} + */ + isCloseable() { + return !this._isDragging; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Close the dialog with this attachment viewer. + * + * @private + */ + _close() { + this.attachmentViewer.delete(); + } + + /** + * Download the attachment. + * + * @private + */ + _download() { + const id = this.attachmentViewer.attachment.id; + this.env.services.navigate(`/web/content/ir.attachment/${id}/datas?download=true`); + } + + /** + * Determine whether the current image is rendered for the 1st time, and if + * that's the case, display a spinner until loaded. + * + * @private + */ + _handleImageLoad() { + if ( + this.attachmentViewer.attachment.fileType === 'image' && + this._renderedAttachment !== this.attachmentViewer.attachment + ) { + this.attachmentViewer.update({ isImageLoading: true }); + this._imageRef.el.addEventListener('load', ev => this._onLoadImage(ev)); + } + } + + /** + * Display the previous attachment in the list of attachments. + * + * @private + */ + _next() { + const attachmentViewer = this.attachmentViewer; + const index = attachmentViewer.attachments.findIndex(attachment => + attachment === attachmentViewer.attachment + ); + const nextIndex = (index + 1) % attachmentViewer.attachments.length; + attachmentViewer.update({ + attachment: [['link', attachmentViewer.attachments[nextIndex]]], + }); + } + + /** + * Display the previous attachment in the list of attachments. + * + * @private + */ + _previous() { + const attachmentViewer = this.attachmentViewer; + const index = attachmentViewer.attachments.findIndex(attachment => + attachment === attachmentViewer.attachment + ); + const nextIndex = index === 0 + ? attachmentViewer.attachments.length - 1 + : index - 1; + attachmentViewer.update({ + attachment: [['link', attachmentViewer.attachments[nextIndex]]], + }); + } + + /** + * Prompt the browser print of this attachment. + * + * @private + */ + _print() { + const printWindow = window.open('about:blank', '_new'); + printWindow.document.open(); + printWindow.document.write(` + <html> + <head> + <script> + function onloadImage() { + setTimeout('printImage()', 10); + } + function printImage() { + window.print(); + window.close(); + } + </script> + </head> + <body onload='onloadImage()'> + <img src="${this.attachmentViewer.attachment.defaultSource}" alt=""/> + </body> + </html>`); + printWindow.document.close(); + } + + /** + * Rotate the image by 90 degrees to the right. + * + * @private + */ + _rotate() { + this.attachmentViewer.update({ angle: this.attachmentViewer.angle + 90 }); + } + + /** + * Stop dragging interaction of the user. + * + * @private + */ + _stopDragging() { + this._isDragging = false; + this._translate.x += this._translate.dx; + this._translate.y += this._translate.dy; + this._translate.dx = 0; + this._translate.dy = 0; + this._updateZoomerStyle(); + } + + /** + * Update the style of the zoomer based on translate transformation. Changes + * are directly applied on zoomer, instead of triggering re-render and + * defining them in the template, for performance reasons. + * + * @private + * @returns {string} + */ + _updateZoomerStyle() { + const attachmentViewer = this.attachmentViewer; + const tx = this._imageRef.el.offsetWidth * attachmentViewer.scale > this._zoomerRef.el.offsetWidth + ? this._translate.x + this._translate.dx + : 0; + const ty = this._imageRef.el.offsetHeight * attachmentViewer.scale > this._zoomerRef.el.offsetHeight + ? this._translate.y + this._translate.dy + : 0; + if (tx === 0) { + this._translate.x = 0; + } + if (ty === 0) { + this._translate.y = 0; + } + this._zoomerRef.el.style = `transform: ` + + `translate(${tx}px, ${ty}px)`; + } + + /** + * Zoom in the image. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.scroll=false] + */ + _zoomIn({ scroll = false } = {}) { + this.attachmentViewer.update({ + scale: this.attachmentViewer.scale + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP), + }); + this._updateZoomerStyle(); + } + + /** + * Zoom out the image. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.scroll=false] + */ + _zoomOut({ scroll = false } = {}) { + if (this.attachmentViewer.scale === MIN_SCALE) { + return; + } + const unflooredAdaptedScale = ( + this.attachmentViewer.scale - + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP) + ); + this.attachmentViewer.update({ + scale: Math.max(MIN_SCALE, unflooredAdaptedScale), + }); + this._updateZoomerStyle(); + } + + /** + * Reset the zoom scale of the image. + * + * @private + */ + _zoomReset() { + this.attachmentViewer.update({ scale: 1 }); + this._updateZoomerStyle(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on mask of attachment viewer. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (this._isDragging) { + return; + } + // TODO: clicking on the background should probably be handled by the dialog? + // task-2092965 + this._close(); + } + + /** + * Called when clicking on cross icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + this._close(); + } + + /** + * Called when clicking on download icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDownload(ev) { + ev.stopPropagation(); + this._download(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickGlobal(ev) { + if (!this._isDragging) { + return; + } + ev.stopPropagation(); + this._stopDragging(); + } + + /** + * Called when clicking on the header. Stop propagation of event to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickHeader(ev) { + ev.stopPropagation(); + } + + /** + * Called when clicking on image. Stop propagation of event to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickImage(ev) { + if (this._isDragging) { + return; + } + ev.stopPropagation(); + } + + /** + * Called when clicking on next icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickNext(ev) { + ev.stopPropagation(); + this._next(); + } + + /** + * Called when clicking on previous icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickPrevious(ev) { + ev.stopPropagation(); + this._previous(); + } + + /** + * Called when clicking on print icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickPrint(ev) { + ev.stopPropagation(); + this._print(); + } + + /** + * Called when clicking on rotate icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickRotate(ev) { + ev.stopPropagation(); + this._rotate(); + } + + /** + * Called when clicking on embed video player. Stop propagation to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickVideo(ev) { + ev.stopPropagation(); + } + + /** + * Called when clicking on zoom in icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomIn(ev) { + ev.stopPropagation(); + this._zoomIn(); + } + + /** + * Called when clicking on zoom out icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomOut(ev) { + ev.stopPropagation(); + this._zoomOut(); + } + + /** + * Called when clicking on reset zoom icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomReset(ev) { + ev.stopPropagation(); + this._zoomReset(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + switch (ev.key) { + case 'ArrowRight': + this._next(); + break; + case 'ArrowLeft': + this._previous(); + break; + case 'Escape': + this._close(); + break; + case 'q': + this._close(); + break; + case 'r': + this._rotate(); + break; + case '+': + this._zoomIn(); + break; + case '-': + this._zoomOut(); + break; + case '0': + this._zoomReset(); + break; + default: + return; + } + ev.stopPropagation(); + } + + /** + * Called when new image has been loaded + * + * @private + * @param {Event} ev + */ + _onLoadImage(ev) { + ev.stopPropagation(); + this.attachmentViewer.update({ isImageLoading: false }); + } + + /** + * @private + * @param {DragEvent} ev + */ + _onMousedownImage(ev) { + if (this._isDragging) { + return; + } + if (ev.button !== 0) { + return; + } + ev.stopPropagation(); + this._isDragging = true; + this._dragstartX = ev.clientX; + this._dragstartY = ev.clientY; + } + + /** + * @private + * @param {DragEvent} + */ + _onMousemoveView(ev) { + if (!this._isDragging) { + return; + } + this._translate.dx = ev.clientX - this._dragstartX; + this._translate.dy = ev.clientY - this._dragstartY; + this._updateZoomerStyle(); + } + + /** + * @private + * @param {Event} ev + */ + _onWheelImage(ev) { + ev.stopPropagation(); + if (!this.el) { + return; + } + if (ev.deltaY > 0) { + this._zoomIn({ scroll: true }); + } else { + this._zoomOut({ scroll: true }); + } + } + +} + +Object.assign(AttachmentViewer, { + props: {}, + template: 'mail.AttachmentViewer', +}); + +QWeb.registerComponent('AttachmentViewer', AttachmentViewer); + +return AttachmentViewer; + +}); diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss new file mode 100644 index 00000000000..54f00c1ae5d --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss @@ -0,0 +1,198 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + display: flex; + width: 100%; + height: 100%; + flex-flow: column; + align-items: center; + z-index: -1; +} + +.o_AttachmentViewer_buttonNavigation { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + top: 50%; + transform: translateY(-50%); +} + +.o_AttachmentViewer_buttonNavigationNext { + right: 15px; + + > .fa { + margin: 1px 0 0 1px; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_buttonNavigationPrevious { + left: 15px; + + > .fa { + margin: 1px 1px 0 0; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_header { + display: flex; + height: $o-navbar-height; + align-items: center; + padding: 0 15px; + width: 100%; +} + +.o_AttachmentViewer_headerItem { + margin: 0 5px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } +} + +.o_AttachmentViewer_loading { + position: absolute; +} + +.o_AttachmentViewer_main { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: -1; + padding: 45px 0; + + &.o_with_img { + overflow: hidden; + } +} + +.o_AttachmentViewer_toolbar { + position: absolute; + bottom: 45px; + transform: translateY(100%); + display: flex; +} + +.o_AttachmentViewer_toolbarButton { + padding: 8px; +} + +.o_AttachmentViewer_viewImage { + max-height: 100%; + max-width: 100%; +} + +.o_AttachmentViewer_viewIframe { + width: 90%; + height: 100%; +} + +.o_AttachmentViewer_viewVideo { + width: 75%; + height: 75%; +} + +.o_AttachmentViewer_zoomer { + position: absolute; + padding: 45px 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + outline: none; +} + +.o_AttachmentViewer_buttonNavigation { + color: gray('400'); + background-color: lighten(black, 15%); + border-radius: 100%; + cursor: pointer; + + &:hover { + color: lighten(gray('400'), 15%); + background-color: black; + } +} + +.o_AttachmentViewer_header { + background-color: rgba(0, 0, 0, 0.7); + color: gray('400'); +} + +.o_AttachmentViewer_headerItemButton { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + color: lighten(gray('400'), 15%); + } +} + +.o_AttachmentViewer_headerItemButtonClose { + cursor: pointer; + font-size: 1.3rem; +} + +.o_AttachmentViewer_toolbar { + cursor: pointer; +} + +.o_AttachmentViewer_toolbarButton { + background-color: lighten(black, 15%); + + &.o_disabled { + cursor: not-allowed; + filter: brightness(1.3); + } + + &:not(.o_disabled) { + color: gray('400'); + cursor: pointer; + + &:hover { + background-color: black; + color: lighten(gray('400'), 15%); + } + } +} + +.o_AttachmentViewer_view { + background-color: black; + box-shadow: 0 0 40px black; + outline: none; + border: none; + + &.o_text { + background-color: white; + } +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_AttachmentViewer_viewImage { + transition: transform 0.3s ease; +} + diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml new file mode 100644 index 00000000000..af7967b164f --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentViewer" owl="1"> + <div class="o_AttachmentViewer" t-on-click="_onClick" t-on-keydown="_onKeydown" tabindex="0" t-att-data-dialog="attachmentViewer.dialog.localId"> + <div class="o_AttachmentViewer_header" t-on-click="_onClickHeader"> + <t t-if="attachmentViewer.attachment.fileType"> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_icon"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <i class="fa fa-picture-o" role="img" title="Image"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <i class="fa fa-file-text" role="img" title="PDF file"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <i class="fa fa-file-text" role="img" title="Text file"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <i class="fa fa-video-camera" role="img" title="Video"/> + </t> + </div> + </t> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_name"> + <t t-esc="attachmentViewer.attachment.displayName"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton" t-on-click="_onClickDownload" role="button" title="Download"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + <div class="o-autogrow"/> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton o_AttachmentViewer_headerItemButtonClose" t-on-click="_onClickClose" role="button" title="Close (Esc)" aria-label="Close"> + <i class="fa fa-fw fa-times" role="img"/> + </div> + </div> + <div class="o_AttachmentViewer_main" t-att-class="{ o_with_img: attachmentViewer.attachment.fileType === 'image' }" t-on-mousemove="_onMousemoveView"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_zoomer" t-ref="zoomer"> + <t t-if="attachmentViewer.isImageLoading"> + <div class="o_AttachmentViewer_loading"> + <i class="fa fa-3x fa-circle-o-notch fa-fw fa-spin" role="img" title="Loading"/> + </div> + </t> + <img class="o_AttachmentViewer_view o_AttachmentViewer_viewImage" t-on-click="_onClickImage" t-on-mousedown="_onMousedownImage" t-on-wheel="_onWheelImage" t-att-src="attachmentViewer.attachment.defaultSource" t-att-style="imageStyle" draggable="false" alt="Viewer" t-ref="image"/> + </div> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_viewPdf" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_text" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'youtu'"> + <iframe allow="autoplay; encrypted-media" class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_youtube" t-att-src="attachmentViewer.attachment.defaultSource" height="315" width="560"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <video class="o_AttachmentViewer_view o_AttachmentViewer_viewVideo" t-on-click="_onClickVideo" controls="controls"> + <source t-att-data-type="attachmentViewer.attachment.mimetype" t-att-src="attachmentViewer.attachment.defaultSource"/> + </video> + </t> + </div> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_toolbar" role="toolbar"> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickZoomIn" title="Zoom In (+)" role="button"> + <i class="fa fa-fw fa-plus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === 1 }" t-on-click="_onClickZoomReset" role="button" title="Reset Zoom (0)"> + <i class="fa fa-fw fa-search" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === MIN_SCALE }" t-on-click="_onClickZoomOut" title="Zoom Out (-)" role="button"> + <i class="fafa-fw fa-minus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickRotate" title="Rotate (r)" role="button"> + <i class="fa fa-fw fa-repeat" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickPrint" title="Print" role="button"> + <i class="fa fa-fw fa-print" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_toolbarButton" t-on-click="_onClickDownload" title="Download" role="button"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + </div> + </t> + <t t-if="attachmentViewer.attachments.length > 1"> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationPrevious" t-on-click="_onClickPrevious" title="Previous (Left-Arrow)" role="button"> + <span class="fa fa-chevron-left" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationNext" t-on-click="_onClickNext" title="Next (Right-Arrow)" role="button"> + <span class="fa fa-chevron-right" role="img"/> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js new file mode 100644 index 00000000000..1e383f7b307 --- /dev/null +++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js @@ -0,0 +1,161 @@ +odoo.define('mail/static/src/components/autocomplete_input/autocomplete_input.js', function (require) { +'use strict'; + +const { Component } = owl; + +class AutocompleteInput extends Component { + + mounted() { + if (this.props.isFocusOnMount) { + this.el.focus(); + } + + let args = { + select: (ev, ui) => this._onAutocompleteSelect(ev, ui), + source: (req, res) => this._onAutocompleteSource(req, res), + focus: ev => this._onAutocompleteFocus(ev), + html: this.props.isHtml || false, + }; + + if (this.props.customClass) { + args.classes = { 'ui-autocomplete': this.props.customClass }; + } + + $(this.el).autocomplete(args); + } + + willUnmount() { + $(this.el).autocomplete('destroy'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns whether the given node is self or a children of self, including + * the suggestion menu. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + if (this.el.contains(node)) { + return true; + } + if (!this.props.customClass) { + return false; + } + const element = document.querySelector(`.${this.props.customClass}`); + if (!element) { + return false; + } + return element.contains(node); + } + + focus() { + if (!this.el) { + return; + } + this.el.focus(); + } + + focusout() { + if (!this.el) { + return; + } + this.el.blur(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {FocusEvent} ev + */ + _onAutocompleteFocus(ev) { + if (this.props.focus) { + this.props.focus(ev); + } else { + ev.preventDefault(); + } + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + */ + _onAutocompleteSelect(ev, ui) { + ev.stopPropagation(); + if (this.props.select) { + this.props.select(ev, ui); + } + } + + /** + * @private + * @param {Object} req + * @param {function} res + */ + _onAutocompleteSource(req, res) { + if (this.props.source) { + this.props.source(req, res); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onBlur(ev) { + this.trigger('o-hide'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onKeydown(ev) { + if (ev.key === 'Escape') { + this.trigger('o-hide'); + } + } + +} + +Object.assign(AutocompleteInput, { + defaultProps: { + isFocusOnMount: false, + isHtml: false, + placeholder: '', + }, + props: { + customClass: { + type: String, + optional: true, + }, + focus: { + type: Function, + optional: true, + }, + isFocusOnMount: Boolean, + isHtml: Boolean, + placeholder: String, + select: { + type: Function, + optional: true, + }, + source: { + type: Function, + optional: true, + }, + }, + template: 'mail.AutocompleteInput', +}); + +return AutocompleteInput; + +}); diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml new file mode 100644 index 00000000000..ffa1bc890a4 --- /dev/null +++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AutocompleteInput" owl="1"> + <input class="o_AutocompleteInput" t-on-blur="_onBlur" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js new file mode 100644 index 00000000000..8d1851c78fc --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.js @@ -0,0 +1,352 @@ +odoo.define('mail/static/src/components/chat_window/chat_window.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'), + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ChatWindow extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId); + return { + chatWindow: chatWindow ? chatWindow.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + localeTextDirection: this.env.messaging.locale.textDirection, + }; + }); + /** + * Reference of the header of the chat window. + * Useful to prevent click on header from wrongly focusing the window. + */ + this._chatWindowHeaderRef = useRef('header'); + /** + * Reference of the autocomplete input (new_message chat window only). + * Useful when focusing this chat window, which consists of focusing + * this input. + */ + this._inputRef = useRef('input'); + /** + * Reference of thread in the chat window (chat window with thread + * only). Useful when focusing this chat window, which consists of + * focusing this thread. Will likely focus the composer of thread, if + * it has one! + */ + this._threadRef = useRef('thread'); + // the following are passed as props to children + this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this); + this._onAutocompleteSource = this._onAutocompleteSource.bind(this); + } + + mounted() { + this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this)); + this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this)); + this._update(); + } + + patched() { + this._update(); + } + + willUnmount() { + this.env.messagingBus.off('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this)); + this.env.messagingBus.off('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this)); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chat_window} + */ + get chatWindow() { + return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId); + } + + /** + * Get the content of placeholder for the autocomplete input of + * 'new_message' chat window. + * + * @returns {string} + */ + get newMessageFormInputPlaceholder() { + return this.env._t("Search user..."); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply visual position of the chat window. + * + * @private + */ + _applyVisibleOffset() { + const textDirection = this.env.messaging.locale.textDirection; + const offsetFrom = textDirection === 'rtl' ? 'left' : 'right'; + const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right'; + this.el.style[offsetFrom] = this.chatWindow.visibleOffset + 'px'; + this.el.style[oppositeFrom] = 'auto'; + } + + /** + * Focus this chat window. + * + * @private + */ + _focus() { + this.chatWindow.update({ + isDoFocus: false, + isFocused: true, + }); + if (!this.chatWindow.thread) { + this._inputRef.comp.focus(); + } else { + this._threadRef.comp.focus(); + } + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + _saveThreadScrollTop() { + this.chatWindow.update({ + threadInitialScrollTop: this._threadRef.comp.getScrollTop(), + }); + } + + /** + * @private + */ + _update() { + if (!this.chatWindow) { + // chat window is being deleted + return; + } + if (this.chatWindow.isDoFocus) { + this._focus(); + } + this._applyVisibleOffset(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when selecting an item in the autocomplete input of the + * 'new_message' chat window. + * + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAutocompleteSelect(ev, ui) { + const partnerId = ui.item.id; + const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId); + const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); + if (chat) { + chat.open({ chatWindowMode: 'from_new_message' }); + } else { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + autoselectChatWindowMode: 'from_new_message', + partnerId, + type: 'chat', + }); + } + } + + /** + * Called when typing in the autocomplete input of the 'new_message' chat + * window. + * + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAutocompleteSource(req, res) { + this.env.models['mail.partner'].imSearch({ + callback: (partners) => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: _.escape(req.term), + limit: 10, + }); + } + + /** + * Handle focus of the chat window based on position of click. The click on + * chat window that folds it should NOT set focus on this chat window. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const chatWindowHeader = this._chatWindowHeaderRef.el; + if (chatWindowHeader && chatWindowHeader.contains(ev.target)) { + // handled in _onClickedHeader + return; + } + if (this.chatWindow.isFocused) { + return; + } + this.chatWindow.focus(); + } + + /** + * Called when clicking on header of chat window. Usually folds the chat + * window. + * + * @private + * @param {CustomEvent} ev + */ + _onClickedHeader(ev) { + ev.stopPropagation(); + if (this.env.messaging.device.isMobile) { + return; + } + if (this.chatWindow.isFolded) { + this.chatWindow.unfold(); + this.chatWindow.focus(); + } else { + this.chatWindow.fold(); + } + } + + /** + * Called when an element in the thread becomes focused. + * + * @private + * @param {FocusEvent} ev + */ + _onFocusinThread(ev) { + ev.stopPropagation(); + this.chatWindow.update({ isFocused: true }); + } + + /** + * Focus out the chat window. + * + * @private + */ + _onFocusout() { + if (this._inputRef.comp) { + this._inputRef.comp.focusout(); + } + if (this._threadRef.comp) { + this._threadRef.comp.focusout(); + } + if (!this.chatWindow) { + // ignore focus out due to record being deleted + return; + } + this.chatWindow.update({ isFocused: false }); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + /** + * Prevent auto-focus of fuzzy search in the home menu. + * Useful in order to allow copy/paste content inside chat window with + * CTRL-C & CTRL-V when on the home menu. + */ + ev.stopPropagation(); + switch (ev.key) { + case 'Tab': + ev.preventDefault(); + if (ev.shiftKey) { + this.chatWindow.focusPreviousVisibleUnfoldedChatWindow(); + } else { + this.chatWindow.focusNextVisibleUnfoldedChatWindow(); + } + break; + case 'Escape': + ev.preventDefault(); + this.chatWindow.close(); + break; + } + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + async _onWillHideHomeMenu() { + if (!this.chatWindow.thread) { + return; + } + this._saveThreadScrollTop(); + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + async _onWillShowHomeMenu() { + if (!this.chatWindow.thread) { + return; + } + this._saveThreadScrollTop(); + } + +} + +Object.assign(ChatWindow, { + components, + defaultProps: { + hasCloseAsBackButton: false, + isExpandable: false, + isFullscreen: false, + }, + props: { + chatWindowLocalId: String, + hasCloseAsBackButton: Boolean, + isExpandable: Boolean, + isFullscreen: Boolean, + }, + template: 'mail.ChatWindow', +}); + +return ChatWindow; + +}); diff --git a/addons/mail/static/src/components/chat_window/chat_window.scss b/addons/mail/static/src/components/chat_window/chat_window.scss new file mode 100644 index 00000000000..299da88575e --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.scss @@ -0,0 +1,101 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindow { + position: absolute; + bottom: 0; + display: flex; + flex-flow: column; + + &:not(.o-mobile) { + max-width: 100%; + max-height: 100%; + width: 325px; + + &.o-folded { + height: $o-mail-chat-window-header-height; + } + + &:not(.o-folded) { + height: 400px; + } + } + + &.o-mobile { + position: fixed; + } + + &.o-fullscreen { + height: 100%; + width: 100%; + } +} + +.o_ChatWindow_header { + flex: 0 0 auto; +} + +.o_ChatWindow_newMessageForm { + padding: 3px; + margin-top: 3px; + display: flex; + align-items: center; +} + +.o_ChatWindow_newMessageFormInput { + flex: 1 1 auto; +} + +.o_ChatWindow_newMessageFormLabel { + margin-right: 5px; + flex: 0 0 auto; +} + +.o_ChatWindow_thread { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindow { + background-color: $o-mail-thread-window-bg; + border-radius: 6px 6px 0 0; + box-shadow: -5px -5px 10px rgba(black, 0.09); + outline: none; + + &:not(.o-mobile) { + + &.o-focused { + box-shadow: -5px -5px 10px rgba(black, 0.18); + } + } + + + .o_Composer { + border: 0; + } +} + +.o_ChatWindow_header { + border-radius: 3px 3px 0 0; +} + +.o_ChatWindow_newMessageFormInput { + outline: none; + border: 1px solid gray('300'); // cancel firefox border on input focus +} + +.o_ChatWindow_thread .o_ThreadViewer_messageList { + font-size: 1rem; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_ChatWindow:not(.o-mobile) { + transition: height 0.3s; +} diff --git a/addons/mail/static/src/components/chat_window/chat_window.xml b/addons/mail/static/src/components/chat_window/chat_window.xml new file mode 100644 index 00000000000..c4f6117a25d --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindow" owl="1"> + <div class="o_ChatWindow" tabindex="0" + t-att-class="{ + 'o-focused': chatWindow and chatWindow.isFocused, + 'o-folded': chatWindow and chatWindow.isFolded, + 'o-fullscreen': props.isFullscreen, + 'o-mobile': env.messaging.device.isMobile, + 'o-new-message': chatWindow and !chatWindow.thread, + }" t-on-click="_onClick" t-on-keydown="_onKeydown" t-on-focusout="_onFocusout" t-att-data-chat-window-local-id="chatWindow ? chatWindow.localId : undefined" t-att-data-thread-local-id="chatWindow ? (chatWindow.thread ? chatWindow.thread.localId : '') : undefined" + > + <t t-if="chatWindow"> + <ChatWindowHeader + class="o_ChatWindow_header" + chatWindowLocalId="chatWindow.localId" + hasCloseAsBackButton="props.hasCloseAsBackButton" + isExpandable="props.isExpandable" + t-on-o-clicked="_onClickedHeader" + t-ref="header" + /> + <t t-if="chatWindow.thread"> + <ThreadViewer + class="o_ChatWindow_thread" + composerAttachmentsDetailsMode="'card'" + hasComposer="chatWindow.thread.model !== 'mail.box'" + hasComposerCurrentPartnerAvatar="false" + hasComposerSendButton="env.messaging.device.isMobile" + hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'" + haveMessagesAuthorRedirect="chatWindow.thread.channel_type !== 'chat'" + threadViewerLocalId="chatWindow.threadViewer.localId" + t-on-focusin="_onFocusinThread" + t-ref="thread" + /> + </t> + <t t-else=""> + <div class="o_ChatWindow_newMessageForm"> + <span class="o_ChatWindow_newMessageFormLabel"> + To: + </span> + <AutocompleteInput + class="o_ChatWindow_newMessageFormInput" + isHtml="true" + placeholder="newMessageFormInputPlaceholder" + select="_onAutocompleteSelect" + source="_onAutocompleteSource" + t-ref="input" + /> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.js b/addons/mail/static/src/components/chat_window_header/chat_window_header.js new file mode 100644 index 00000000000..60294ffc213 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.js @@ -0,0 +1,107 @@ +odoo.define('mail/static/src/components/chat_window_header/chat_window_header.js', function (require) { +'use strict'; + +const components = { + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatWindowHeader extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId); + const thread = chatWindow && chatWindow.thread; + return { + chatWindow: chatWindow ? chatWindow.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chat_window} + */ + get chatWindow() { + return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const chatWindow = this.chatWindow; + this.trigger('o-clicked', { chatWindow }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + ev.stopPropagation(); + this.chatWindow.close(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickExpand(ev) { + ev.stopPropagation(); + this.chatWindow.expand(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickShiftLeft(ev) { + ev.stopPropagation(); + this.chatWindow.shiftLeft(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickShiftRight(ev) { + ev.stopPropagation(); + this.chatWindow.shiftRight(); + } + +} + +Object.assign(ChatWindowHeader, { + components, + defaultProps: { + hasCloseAsBackButton: false, + isExpandable: false, + }, + props: { + chatWindowLocalId: String, + hasCloseAsBackButton: Boolean, + isExpandable: Boolean, + }, + template: 'mail.ChatWindowHeader', +}); + +return ChatWindowHeader; + +}); diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.scss b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss new file mode 100644 index 00000000000..c5c23634d20 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowHeader { + display: flex; + align-items: center; + height: $o-mail-chat-window-header-height; + + &.o-mobile { + height: $o-mail-chat-window-header-height-mobile; + } +} + +.o_ChatWindowHeader_command { + padding: 0 8px; + display: flex; + height: 100%; + align-items: center; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } +} + +.o_ChatWindowHeader_commandBack { + margin-right: 5px; +} + +.o_ChatWindowHeader_item { + margin: 0 3px; + + &.o_ChatWindowHeader_rightArea { + margin-right: 0; + } + + &:first-child { + margin-left: 10px; + + &.o_ChatWindowHeader_command { + margin-left: 0px; // no margin for commands + } + } + + &.o_ChatWindowHeader_rightArea:last-child .o_ChatWindowHeader_command { + margin-right: 0px; // no margin for commands + } +} + +.o_ChatWindowHeader_name { + max-height: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.o_ChatWindowHeader_rightArea { + display: flex; + height: 100%; + align-items: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindowHeader { + background-color: $o-brand-odoo; + color: white; + cursor: pointer; + + &:not(.o-mobile) { + + &:hover .o_ChatWindowHeader_command { + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + + &:not(:hover) .o_ChatWindowHeader_command { + opacity: 0.5; + } + } + +} + +.o_ChatWindowHeader_command.o-mobile { + font-size: 1.3rem; +} + +.o_ChatWindowHeader_name { + user-select: none; +} diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.xml b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml new file mode 100644 index 00000000000..0c3261282cc --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowHeader" owl="1"> + <div class="o_ChatWindowHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClick"> + <t t-if="chatWindow"> + <t t-if="props.hasCloseAsBackButton"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandBack o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close conversation"> + <i class="fa fa-arrow-left"/> + </div> + </t> + <t t-if="chatWindow.thread"> + <ThreadIcon + class="o_ChatWindowHeader_icon o_ChatWindowHeader_item" + threadLocalId="chatWindow.thread.localId" + /> + </t> + <div class="o_ChatWindowHeader_item o_ChatWindowHeader_name" t-att-title="chatWindow.name"> + <t t-esc="chatWindow.name"/> + </div> + <t t-if="chatWindow.thread and chatWindow.thread.message_unread_counter > 0"> + <div class="o_ChatWindowHeader_counter o_ChatWindowHeader_item"> + (<t t-esc="chatWindow.thread.message_unread_counter"/>) + </div> + </t> + <div class="o-autogrow"/> + <div class="o_ChatWindowHeader_item o_ChatWindowHeader_rightArea"> + <t t-if="chatWindow.hasShiftLeft"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftLeft" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftLeft" title="Shift left"> + <i class="fa fa-angle-left"/> + </div> + </t> + <t t-if="chatWindow.hasShiftRight"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftRight" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftRight" title="Shift right"> + <i class="fa fa-angle-right"/> + </div> + </t> + <t t-if="props.isExpandable"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandExpand" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickExpand" title="Open in Discuss"> + <i class="fa fa-expand"/> + </div> + </t> + <t t-if="!props.hasCloseAsBackButton"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close chat window"> + <i class="fa fa-close"/> + </div> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js new file mode 100644 index 00000000000..b598eeee4b4 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js @@ -0,0 +1,142 @@ +odoo.define('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js', function (require) { +'use strict'; + +const components = { + ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ChatWindowHiddenMenu extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatWindowManager = this.env.messaging.chatWindowManager; + const device = this.env.messaging.device; + const locale = this.env.messaging.locale; + return { + chatWindowManager: chatWindowManager ? chatWindowManager.__state : undefined, + device: device ? device.__state : undefined, + localeTextDirection: locale ? locale.textDirection : undefined, + }; + }); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + /** + * Reference of the dropup list. Useful to auto-set max height based on + * browser screen height. + */ + this._listRef = useRef('list'); + /** + * The intent of the toggle button depends on the last rendered state. + */ + this._wasMenuOpen; + } + + mounted() { + this._apply(); + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + patched() { + this._apply(); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _apply() { + this._applyListHeight(); + this._applyOffset(); + this._wasMenuOpen = this.env.messaging.chatWindowManager.isHiddenMenuOpen; + } + + /** + * @private + */ + _applyListHeight() { + const device = this.env.messaging.device; + const height = device.globalWindowInnerHeight / 2; + this._listRef.el.style['max-height'] = `${height}px`; + } + + /** + * @private + */ + _applyOffset() { + const textDirection = this.env.messaging.locale.textDirection; + const offsetFrom = textDirection === 'rtl' ? 'left' : 'right'; + const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right'; + const offset = this.env.messaging.chatWindowManager.visual.hidden.offset; + this.el.style[offsetFrom] = `${offset}px`; + this.el.style[oppositeFrom] = 'auto'; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Closes the menu when clicking outside. + * Must be done as capture to avoid stop propagation. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + if (this.el.contains(ev.target)) { + return; + } + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickToggle(ev) { + if (this._wasMenuOpen) { + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } else { + this.env.messaging.chatWindowManager.openHiddenMenu(); + } + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.chat_window} ev.detail.chatWindow + */ + _onClickedChatWindow(ev) { + const chatWindow = ev.detail.chatWindow; + chatWindow.makeVisible(); + chatWindow.unfold(); + chatWindow.focus(); + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } + +} + +Object.assign(ChatWindowHiddenMenu, { + components, + props: {}, + template: 'mail.ChatWindowHiddenMenu', +}); + +return ChatWindowHiddenMenu; + +}); diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss new file mode 100644 index 00000000000..119d61848ab --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowHiddenMenu { + position: fixed; + bottom: 0; + display: flex; + width: 50px; + height: 28px; + align-items: stretch; +} + +.o_ChatWindowHiddenMenu_chatWindowHeader { + max-width: 200px; +} + +.o_ChatWindowHiddenMenu_dropdownToggle { + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 auto; + max-width: 100%; +} + +.o_ChatWindowHiddenMenu_dropdownToggleIcon { + margin-right: 1px; +} + +.o_ChatWindowHiddenMenu_dropdownToggleItem { + margin: 0 3px; +} + +.o_ChatWindowHiddenMenu_list { + overflow: auto; + margin: 0; + padding: 0; +} + +.o_ChatWindowHiddenMenu_listItem { + + &:not(:last-child) { + margin-bottom: 1px; + } +} + +.o_ChatWindowHiddenMenu_unreadCounter { + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + z-index: 1001; // on top of bootstrap dropup menu +} + +.o_ChatWindowHiddenMenu_windowCounter { + overflow: hidden; + text-overflow: ellipsis; + margin-left: 1px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindowHiddenMenu { + background-color: gray('900'); + border-radius: 6px 6px 0 0; + color: white; + cursor: pointer; +} + +.o_ChatWindowHiddenMenu_chatWindowHeader { + opacity: 0.95; + + &:hover { + opacity: 1; + } +} + +.o_ChatWindowHiddenMenu_dropdownToggle.show { + opacity: 0.5; +} + +.o_ChatWindowHiddenMenu_unreadCounter { + background-color: $o-brand-primary; +} + +.o_ChatWindowHiddenMenu_windowCounter { + user-select: none; +} diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml new file mode 100644 index 00000000000..943fc71d09d --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowHiddenMenu" owl="1"> + <div class="dropup o_ChatWindowHiddenMenu"> + <div class="dropdown-toggle o_ChatWindowHiddenMenu_dropdownToggle" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" t-on-click="_onClickToggle"> + <div class="fa fa-comments-o o_ChatWindowHiddenMenu_dropdownToggleIcon o_ChatWindowHiddenMenu_dropdownToggleItem"/> + <div class="o_ChatWindowHiddenMenu_dropdownToggleItem o_ChatWindowHiddenMenu_windowCounter"> + <t t-esc="env.messaging.chatWindowManager.allOrderedHidden.length"/> + </div> + </div> + <ul class="dropdown-menu dropdown-menu-right o_ChatWindowHiddenMenu_list" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" role="menu" t-ref="list"> + <t t-foreach="env.messaging.chatWindowManager.allOrderedHidden" t-as="chatWindow" t-key="chatWindow.localId"> + <li class="o_ChatWindowHiddenMenu_listItem" role="menuitem"> + <ChatWindowHeader + class="o_ChatWindowHiddenMenu_chatWindowHeader" + chatWindowLocalId="chatWindow.localId" + t-on-o-clicked="_onClickedChatWindow" + /> + </li> + </t> + </ul> + <t t-if="env.messaging.chatWindowManager.unreadHiddenConversationAmount > 0"> + <div class="badge badge-pill o_ChatWindowHiddenMenu_unreadCounter"> + <t t-esc="env.messaging.chatWindowManager.unreadHiddenConversationAmount"/> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js new file mode 100644 index 00000000000..956c6811e68 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js @@ -0,0 +1,37 @@ +odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager.js', function (require) { +'use strict'; + +const components = { + ChatWindow: require('mail/static/src/components/chat_window/chat_window.js'), + ChatWindowHiddenMenu: require('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatWindowManager extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + return { + chatWindowManagerVisual: this.env.messaging.chatWindowManager.visual, + device: this.env.messaging.device, + }; + }); + } + +} + +Object.assign(ChatWindowManager, { + components, + props: {}, + template: 'mail.ChatWindowManager', +}); + +return ChatWindowManager; + +}); diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss new file mode 100644 index 00000000000..1e8abf54184 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowManager { + bottom: 0; + right: 0; + display: flex; + flex-direction: row-reverse; + z-index: 1000; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml new file mode 100644 index 00000000000..3ced7210da1 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowManager" owl="1"> + <div class="o_ChatWindowManager"> + <!-- Note: DOM elements are ordered from left to right --> + <t t-if="env.messaging.chatWindowManager.hasHiddenChatWindows"> + <ChatWindowHiddenMenu class="o_ChatWindowManager_hiddenMenu"/> + </t> + <t t-foreach="env.messaging.chatWindowManager.allOrderedVisible" t-as="chatWindow" t-key="chatWindow.localId"> + <ChatWindow + chatWindowLocalId="chatWindow.localId" + hasCloseAsBackButton="env.messaging.device.isMobile" + isExpandable="!env.messaging.device.isMobile and !!chatWindow.thread" + isFullscreen="env.messaging.device.isMobile" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js new file mode 100644 index 00000000000..f987a40db46 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js @@ -0,0 +1,1243 @@ +odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + inputFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { + file: { createFile }, + dom: { triggerEvent }, +} = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chat_window_manager', {}, function () { +QUnit.module('chat_window_manager_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({ + hasChatWindow: true, + hasMessagingMenu: true, + }, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.widget) { + this.widget.destroy(); + } + }, +}); + +QUnit.test('initial mount', async function (assert) { + assert.expect(1); + + await this.start(); + assert.strictEqual(document.querySelectorAll('.o_ChatWindowManager').length, + 1, + "should have chat window manager"); +}); + +QUnit.test('chat window new message: basic rendering', async function (assert) { + assert.expect(10); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header`).length, + 1, + "should have a header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_name`).length, + 1, + "should have name part in header" + ); + assert.strictEqual( + document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_name`).textContent, + "New message", + "should display 'new message' in the header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_command`).length, + 1, + "should have 1 command in header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).length, + 1, + "should have command to close chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageForm`).length, + 1, + "should have a new message chat window container" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageFormLabel`).length, + 1, + "should have a part in selection with label" + ); + assert.strictEqual( + document.querySelector(`.o_ChatWindow_newMessageFormLabel`).textContent.trim(), + "To:", + "should have label 'To:' in selection" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageFormInput`).length, + 1, + "should have an input in selection" + ); +}); + +QUnit.test('chat window new message: focused on open', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'), + "chat window should be focused" + ); + assert.ok( + document.activeElement, + document.querySelector(`.o_ChatWindow_newMessageFormInput`), + "chat window focused = selection input focused" + ); +}); + +QUnit.test('chat window new message: close', async function (assert) { + assert.expect(1); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 0, + "chat window should be closed" + ); +}); + +QUnit.test('chat window new message: fold', async function (assert) { + assert.expect(3); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.notOk( + document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'), + "chat window should not be folded by default" + ); + + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'), + "chat window should become folded" + ); + + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.notOk( + document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'), + "chat window should become unfolded" + ); +}); + +QUnit.test('chat window: basic rendering', async function (assert) { + assert.expect(11); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_NotificationList_preview`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + const chatWindow = document.querySelector(`.o_ChatWindow`); + assert.strictEqual( + chatWindow.dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId, + "should have open a chat window of channel" + ); + assert.strictEqual( + chatWindow.querySelectorAll(`:scope .o_ChatWindow_header`).length, + 1, + "should have header part" + ); + const chatWindowHeader = chatWindow.querySelector(`:scope .o_ChatWindow_header`); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have thread icon in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_name`).length, + 1, + "should have thread name in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelector(`:scope .o_ChatWindowHeader_name`).textContent, + "General", + "should have correct thread name in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_command`).length, + 2, + "should have 2 commands in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandExpand`).length, + 1, + "should have command to expand thread in discuss" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandClose`).length, + 1, + "should have command to close chat window" + ); + assert.strictEqual( + chatWindow.querySelectorAll(`:scope .o_ChatWindow_thread`).length, + 1, + "should have part to display thread content inside chat window" + ); + assert.ok( + chatWindow.querySelector(`:scope .o_ChatWindow_thread`).classList.contains('o_ThreadViewer'), + "thread viewer part should use component ThreadViewer" + ); +}); + +QUnit.test('chat window: fold', async function (assert) { + assert.expect(24); + + let foldCall = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + assert.step(`rpc:${args.method}/${args.kwargs.state}`); + foldCall++; + const kwargsKeys = Object.keys(args.kwargs); + assert.strictEqual( + args.args.length, + 0, + "channel_fold call have no args" + ); + assert.strictEqual( + kwargsKeys.length, + 2, + "channel_fold call have exactly 2 kwargs" + ); + assert.ok( + kwargsKeys.includes('state'), + "channel_fold call have 'state' kwargs" + ); + assert.ok( + kwargsKeys.includes('uuid'), + "channel_fold call have 'uuid' kwargs" + ); + assert.strictEqual( + args.kwargs.uuid, + 'channel-20-uuid', + "channel_fold call uuid is from channel 20" + ); + if (foldCall % 2 === 0) { + assert.strictEqual( + args.kwargs.state, + 'folded', + "channel_fold call state is 'folded'" + ); + } else { + assert.strictEqual( + args.kwargs.state, + 'open', + "channel_fold call state is 'open'" + ); + } + return []; + } + return this._super(...arguments); + }, + }); + // Open Thread + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); + // Fold chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.verifySteps(['rpc:channel_fold/folded']); + // Unfold chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.verifySteps(['rpc:channel_fold/open']); +}); + +QUnit.test('chat window: open / close', async function (assert) { + assert.expect(24); + + let foldCall = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + assert.step(`rpc:channel_fold/${args.kwargs.state}`); + foldCall++; + const kwargsKeys = Object.keys(args.kwargs); + assert.strictEqual( + args.args.length, + 0, + "channel_fold call have no args" + ); + assert.strictEqual( + kwargsKeys.length, + 2, + "channel_fold call have exactly 2 kwargs" + ); + assert.ok( + kwargsKeys.includes('state'), + "channel_fold call have 'state' kwargs" + ); + assert.ok( + kwargsKeys.includes('uuid'), + "channel_fold call have 'uuid' kwargs" + ); + assert.strictEqual( + args.kwargs.uuid, + 'channel-20-uuid', + "channel_fold call uuid should be correct" + ); + if (foldCall % 2 === 0) { + assert.strictEqual( + args.kwargs.state, + 'closed', + "channel_fold call state is 'closed'" + ); + } else { + assert.strictEqual( + args.kwargs.state, + 'open', + "channel_fold call state is 'open'" + ); + } + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); + + // Close chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click()); + assert.verifySteps(['rpc:channel_fold/closed']); + + // Reopen chat window + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); +}); + +QUnit.test('chat window: close on ESCAPE', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + assert.step(`rpc:channel_fold/${args.kwargs.state}`); + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); + + await afterNextRender(() => { + document.querySelector(`.o_ChatWindow`).focus(); + const kevt = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ChatWindow`).dispatchEvent(kevt); + }); + assert.verifySteps(['rpc:channel_fold/closed']); +}); + +QUnit.test('chat window: state conservation on toggle home menu', async function (assert) { + assert.expect(9); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + return; + } else if (args.method === 'message_fetch') { + return [...Array(20).keys()].map(i => { + return { + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: i + 10, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: i + 1, + }; + }); + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + // Set a scroll position to chat window + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 142; + // Set html content of the composer of the chat window + let composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + composerTextInputTextArea.focus(); + document.execCommand('insertText', false, 'XDU for the win !'); + // Set attachments of the composer + const files = [ + await createFile({ + name: 'text state conservation on toggle home menu.txt', + content: 'hello, world', + contentType: 'text/plain', + }), + await createFile({ + name: 'text2 state conservation on toggle home menu.txt', + content: 'hello, xdu is da best man', + contentType: 'text/plain', + }) + ]; + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + files + ) + ); + assert.strictEqual( + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop, + 142, + "verify chat window initial scrollTop" + ); + composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + assert.strictEqual( + composerTextInputTextArea.value, + "XDU for the win !", + "verifif chat window composer initial html input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "verify chat window composer initial attachment count" + ); + // Hide home menu + await this.env.bus.trigger('will_hide_home_menu'); + await this.env.bus.trigger('hide_home_menu'); + assert.strictEqual( + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same (1)" + ); + composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + assert.strictEqual( + composerTextInputTextArea.value, + "XDU for the win !", + "Chat window composer should still have the same html input (1)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "Chat window composer should have 2 attachments (1)" + ); + + // Show home menu + await this.env.bus.trigger('will_show_home_menu'); + await this.env.bus.trigger('show_home_menu'); + assert.strictEqual( + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same (2)" + ); + composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + assert.strictEqual( + composerTextInputTextArea.value, + "XDU for the win !", + "chat window composer should still have the same html input (2)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "Chat window composer should have 2 attachments (2)" + ); +}); + +QUnit.test('open 2 different chat windows: enough screen width [REQUIRE FOCUS]', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) + * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1920px + * + * Enough space for 2 visible chat windows: + * 10 + 325 + 5 + 325 + 10 = 670 < 1920 + */ + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + env: { + browser: { + innerHeight: 1080, + innerWidth: 1920, + }, + }, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }, { + id: 10, + last_message: { + author_id: [7, "Demo"], + body: "<p>test2</p>", + channel_ids: [10], + id: 101, + message_type: 'comment', + model: 'mail.channel', + res_id: 10, + }, + }]; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of chat should be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of chat should have focus" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open a new chat window" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of channel should be open" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of chat should still be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of channel should have focus" + ); + assert.notOk( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of chat should no longer have focus" + ); +}); + +QUnit.test('open 2 chat windows: check shift operations are available', async function (assert) { + assert.expect(9); + + const channel = { + channel_type: "channel", + id: 20, + name: "General", + }; + const chat = { + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }; + this.data['mail.channel'].records = [channel, chat]; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [channel], + channel_direct_message: [chat], + }, + }); + await this.start(); + await afterNextRender(() => { + document.querySelector('.o_MessagingMenu_toggler').click(); + }); + await afterNextRender(() => { + document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[0].click(); + }); + await afterNextRender(() => { + document.querySelector('.o_MessagingMenu_toggler').click(); + }); + await afterNextRender(() => { + document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[1].click(); + }); + + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "should have opened 2 chat windows" + ); + assert.containsOnce( + document.querySelectorAll('.o_ChatWindow')[0], + '.o_ChatWindowHeader_commandShiftLeft', + "first chat window should be allowed to shift left" + ); + assert.containsNone( + document.querySelectorAll('.o_ChatWindow')[0], + '.o_ChatWindowHeader_commandShiftRight', + "first chat window should not be allowed to shift right" + ); + assert.containsNone( + document.querySelectorAll('.o_ChatWindow')[1], + '.o_ChatWindowHeader_commandShiftLeft', + "second chat window should not be allowed to shift left" + ); + assert.containsOnce( + document.querySelectorAll('.o_ChatWindow')[1], + '.o_ChatWindowHeader_commandShiftRight', + "second chat window should be allowed to shift right" + ); + + const initialFirstChatWindowThreadLocalId = + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId; + const initialSecondChatWindowThreadLocalId = + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId; + await afterNextRender(() => { + document.querySelectorAll('.o_ChatWindow')[0] + .querySelector(':scope .o_ChatWindowHeader_commandShiftLeft') + .click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "First chat window should be second after it has been shift left" + ); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "Second chat window should be first after the first has been shifted left" + ); + + await afterNextRender(() => { + document.querySelectorAll('.o_ChatWindow')[1] + .querySelector(':scope .o_ChatWindowHeader_commandShiftRight') + .click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "First chat window should be back at first place after being shifted left then right" + ); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "Second chat window should be back at second place after first one has been shifted left then right" + ); +}); + +QUnit.test('open 3 different chat windows: not enough screen width', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: 900px) + * (others: @see `mail/static/src/models/chat_window_manager/chat_window_manager.js:visual`) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1080px + * + * Enough space for 2 visible chat windows, and one hidden chat window: + * 3 visible chat windows: + * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 900 + * 2 visible chat windows + hidden menu: + * 10 + 325 + 5 + 325 + 10 + 200 + 5 = 875 < 900 + */ + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "channel1", + }, { + channel_type: "channel", + id: 2, + name: "channel2", + }, { + channel_type: "channel", + id: 3, + name: "channel3", + }], + }, + }); + await this.start({ + env: { + browser: { + innerHeight: 900, + innerWidth: 900, + }, + }, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + // open, from systray menu, chat windows of channels with Id 1, 2, then 3 + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open 1 visible chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 0, + "should not have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open 2 visible chat windows" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 0, + "should not have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 3 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open 2 visible chat windows" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 1, + "should have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of channel 1 should be open" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 3 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of channel 3 should be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 3 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of channel 3 should have focus" + ); +}); + +QUnit.test('chat window: switch on TAB', async function (assert) { + assert.expect(10); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "channel1", + }, { + channel_type: "channel", + id: 2, + name: "channel2", + }], + }, + }); + await this.start(); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"]` + ).click() + ); + + assert.containsOnce(document.body, '.o_ChatWindow', "Only 1 chatWindow must be opened"); + const chatWindow = document.querySelector('.o_ChatWindow'); + assert.strictEqual( + chatWindow.querySelector('.o_ChatWindowHeader_name').textContent, + 'channel1', + "The name of the only chatWindow should be 'channel1' (channel with ID 1)" + ); + assert.strictEqual( + chatWindow.querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The chatWindow composer must have focus" + ); + + await afterNextRender(() => + triggerEvent( + chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.strictEqual( + chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'), + document.activeElement, + "The chatWindow composer still has focus" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"]` + ).click() + ); + + assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows must be opened"); + const chatWindows = document.querySelectorAll('.o_ChatWindow'); + assert.strictEqual( + chatWindows[0].querySelector('.o_ChatWindowHeader_name').textContent, + 'channel1', + "The name of the 1st chatWindow should be 'channel1' (channel with ID 1)" + ); + assert.strictEqual( + chatWindows[1].querySelector('.o_ChatWindowHeader_name').textContent, + 'channel2', + "The name of the 2nd chatWindow should be 'channel2' (channel with ID 2)" + ); + assert.strictEqual( + chatWindows[1].querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The 2nd chatWindow composer must have focus (channel with ID 2)" + ); + + await afterNextRender(() => + triggerEvent( + chatWindows[1].querySelector('.o_ComposerTextInput_textarea'), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows should still be opened"); + assert.strictEqual( + chatWindows[0].querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The 1st chatWindow composer must have focus (channel with ID 1)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/chatter/chatter.js b/addons/mail/static/src/components/chatter/chatter.js new file mode 100644 index 00000000000..5503cc90d8b --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.js @@ -0,0 +1,103 @@ +odoo.define('mail/static/src/components/chatter/chatter.js', function (require) { +'use strict'; + +const components = { + ActivityBox: require('mail/static/src/components/activity_box/activity_box.js'), + AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'), + ChatterTopbar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'), + Composer: require('mail/static/src/components/composer/composer.js'), + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Chatter extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + const thread = chatter ? chatter.thread : undefined; + let attachments = []; + if (thread) { + attachments = thread.allAttachments; + } + return { + attachments: attachments.map(attachment => attachment.__state), + chatter: chatter ? chatter.__state : undefined, + thread: thread ? thread.__state : undefined, + }; + }, { + compareDepth: { + attachments: 1, + }, + }); + this._threadRef = useRef('thread'); + } + + mounted() { + if (this.chatter.thread) { + this._notifyRendered(); + } + } + + patched() { + if (this.chatter.thread) { + this._notifyRendered(); + } + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _notifyRendered() { + this.trigger('o-chatter-rendered', { + attachments: this.chatter.thread.allAttachments, + thread: this.chatter.thread.localId, + }); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onComposerMessagePosted() { + this.chatter.update({ isComposerVisible: false }); + } + +} + +Object.assign(Chatter, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.Chatter', +}); + +return Chatter; + +}); diff --git a/addons/mail/static/src/components/chatter/chatter.scss b/addons/mail/static/src/components/chatter/chatter.scss new file mode 100644 index 00000000000..ef33f676de2 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.scss @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Chatter { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: auto; +} + +.o_Chatter_composer { + border-bottom: $border-width solid; + + &.o-bordered { + border-left: $border-width solid; + border-right: $border-width solid; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Chatter { + background-color: white; + border-color: $border-color; +} + +.o_Chatter_composer { + border-bottom-color: $border-color; + + &.o-bordered { + border-left-color: $border-color; + border-right-color: $border-color; + } +} diff --git a/addons/mail/static/src/components/chatter/chatter.xml b/addons/mail/static/src/components/chatter/chatter.xml new file mode 100644 index 00000000000..f630eaf7634 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Chatter" owl="1"> + <div class="o_Chatter"> + <t t-if="chatter"> + <ChatterTopbar + class="o_Chatter_topbar" + chatterLocalId="chatter.localId" + /> + <t t-if="chatter.hasThread and chatter.thread and chatter.isComposerVisible"> + <Composer + class="o_Chatter_composer" + t-att-class="{ 'o-bordered': chatter.hasExternalBorder }" + composerLocalId="chatter.thread.composer.localId" + hasMentionSuggestionsBelowPosition="true" + hasTextInputSendOnEnterEnabled="false" + isCompact="false" + isExpandable="true" + isLog="chatter.isComposerLog" + t-on-o-message-posted="_onComposerMessagePosted" + t-ref="composer" + /> + </t> + <t t-if="chatter.isAttachmentBoxVisible"> + <AttachmentBox + class="o_Chatter_attachmentBox" + threadLocalId="chatter.thread.localId" + /> + </t> + <t t-if="chatter.thread"> + <t t-if="chatter.hasActivities and chatter.activities.length > 0"> + <ActivityBox + class="o_Chatter_activityBox" + chatterLocalId="chatter.localId" + /> + </t> + <t t-if="chatter.hasThread"> + <ThreadViewer + class="o_Chatter_thread" + hasComposer="false" + haveMessagesAuthorRedirect="true" + order="'desc'" + threadViewerLocalId="chatter.threadViewer.localId" + t-ref="thread" + /> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter/chatter_tests.js b/addons/mail/static/src/components/chatter/chatter_tests.js new file mode 100644 index 00000000000..a27a4036105 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter_tests.js @@ -0,0 +1,337 @@ +odoo.define('mail/static/src/components/chatter/chatter_tests', function (require) { +'use strict'; + +const components = { + Chatter: require('mail/static/src/components/chatter/chatter.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chatter', {}, function () { +QUnit.module('chatter_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createChatterComponent = async ({ chatter }, otherProps) => { + const ChatterComponent = components.Chatter; + ChatterComponent.env = this.env; + this.component = new ChatterComponent( + null, + Object.assign({ chatterLocalId: chatter.localId }, otherProps) + ); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.Chatter.env; + this.env = undefined; + }, +}); + +QUnit.test('base rendering when chatter has no attachment', async function (assert) { + assert.expect(6); + + const messages = [...Array(60).keys()].map(id => { + return { + author_id: [10, "Demo User"], + body: `<p>Message ${id + 1}</p>`, + date: "2019-04-20 10:00:00", + id: id + 1, + message_type: 'comment', + model: 'res.partner', + record_name: 'General', + res_id: 100, + }; + }); + this.data['mail.message'].records = messages; + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_thread`).length, + 1, + "should have a thread in the chatter" + ); + assert.strictEqual( + document.querySelector(`.o_Chatter_thread`).dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'res.partner' + ).localId, + "thread should have the right thread local id" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "the first 30 messages of thread should be loaded" + ); +}); + +QUnit.test('base rendering when chatter has no record', async function (assert) { + assert.expect(7); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_thread`).length, + 1, + "should have a thread in the chatter" + ); + assert.ok( + chatter.thread.isTemporary, + "thread should have a temporary thread linked to chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 1, + "should have a message" + ); + assert.strictEqual( + document.querySelector(`.o_Message_content`).textContent, + "Creating a new record...", + "should have the 'Creating a new record ...' message" + ); +}); + +QUnit.test('base rendering when chatter has attachments', async function (assert) { + assert.expect(3); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); +}); + +QUnit.test('show attachment box', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 1, + "should have an attachment box in the chatter" + ); +}); + +QUnit.test('composer show/hide on log note/send message', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length, + 1, + "should have a send message button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length, + 1, + "should have a log note button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should not have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should still have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should have no composer anymore" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should have no composer anymore" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.js b/addons/mail/static/src/components/chatter_container/chatter_container.js new file mode 100644 index 00000000000..0c5de5208fc --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.js @@ -0,0 +1,80 @@ +odoo.define('mail/static/src/components/chatter_container/chatter_container.js', function (require) { +'use strict'; + +const components = { + Chatter: require('mail/static/src/components/chatter/chatter.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +/** + * This component abstracts chatter component to its parent, so that it can be + * mounted and receive chatter data even when a chatter component cannot be + * created. Indeed, in order to create a chatter component, we must create + * a chatter record, the latter requiring messaging to be initialized. The view + * may attempt to create a chatter before messaging has been initialized, so + * this component delays the mounting of chatter until it becomes initialized. + */ +class ChatterContainer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.chatter = undefined; + this._wasMessagingInitialized = false; + useStore(props => { + const isMessagingInitialized = this.env.isMessagingInitialized(); + if (!this._wasMessagingInitialized && isMessagingInitialized) { + this._wasMessagingInitialized = true; + this.chatter = this.env.models['mail.chatter'].create(props); + } + return { isMessagingInitialized }; + }); + } + + mounted() { + this._update(); + } + + patched() { + this._update(); + } + + destroy() { + super.destroy(); + if (this.chatter) { + this.chatter.delete(); + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + if (this.chatter) { + this.chatter.update(this.props); + } + } + +} + +Object.assign(ChatterContainer, { + components, + /** + * No props validation because this component simply forwards props to + * chatter record as its data. + */ + template: 'mail.ChatterContainer', +}); + + +return ChatterContainer; + +}); diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.scss b/addons/mail/static/src/components/chatter_container/chatter_container.scss new file mode 100644 index 00000000000..99e8d530fb3 --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.scss @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatterContainer { + display: flex; + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.xml b/addons/mail/static/src/components/chatter_container/chatter_container.xml new file mode 100644 index 00000000000..f9ee262e69b --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatterContainer" owl="1"> + <div class="o_ChatterContainer"> + <t t-if="chatter"> + <Chatter chatterLocalId="chatter.localId"/> + </t> + <t t-else=""> + Please wait... + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js new file mode 100644 index 00000000000..c4b088b7709 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js @@ -0,0 +1,128 @@ +odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar.js', function (require) { +'use strict'; + +const components = { + FollowButton: require('mail/static/src/components/follow_button/follow_button.js'), + FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatterTopbar extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + const thread = chatter ? chatter.thread : undefined; + const threadAttachments = thread ? thread.allAttachments : []; + return { + areThreadAttachmentsLoaded: thread && thread.areAttachmentsLoaded, + chatter: chatter ? chatter.__state : undefined, + threadAttachmentsAmount: threadAttachments.length, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAttachments(ev) { + this.chatter.update({ + isAttachmentBoxVisible: !this.chatter.isAttachmentBoxVisible, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + this.trigger('o-close-chatter'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickLogNote(ev) { + if (this.chatter.isComposerVisible && this.chatter.isComposerLog) { + this.chatter.update({ isComposerVisible: false }); + } else { + this.chatter.showLogNote(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickScheduleActivity(ev) { + const action = { + type: 'ir.actions.act_window', + name: this.env._t("Schedule Activity"), + res_model: 'mail.activity', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.chatter.threadId, + default_res_model: this.chatter.threadModel, + }, + res_id: false, + }; + return this.env.bus.trigger('do-action', { + action, + options: { + // A bit "extreme", could be improved: + // normally only an activity is created (no update nor delete) + on_close: () => this.chatter.refreshActivities(), + }, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSendMessage(ev) { + if (this.chatter.isComposerVisible && !this.chatter.isComposerLog) { + this.chatter.update({ isComposerVisible: false }); + } else { + this.chatter.showSendMessage(); + } + } + +} + +Object.assign(ChatterTopbar, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.ChatterTopbar', +}); + +return ChatterTopbar; + +}); diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss new file mode 100644 index 00000000000..17c5fa5d856 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss @@ -0,0 +1,100 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatterTopbar { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.o_ChatterTopbar_actions { + display: flex; + flex: 1; + flex-direction: row; + flex-wrap: wrap; + + &.o-has-active-button { + border-bottom: $border-width solid; + } +} + +.o_ChatterTopbar_button { + margin-bottom: -$border-width; /* Needed to allow "overriding" of the bottom border */ +} + +.o_ChatterTopbar_buttonAttachmentsCountLoader { + margin-left: 2px; +} + +.o_ChatterTopbar_buttonCount { + padding-left: 0.25rem; +} + +.o_ChatterTopbar_buttonClose { + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 34px; + height: 34px; +} + +.o_ChatterTopbar_followerListMenu { + display: flex; +} + +.o_ChatterTopbar_rightSection { + display: flex; + flex: 1 0 auto; + justify-content: flex-end; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatterTopbar_actions.o-has-active-button { + border-color: $border-color; +} + +.o_ChatterTopbar_button { + border-radius: 0; + + &:hover { + background-color: gray('300'); + } + + &.o-active { + color: $o-brand-odoo; + background-color: lighten(gray('300'), 7%); + border-right-color: $border-color; + + &:not(:first-of-type), + &:first-of-type.o-bordered { + border-left-color: $border-color; + } + + &.o-bordered { + border-top-color: $border-color; + } + + &:hover { + background-color: gray('300'); + color: $link-hover-color; + } + } +} + +.o_ChatterTopbar_buttonClose { + border-radius: 0 0 10px 10px; + font-size: $font-size-lg; + background-color: gray('700'); + color: gray('100'); + cursor: pointer; + + &:hover { + background-color: gray('600'); + color: $white; + } +} diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml new file mode 100644 index 00000000000..67dfe4ea844 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatterTopbar" owl="1"> + <div class="o_ChatterTopbar"> + <t t-if="chatter"> + <div class="o_ChatterTopbar_actions" t-att-class="{'o-has-active-button': chatter.isComposerVisible }"> + <t t-if="chatter.hasThread"> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonSendMessage" + type="button" + t-att-class="{ + 'o-active': chatter.isComposerVisible and !chatter.isComposerLog, + 'o-bordered': chatter.hasExternalBorder, + }" + t-att-disabled="chatter.isDisabled" + t-on-click="_onClickSendMessage" + > + Send message + </button> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonLogNote" + type="button" + t-att-class="{ + 'o-active': chatter.isComposerVisible and chatter.isComposerLog, + 'o-bordered': chatter.hasExternalBorder, + }" + t-att-disabled="chatter.isDisabled" + t-on-click="_onClickLogNote" + > + Log note + </button> + </t> + <t t-if="chatter.hasActivities"> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonScheduleActivity" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickScheduleActivity"> + <i class="fa fa-clock-o"/> + Schedule activity + </button> + </t> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonAttachments" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickAttachments"> + <i class="fa fa-paperclip"/> + <t t-if="chatter.isDisabled or !chatter.isShowingAttachmentsLoading"> + <span class="o_ChatterTopbar_buttonCount o_ChatterTopbar_buttonAttachmentsCount" t-esc="chatter.thread ? chatter.thread.allAttachments.length : 0"/> + </t> + <t t-else=""> + <i class="o_ChatterTopbar_buttonAttachmentsCountLoader fa fa-spinner fa-spin" aria-label="Attachment counter loading..."/> + </t> + </button> + <div class="o-autogrow"/> + <div class="o_ChatterTopbar_rightSection"> + <t t-if="chatter.hasFollowers and chatter.thread"> + <FollowButton + class="o_ChatterTopbar_button o_ChatterTopbar_followButton" + isDisabled="chatter.isDisabled" + threadLocalId="chatter.thread.localId" + /> + <FollowerListMenu + class="o_ChatterTopbar_button o_ChatterTopbar_followerListMenu" + isDisabled="chatter.isDisabled" + threadLocalId="chatter.thread.localId" + /> + </t> + </div> + </div> + <t t-if="chatter.hasTopbarCloseButton"> + <div class="o_ChatterTopbar_buttonClose" title="Close" t-on-click="_onClickClose"> + <i class="fa fa-times"/> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js new file mode 100644 index 00000000000..c6c38c081b6 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js @@ -0,0 +1,590 @@ +odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar_tests.js', function (require) { +'use strict'; + +const components = { + ChatterTopBar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { makeTestPromise } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chatter_topbar', {}, function () { +QUnit.module('chatter_topbar_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createChatterTopbarComponent = async (chatter, otherProps) => { + const ChatterTopBarComponent = components.ChatterTopBar; + ChatterTopBarComponent.env = this.env; + this.component = new ChatterTopBarComponent( + null, + Object.assign({ chatterLocalId: chatter.localId }, otherProps) + ); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.ChatterTopBar.env; + this.env = undefined; + }, +}); + +QUnit.test('base rendering', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length, + 1, + "should have a send message button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length, + 1, + "should have a log note button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonScheduleActivity`).length, + 1, + "should have a schedule activity button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_followerListMenu`).length, + 1, + "should have a follower menu" + ); +}); + +QUnit.test('base disabled rendering', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled, + "send message button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled, + "log note button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonScheduleActivity`).disabled, + "schedule activity should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled, + "attachments button should be disabled" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '0', + "attachments button counter should be 0" + ); +}); + +QUnit.test('attachment loading is delayed', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + loadingBaseDelayDuration: 100, + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + return new Promise(() => {}); // simulate long loading + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader yet" + ); + + await afterNextRender(async () => this.env.testUtils.advanceTime(100)); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should now have a loader" + ); +}); + +QUnit.test('attachment counter while loading attachments', async function (assert) { + assert.expect(4); + + await this.start({ + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + return new Promise(() => {}); // simulate long loading + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 0, + "attachments button should not have a counter" + ); +}); + +QUnit.test('attachment counter transition when attachments become loaded)', async function (assert) { + assert.expect(7); + + const attachmentPromise = makeTestPromise(); + await this.start({ + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + await attachmentPromise; + return []; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 0, + "attachments button should not have a counter" + ); + + await afterNextRender(() => attachmentPromise.resolve()); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); +}); + +QUnit.test('attachment counter without attachments', async function (assert) { + assert.expect(4); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '0', + 'attachment counter should contain "0"' + ); +}); + +QUnit.test('attachment counter with attachments', async function (assert) { + assert.expect(4); + + await this.start({ + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '2', + 'attachment counter should contain "2"' + ); +}); + +QUnit.test('composer state conserved when clicking on another topbar button', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + `.o_ChatterTopbar`, + "should have a chatter topbar" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonSendMessage`, + "should have a send message button in chatter menu" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote`, + "should have a log note button in chatter menu" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonAttachments`, + "should have an attachments button in chatter menu" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click(); + }); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote.o-active`, + "log button should now be active" + ); + assert.containsNone( + document.body, + `.o_ChatterTopbar_buttonSendMessage.o-active`, + "send message button should not be active" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click(); + }); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote.o-active`, + "log button should still be active" + ); + assert.containsNone( + document.body, + `.o_ChatterTopbar_buttonSendMessage.o-active`, + "send message button should still be not active" + ); +}); + +QUnit.test('rendering with multiple partner followers', async function (assert) { + assert.expect(7); + + await this.start(); + this.data['res.partner'].records = [{ + id: 100, + message_follower_ids: [1, 2], + }]; + this.data['mail.followers'].records = [ + { + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + channel_id: false, + id: 1, + name: "Jean Michang", + partner_id: 12, + }, { + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + channel_id: false, + id: 2, + name: "Eden Hazard", + partner_id: 11, + }, + ]; + const chatter = this.env.models['mail.chatter'].create({ + followerIds: [1, 2], + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsN( + document.body, + '.o_Follower', + 2, + "exactly two followers should be listed" + ); + assert.containsN( + document.body, + '.o_Follower_name', + 2, + "exactly two follower names should be listed" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[0].textContent.trim(), + "Jean Michang", + "first follower is 'Jean Michang'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[1].textContent.trim(), + "Eden Hazard", + "second follower is 'Eden Hazard'" + ); +}); + +QUnit.test('rendering with multiple channel followers', async function (assert) { + assert.expect(7); + + this.data['res.partner'].records = [{ + id: 100, + message_follower_ids: [1, 2], + }]; + await this.start(); + this.data['mail.followers'].records = [ + { + channel_id: 11, + id: 1, + name: "channel numero 5", + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + partner_id: false, + }, { + channel_id: 12, + id: 2, + name: "channel armstrong", + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + partner_id: false, + }, + ]; + const chatter = this.env.models['mail.chatter'].create({ + followerIds: [1, 2], + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsN( + document.body, + '.o_Follower', + 2, + "exactly two followers should be listed" + ); + assert.containsN( + document.body, + '.o_Follower_name', + 2, + "exactly two follower names should be listed" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[0].textContent.trim(), + "channel numero 5", + "first follower is 'channel numero 5'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[1].textContent.trim(), + "channel armstrong", + "second follower is 'channel armstrong'" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js new file mode 100644 index 00000000000..f2135835a4d --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.js @@ -0,0 +1,393 @@ +odoo.define('mail/static/src/components/composer/composer.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'), + EmojisPopover: require('mail/static/src/components/emojis_popover/emojis_popover.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), + TextInput: require('mail/static/src/components/composer_text_input/composer_text_input.js'), + ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), +}; +const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Composer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.isDropZoneVisible = useDragVisibleDropZone(); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + return { + composer: composer ? composer.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + thread: composer && composer.thread + ? composer.thread.__state + : undefined, + }; + }); + /** + * Reference of the emoji button. Useful to include emoji popover as + * click "inside" the composer for the prop `isDiscardOnClickAway`. + */ + this._emojisPopoverRef = useRef('emojisPopover'); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + /** + * Reference of the text input component. + */ + this._textInputRef = useRef('textInput'); + /** + * Tracked focus counter from props. Useful to determine whether it + * should auto focus this composer when patched. + */ + this._focusCount = 0; + // to focus if the prop changes + this._lastComposer = undefined; + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + this._lastComposer = this.composer; + if (this.props.isFocusOnMount) { + this.focus(); + } + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + patched() { + // focus when changing composer + if ( + this.props.isFocusOnMount && + this._lastComposer !== this.composer + ) { + this.focus(); + } + this._lastComposer = this.composer; + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + /** + * Get the current partner image URL. + * + * @returns {string} + */ + get currentPartnerAvatar() { + const avatar = this.env.session.uid > 0 + ? this.env.session.url('/web/image', { + field: 'image_128', + id: this.env.session.uid, + model: 'res.users', + }) + : '/web/static/src/img/user_menu_avatar.png'; + return avatar; + } + + /** + * Focus the composer. + */ + focus() { + if (this.env.messaging.device.isMobile) { + this.el.scrollIntoView(); + } + this.composer.focus(); + } + + /** + * Focusout the composer. + */ + focusout() { + this._textInputRef.comp.focusout(); + } + + /** + * Determine whether composer should display a footer. + * + * @returns {boolean} + */ + get hasFooter() { + return ( + this.props.hasThreadTyping || + this.composer.attachments.length > 0 || + !this.props.isCompact + ); + } + + /** + * Determine whether the composer should display a header. + * + * @returns {boolean} + */ + get hasHeader() { + return ( + (this.props.hasThreadName && this.composer.thread) || + (this.props.hasFollowers && !this.props.isLog) + ); + } + + /** + * Get an object which is passed to FileUploader component to be used when + * creating attachment. + * + * @returns {Object} + */ + get newAttachmentExtraData() { + return { + composers: [['replace', this.composer]], + }; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Post a message in the composer on related thread. + * + * @private + */ + async _postMessage() { + // TODO: take suggested recipients into account + await this.composer.postMessage({ isLog: this.props.isLog }); + // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done + this.trigger('o-message-posted'); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on attachment button. + * + * @private + */ + _onClickAddAttachment() { + this._fileUploaderRef.comp.openBrowserFileUploader(); + this.focus(); + } + + /** + * Discards the composer when clicking away from the Inbox reply in discuss. + * TODO maybe move this in discuss. task-2092950 + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + // in discuss app: prevents discarding the composer when clicking away + if (!this.props.isDiscardOnClickAway) { + return; + } + // prevents discarding when clicking on self (on discuss and not on discuss) + if (this.el.contains(ev.target)) { + return; + } + // emoji popover is outside but should be considered inside + if (this._emojisPopoverRef.comp.isInsideEventTarget(ev.target)) { + return; + } + this.composer.discard(); + } + + /** + * Called when clicking on "expand" button. + * + * @private + */ + async _onClickFullComposer() { + const attachmentIds = this.composer.attachments.map(attachment => attachment.res_id); + + const context = { + // default_parent_id: this.id, + default_body: mailUtils.escapeAndCompactTextContent(this.composer.textInputContent), + default_attachment_ids: attachmentIds, + // default_partner_ids: partnerIds, + default_is_log: this.props.isLog, + mail_post_autofollow: true, + }; + + // if (this.context.default_model && this.context.default_res_id) { + // context.default_model = this.context.default_model; + // context.default_res_id = this.context.default_res_id; + // } + + const action = { + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: context, + }; + await this.env.bus.trigger('do-action', { action }); + this.trigger('o-full-composer-opened'); + } + + /** + * Called when clicking on "discard" button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard(ev) { + this.composer.discard(); + } + + /** + * Called when clicking on "send" button. + * + * @private + */ + _onClickSend() { + if (!this.composer.canPostMessage) { + return; + } + this._postMessage(); + } + + /** + * Called when some files have been dropped in the dropzone. + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {FileList} ev.detail.files + */ + async _onDropZoneFilesDropped(ev) { + ev.stopPropagation(); + await this._fileUploaderRef.comp.uploadFiles(ev.detail.files); + this.isDropZoneVisible.value = false; + } + + /** + * Called when selection an emoji from the emoji popover (from the emoji + * button). + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.unicode + */ + _onEmojiSelection(ev) { + ev.stopPropagation(); + this._textInputRef.comp.saveStateInStore(); + this.composer.insertIntoTextInput(ev.detail.unicode); + this.composer.focus(); + } + + /** + * @private + * @param {CustomEvent} ev + */ + async _onPasteTextInput(ev) { + if (!ev.clipboardData || !ev.clipboardData.files) { + return; + } + await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files); + } + + /** + * @private + */ + _onTextInputKeydownEnter() { + if (!this.composer.canPostMessage) { + return; + } + this._postMessage(); + } + +} + +Object.assign(Composer, { + components, + defaultProps: { + attachmentLocalIds: [], + hasCurrentPartnerAvatar: true, + hasDiscardButton: false, + hasFollowers: false, + hasSendButton: true, + hasTextInputSendOnEnterEnabled: true, + hasThreadName: false, + hasThreadTyping: false, + isCompact: true, + isDiscardOnClickAway: false, + isExpandable: false, + isFocusOnMount: false, + isLog: false, + }, + props: { + attachmentLocalIds: { + type: Array, + element: String, + }, + attachmentsDetailsMode: { + type: String, + optional: true, + }, + composerLocalId: String, + hasCurrentPartnerAvatar: Boolean, + hasDiscardButton: Boolean, + hasFollowers: Boolean, + hasMentionSuggestionsBelowPosition: { + type: Boolean, + optional: true, + }, + hasSendButton: Boolean, + hasTextInputSendOnEnterEnabled: Boolean, + hasThreadName: Boolean, + hasThreadTyping: Boolean, + showAttachmentsExtensions: { + type: Boolean, + optional: true, + }, + showAttachmentsFilenames: { + type: Boolean, + optional: true, + }, + initialAttachmentLocalIds: { + type: Array, + element: String, + optional: true, + }, + initialTextInputHtmlContent: { + type: String, + optional: true, + }, + isCompact: Boolean, + isDiscardOnClickAway: Boolean, + isExpandable: Boolean, + isFocusOnMount: Boolean, + isLog: Boolean, + }, + template: 'mail.Composer', +}); + +return Composer; + +}); diff --git a/addons/mail/static/src/components/composer/composer.scss b/addons/mail/static/src/components/composer/composer.scss new file mode 100644 index 00000000000..98557ec1ce9 --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.scss @@ -0,0 +1,278 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Composer { + display: grid; + grid-template-areas: + "sidebar-header core-header" + "sidebar-main core-main" + "sidebar-footer core-footer"; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr auto; + + &.o-has-current-partner-avatar { + grid-template-columns: 50px 1fr; + padding: map-get($spacers, 0) map-get($spacers, 3) map-get($spacers, 1) map-get($spacers, 1); + + &:not(.o-has-footer) { + padding-bottom: 20px; + } + + &:not(.o-has-header) { + padding-top: 20px; + } + } +} + +.o_Composer_actionButtons { + &.o-composer-is-compact { + display: flex; + } + &:not(.o-composer-is-compact) { + margin-top: 10px; + } +} + +.o_Composer_attachmentList { + flex: 1 1 auto; +} + +.o_Composer_buttons { + display: flex; + align-items: stretch; + flex: 0 0 auto; + height: 41px; // match minimal-height of input, including border width + + &:not(.o-composer-is-compact) { + border: 0; + height: auto; + padding: 0 10px; + width: 100%; + } + + &.o-mobile { + height: 51px; // mobile padding + } +} + +.o_Composer_coreFooter { + grid-area: core-footer; + + &:not(.o-composer-is-compact) { + margin-left: 0; + } +} + +.o_Composer_coreHeader { + grid-area: core-header; +} + +.o_Composer_coreMain { + grid-area: core-main; + min-width: 0; + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + flex: 1 1 auto; + + &:not(.o-composer-is-compact) { + flex-direction: column; + } +} + +.o_Composer_currentPartnerAvatar { + width: 36px; + height: 36px; +} + +.o_Composer_followers, +.o_Composer_suggestedPartners { + flex: 0 0 100%; + margin-bottom: $o-mail-chatter-gap * 0.5; +} + +.o_Composer_primaryToolButtons { + display: flex; + align-items: center; +} + +.o_Composer_sidebarMain { + grid-area: sidebar-main; + justify-self: center; +} + +.o_Composer_textInput { + flex: 1 1 auto; + align-self: stretch; + + &:not(.o-composer-is-compact) { + border: 0; + min-height: 40px; + + .note-editable { + min-height: 40px; + } + } +} + +.o_Composer_threadTextualTypingStatus { + font-size: $font-size-sm; + overflow: hidden; + text-overflow: ellipsis; + + &:before { + // invisible character so that typing status bar has constant height, regardless of text content. + content: "\200b"; /* unicode zero width space character */ + } +} + +.o_Composer_toolButton { + margin: 0 2px; + + &:not(.o-mobile) { + margin-top: 2px; + margin-bottom: 2px; + } +} + +.o_Composer_toolButtons { + display: flex; + + &:not(.o-composer-is-compact) { + flex-direction: row; + justify-content: space-between; + flex: 100%; + } + + &:not(:last-child) .o_Composer_toolButton:last-child { + margin-right: 5px; + } +} + +.o_Composer_toolButtonSeparator { + flex: 0 0 auto; + margin-top: 5px; + margin-bottom: 5px; + margin-right: 5px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Composer { + background-color: lighten(gray('300'), 7%); + + &.o-has-current-partner-avatar { + + .o_Composer_actionButtons.o-composer-is-compact > *:last-child { + border-radius: 0 10px 10px 0; + } + + .o_Composer_textInput { + border-radius: 10px 0 0 10px; + + > .note-editor { + border-radius: 10px 0 0 10px; + + > .note-editing-area { + border-radius: 10px 0 0 10px; + } + } + } + } +} + +.o_Composer_button { + color: gray('600'); + + &.o-open{ + background-color: gray('200'); + } + + &.o-composer-is-compact { + border-left: none; + border-radius: 0 3px 3px 0; + } +} + +// TODO FIXME o-open on the button should be enough, https://github.com/odoo/owl/issues/693 +.o_is_open { + .o_Composer_button { + background-color: gray('200'); + } +} + +.o_Composer_buttonDiscard { + border: 1px solid lighten(gray('400'), 5%); +} + +.o_Composer_buttons { + border: 0; +} + +.o_Composer_buttonSend { + border: 1px solid darken($o-brand-primary, 5%); + + &:disabled { + // enforce this style (override jQuery style) + border: 1px solid darken($o-brand-primary, 5%); + + &.o-composer-is-compact { + border-left: none; + } + } + + &:not(:last-child) { + border-right: none; + } +} + +.o_Composer_coreMain:not(.o-composer-is-compact) { + background: white; + border-radius: 5px; + border: 1px solid lighten(gray('400'), 5%); +} + +.o_Composer_textInput { + appearance: none; + outline: none; + background-color: white; + border: 0; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + border-left: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border: 0; + } +} + +.o_Composer_toolButton { + border: 0; + + &:hover { + // enforce this style (webclient btn-secondary) + border: 0; + } +} + +.o_Composer_toolButtons { + background-color: white; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border-bottom: 0; + border-radius: initial; + } + + &:last-child:not(.o-composer-has-current-partner-avatar) { + border-right: 1px solid lighten(gray('400'), 5%); + } +} + +.o_Composer_toolButtonSeparator { + border-left: 1px solid lighten(gray('400'), 5%); +} diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml new file mode 100644 index 00000000000..055c3837ccf --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.xml @@ -0,0 +1,154 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Composer" owl="1"> + <div class="o_Composer" + t-att-class="{ + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-has-footer': hasFooter, + 'o-has-header': hasHeader, + 'o-is-compact': props.isCompact, + }" + > + <t t-if="composer"> + <t t-if="isDropZoneVisible.value"> + <DropZone + class="o_Composer_dropZone" + t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped" + t-ref="dropzone" + /> + </t> + <FileUploader + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + newAttachmentExtraData="newAttachmentExtraData" + t-ref="fileUploader" + /> + <t t-if="hasHeader"> + <div class="o_Composer_coreHeader"> + <t t-if="props.hasThreadName and composer.thread"> + <span class="o_Composer_threadName"> + on: <b><t t-esc="composer.thread.displayName"/></b> + </span> + </t> + <t t-if="props.hasFollowers and !props.isLog"> + <!-- Text for followers --> + <small class="o_Composer_followers"> + <b class="text-muted">To: </b> + <em class="text-muted">Followers of </em> + <b> + <t t-if="composer.thread"> +  "<t t-esc="composer.thread.name"/>" + </t> + <t t-else=""> + this document + </t> + </b> + </small> + </t> + </div> + </t> + <t t-if="props.hasCurrentPartnerAvatar"> + <div class="o_Composer_sidebarMain"> + <img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt=""/> + </div> + </t> + <div class="o_Composer_coreMain" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <TextInput + class="o_Composer_textInput" + t-att-class="{ + 'o-composer-is-compact': props.isCompact, + 'o_Composer_textInput-mobile': env.messaging.device.isMobile, + }" + composerLocalId="composer.localId" + hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition" + hasSendOnEnterEnabled="props.hasTextInputSendOnEnterEnabled" + t-on-o-keydown-enter="_onTextInputKeydownEnter" + t-on-paste="_onPasteTextInput" + t-ref="textInput" + /> + <div class="o_Composer_buttons" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-mobile': env.messaging.device.isMobile }"> + <div class="o_Composer_toolButtons" + t-att-class="{ + 'o-composer-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-composer-is-compact': props.isCompact, + }"> + <t t-if="props.isCompact"> + <div class="o_Composer_toolButtonSeparator"/> + </t> + <div class="o_Composer_primaryToolButtons"> + <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection" t-ref="emojisPopover"> + <!-- TODO FIXME o-open not possible to code due to https://github.com/odoo/owl/issues/693 --> + <button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-secondary" + t-att-class="{ + 'o-open': false and state.displayed, + 'o-mobile': env.messaging.device.isMobile, + }" + > + <i class="fa fa-smile-o"/> + </button> + <t t-set="opened"> + <EmojisPopover/> + </t> + </Popover> + <button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-secondary fa fa-paperclip" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/> + </div> + <t t-if="props.isExpandable"> + <div class="o_Composer_secondaryToolButtons"> + <button class="btn btn-secondary fa fa-expand o_Composer_button o_Composer_buttonFullComposer o_Composer_toolButton" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Full composer" type="button" t-on-click="_onClickFullComposer"/> + </div> + </t> + </div> + <t t-if="props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </div> + <t t-if="hasFooter"> + <div class="o_Composer_coreFooter" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasThreadTyping"> + <ThreadTextualTypingStatus class="o_Composer_threadTextualTypingStatus" threadLocalId="composer.thread.localId"/> + </t> + <t t-if="composer.attachments.length > 0"> + <AttachmentList + class="o_Composer_attachmentList" + areAttachmentsEditable="true" + attachmentsDetailsMode="props.attachmentsDetailsMode" + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + showAttachmentsExtensions="props.showAttachmentsExtensions" + showAttachmentsFilenames="props.showAttachmentsFilenames" + /> + </t> + <t t-if="!props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </t> + </t> + </div> + </t> + + <t t-name="mail.Composer.actionButtons" owl="1"> + <div class="o_Composer_actionButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasSendButton"> + <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonSend btn btn-primary" + t-att-class="{ + 'fa': env.messaging.device.isMobile, + 'fa-paper-plane-o': env.messaging.device.isMobile, + 'o-composer-is-compact': props.isCompact, + }" + t-att-disabled="(!composer.textInputContent and composer.attachments.length === 0) ? 'disabled' : ''" + type="button" + t-on-click="_onClickSend" + > + <t t-if="!env.messaging.device.isMobile"><t t-if="props.isLog">Log</t><t t-else="">Send</t></t> + </button> + </t> + <t t-if="!env.messaging.device.isMobile and props.hasDiscardButton"> + <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonDiscard btn btn-secondary" t-att-class="{ 'o-composer-is-compact': props.isCompact }" type="button" t-on-click="_onClickDiscard"> + Discard + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js new file mode 100644 index 00000000000..e506e99a48e --- /dev/null +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -0,0 +1,866 @@ +odoo.define('mail/static/src/components/composer/composer_tests.js', function (require) { +'use strict'; + +const components = { + Composer: require('mail/static/src/components/composer/composer.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + dragenterFiles, + dropFiles, + inputFiles, + nextAnimationFrame, + pasteFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer', {}, function () { +QUnit.module('composer_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createComposerComponent = async (composer, otherProps) => { + const ComposerComponent = components.Composer; + ComposerComponent.env = this.env; + this.component = new ComposerComponent(null, Object.assign({ + composerLocalId: composer.localId, + }, otherProps)); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + async afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.Composer.env; + }, +}); + +QUnit.test('composer text input: basic rendering', async function (assert) { + assert.expect(5); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Write something...", + "should have placeholder in note editable of composer text input" + ); +}); + +QUnit.test('add an emoji', async function (assert) { + assert.expect(1); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "emoji should be inserted in the composer text input" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add an emoji after some text', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla", + "composer text input should have text only initially" + ); + + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla😊", + "emoji should be inserted after the text" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + await afterNextRender(() => { + composerTextInputTextArea.focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + composerTextInputTextArea.value, + "Blabla", + "composer text input should have text only initially" + ); + + // simulate selection of all the content by keyboard + composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length); + + // select emoji + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "whole text selection should have been replaced by emoji" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('display partner mention suggestions on typing "@"', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerTextInput_mentionDropdownPropositionList'), + 'show', + "should display mention suggestions on typing '@'" + ); +}); + +QUnit.test('mention a partner', async function (assert) { + assert.expect(4); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_PartnerMentionSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a partner after some text', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => + document.execCommand('insertText', false, "@") + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_PartnerMentionSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh @TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a partner mention', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_PartnerMentionSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner 😊", + "text content of composer should have previous mention and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('composer: add an attachment', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer, { attachmentsDetailsMode: 'card' }); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.ok( + document.querySelector('.o_Composer_attachmentList'), + "should have an attachment list" + ); + assert.ok( + document.querySelector(`.o_Composer .o_Attachment`), + "should have an attachment" + ); +}); + +QUnit.test('composer: drop attachments', async function (assert) { + assert.expect(4); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }), + await createFile({ + content: 'hello, worlduh', + contentType: 'text/plain', + name: 'text2.txt', + }), + ]; + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + assert.ok( + document.querySelector('.o_Composer_dropZone'), + "should have a drop zone" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should have no attachment before files are dropped" + ); + + await afterNextRender(() => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + files + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "should have 2 attachments in the composer after files dropped" + ); + + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + await afterNextRender(async () => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text3.txt', + }) + ] + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 3, + "should have 3 attachments in the box after files dropped" + ); +}); + +QUnit.test('composer: paste attachments', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }) + ]; + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should not have any attachment in the composer before paste" + ); + + await afterNextRender(() => + pasteFiles(document.querySelector('.o_ComposerTextInput'), files) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 1, + "should have 1 attachment in the composer after paste" + ); +}); + +QUnit.test('composer text input cleared on message post', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer); + // Type message + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + // Send message + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); +}); + +QUnit.test('composer with thread typing notification status', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }], + }, + }); + await this.start(); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + assert.containsOnce( + document.body, + '.o_Composer_threadTextualTypingStatus', + "Composer should have a thread textual typing status bar" + ); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "By default, thread textual typing status bar should be empty" + ); +}); + +QUnit.test('current partner notify is typing to other thread members', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); +}); + +QUnit.test('current partner is typing should not translate on textual typing status', async function (assert) { + assert.expect(3); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); + + await nextAnimationFrame(); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "Thread textual typing status bar should not display current partner is typing" + ); +}); + +QUnit.test('current partner notify no longer is typing to thread members after 5 seconds inactivity', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + await this.env.testUtils.advanceTime(5 * 1000); + assert.verifySteps( + ['notify_typing:false'], + "should have notified current partner no longer is typing (inactive for 5 seconds)" + ); +}); + +QUnit.test('current partner notify is typing again to other members every 50s of long continuous typing', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + // simulate current partner typing a character every 2.5 seconds for 50 seconds straight. + let totalTimeElapsed = 0; + const elapseTickTime = 2.5 * 1000; + while (totalTimeElapsed < 50 * 1000) { + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + totalTimeElapsed += elapseTickTime; + await this.env.testUtils.advanceTime(elapseTickTime); + } + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is still typing after 50s of straight typing" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js new file mode 100644 index 00000000000..198f563d1c7 --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js @@ -0,0 +1,334 @@ +odoo.define('mail/static/src/components/composer_text_input/composer_text_input.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'), +}; + +const { Component } = owl; +const { useRef } = owl.hooks; + +/** + * ComposerInput relies on a minimal HTML editor in order to support mentions. + */ +class ComposerTextInput extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + return { + composer: composer ? composer.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + }; + }); + /** + * Last content of textarea from input event. Useful to determine + * whether the current partner is typing something. + */ + this._textareaLastInputValue = ""; + /** + * Reference of the textarea. Useful to set height, selection and content. + */ + this._textareaRef = useRef('textarea'); + } + + /** + * Updates the composer text input content when composer is mounted + * as textarea content can't be changed from the DOM. + */ + mounted() { + this._update(); + } + + /** + * Updates the composer text input content when composer has changed + * as textarea content can't be changed from the DOM. + */ + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + focusout() { + this.saveStateInStore(); + this._textareaRef.el.blur(); + } + + /** + * Saves the composer text input state in store + */ + saveStateInStore() { + this.composer.update({ + textInputContent: this._getContent(), + textInputCursorStart: this._getSelectionStart(), + textInputCursorEnd: this._getSelectionEnd(), + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns textarea current content. + * + * @private + * @returns {string} + */ + _getContent() { + return this._textareaRef.el.value; + } + + /** + * Returns selection end position. + * + * @private + * @returns {integer} + */ + _getSelectionEnd() { + return this._textareaRef.el.selectionEnd; + } + + /** + * Returns selection start position. + * + * @private + * @returns {integer} + * + */ + _getSelectionStart() { + return this._textareaRef.el.selectionStart; + } + + /** + * Determines whether the textarea is empty or not. + * + * @private + * @returns {boolean} + */ + _isEmpty() { + return this._getContent() === ""; + } + + /** + * Updates the content and height of a textarea + * + * @private + */ + _update() { + this._textareaRef.el.value = this.composer.textInputContent; + this._textareaRef.el.setSelectionRange( + this.composer.textInputCursorStart, + this.composer.textInputCursorEnd + ); + this._updateHeight(); + if (this.composer.isDoFocus) { + this._textareaRef.el.focus(); + this.composer.update({ isDoFocus: false }); + } + } + + /** + * Updates the textarea height. + * + * @private + */ + _updateHeight() { + this._textareaRef.el.style.height = "0px"; + this._textareaRef.el.style.height = (this._textareaRef.el.scrollHeight) + "px"; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInputTextarea() { + if (this._textareaLastInputValue !== this._textareaRef.el.value) { + this.composer.handleCurrentPartnerIsTyping(); + } + this._textareaLastInputValue = this._textareaRef.el.value; + this._updateHeight(); + this.saveStateInStore(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextarea(ev) { + switch (ev.key) { + // UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions + case 'ArrowUp': + case 'PageUp': + case 'ArrowDown': + case 'PageDown': + case 'Home': + case 'End': + case 'Tab': + if (this.composer.hasSuggestedPartners) { + // We use preventDefault here to avoid keys native actions but actions are handled in keyUp + ev.preventDefault(); + } + break; + // ENTER: submit the message only if the dropdown mention proposition is not displayed + case 'Enter': + this._onKeydownTextareaEnter(ev); + break; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextareaEnter(ev) { + if (this.composer.hasSuggestedPartners) { + ev.preventDefault(); + } else { + if (!this.props.hasSendOnEnterEnabled) { + return; + } + if (ev.shiftKey) { + return; + } + if (this.env.messaging.device.isMobile) { + return; + } + this.trigger('o-keydown-enter'); + ev.preventDefault(); + } + } + + /** + * Key events management is performed in a Keyup to avoid intempestive RPC calls + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyupTextarea(ev) { + switch (ev.key) { + // ESCAPE: close mention suggestions + case 'Escape': + this._onKeyupTextareaEscape(ev); + break; + // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions + case 'Enter': + if (this.composer.hasSuggestedPartners) { + if (this.composer.activeSuggestedPartner) { + this.composer.insertMentionedPartner(this.composer.activeSuggestedPartner); + this.composer.closeMentionSuggestions(); + this.composer.focus(); + } + } + break; + case 'ArrowUp': + case 'PageUp': + if (this.composer.hasSuggestedPartners) { + this.composer.setPreviousSuggestedPartnerActive(); + } + break; + case 'ArrowDown': + case 'PageDown': + if (this.composer.hasSuggestedPartners) { + this.composer.setNextSuggestedPartnerActive(); + } + break; + case 'Home': + if (this.composer.hasSuggestedPartners) { + this.composer.setFirstSuggestedPartnerActive(); + } + break; + case 'End': + if (this.composer.hasSuggestedPartners) { + this.composer.setLastSuggestedPartnerActive(); + } + break; + case 'Tab': + if (this.composer.hasSuggestedPartners) { + if (ev.shiftKey) { + this.composer.setPreviousSuggestedPartnerActive(); + } else { + this.composer.setNextSuggestedPartnerActive(); + } + } + break; + // Otherwise, check if a mention is typed + default: + this.saveStateInStore(); + this.composer._detectDelimiter(); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeyupTextareaEscape(ev) { + if (this.composer.hasSuggestedPartners) { + this.composer.closeMentionSuggestions(); + } else { + if (!this._isEmpty()) { + return; + } + this.composer.discard(); + } + } + + /** + * @private + * @param {Event} ev + */ + _onPartnerMentionSuggestionClicked(ev) { + this.composer.insertMentionedPartner(ev.detail.partner); + this.composer.closeMentionSuggestions(); + this.composer.focus(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onPartnerMentionSuggestionMouseOver(ev) { + this.composer.update({ + activeSuggestedPartner: [['link', ev.detail.partner]], + }); + } + +} + +Object.assign(ComposerTextInput, { + components, + defaultProps: { + hasMentionSuggestionsBelowPosition: false, + hasSendOnEnterEnabled: true, + }, + props: { + hasMentionSuggestionsBelowPosition: Boolean, + hasSendOnEnterEnabled: Boolean, + composerLocalId: String, + }, + template: 'mail.ComposerTextInput', +}); + +return ComposerTextInput; + +}); diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.scss b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss new file mode 100644 index 00000000000..fe3ad16788d --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ComposerTextInput { + min-width: 0; + position: relative; +} + +.o_ComposerTextInput_mentionDropdown { + position: absolute; + + &.o-lowPosition { + bottom: 0; + } +} + +.o_ComposerTextInput_textarea { + padding: 10px; + max-height: 200px; + resize: none; + border-radius: 10px 0 0 10px; + border: none; + overflow: auto +} diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml new file mode 100644 index 00000000000..f68b295cb8d --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerTextInput" owl="1"> + <div class="o_ComposerTextInput"> + <t t-if="composer"> + <t t-if="composer.hasSuggestedPartners"> + <div class="o_ComposerTextInput_mentionDropdown" t-att-class="{ 'o-lowPosition': props.hasMentionSuggestionsBelowPosition }"> + <div + t-att-class="{ + 'dropdown': props.hasMentionSuggestionsBelowPosition, + 'dropup': !props.hasMentionSuggestionsBelowPosition, + }" + > + <div class="o_ComposerTextInput_mentionDropdownPropositionList dropdown-menu show"> + <t t-foreach="composer.mainSuggestedPartners" t-as="mainSuggestedPartner" t-key="mainSuggestedPartner.localId"> + <PartnerMentionSuggestion + isActive="mainSuggestedPartner === composer.activeSuggestedPartner" + partnerLocalId="mainSuggestedPartner.localId" + t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked" + t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver" + /> + </t> + <t t-if="composer.mainSuggestedPartners.length > 0 and composer.extraSuggestedPartners.length > 0"> + <div role="separator" class="dropdown-divider"/> + </t> + <t t-foreach="composer.extraSuggestedPartners" t-as="extraSuggestedPartner" t-key="extraSuggestedPartner.localId"> + <PartnerMentionSuggestion + isActive="extraSuggestedPartner === composer.activeSuggestedPartner" + partnerLocalId="extraSuggestedPartner.localId" + t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked" + t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver" + /> + </t> + </div> + </div> + </div> + </t> + <textarea class="o_ComposerTextInput_textarea" t-esc="composer.textInputContent" placeholder="Write something..." t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/dialog/dialog.js b/addons/mail/static/src/components/dialog/dialog.js new file mode 100644 index 00000000000..dc7ddf88e0a --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.js @@ -0,0 +1,105 @@ +odoo.define('mail/static/src/components/dialog/dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Dialog extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + /** + * Reference to the component used inside this dialog. + */ + this._componentRef = useRef('component'); + this._onClickGlobal = this._onClickGlobal.bind(this); + this._onKeydownDocument = this._onKeydownDocument.bind(this); + useStore(props => { + const dialog = this.env.models['mail.dialog'].get(props.dialogLocalId); + return { + dialog: dialog ? dialog.__state : undefined, + }; + }); + } + + mounted() { + document.addEventListener('click', this._onClickGlobal, true); + document.addEventListener('keydown', this._onKeydownDocument); + } + + willUnmount() { + document.removeEventListener('click', this._onClickGlobal, true); + document.removeEventListener('keydown', this._onKeydownDocument); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.dialog} + */ + get dialog() { + return this.env.models['mail.dialog'].get(this.props.dialogLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on this dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + ev.stopPropagation(); + } + + /** + * Closes the dialog when clicking outside. + * Does not work with attachment viewer because it takes the whole space. + * + * @private + * @param {MouseEvent} ev + */ + _onClickGlobal(ev) { + if (this._componentRef.el.contains(ev.target)) { + return; + } + // TODO: this should be child logic (will crash if child doesn't have isCloseable!!) + // task-2092965 + if (this._componentRef.comp.isCloseable && !this._componentRef.comp.isCloseable()) { + return; + } + this.dialog.close(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownDocument(ev) { + if (ev.key === 'Escape') { + this.dialog.close(); + } + } + +} + +Object.assign(Dialog, { + props: { + dialogLocalId: String, + }, + template: 'mail.Dialog', +}); + +return Dialog; + +}); diff --git a/addons/mail/static/src/components/dialog/dialog.scss b/addons/mail/static/src/components/dialog/dialog.scss new file mode 100644 index 00000000000..00d6e24ff48 --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.scss @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Dialog { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Dialog { + background-color: rgba(0, 0, 0, 0.7); +} diff --git a/addons/mail/static/src/components/dialog/dialog.xml b/addons/mail/static/src/components/dialog/dialog.xml new file mode 100644 index 00000000000..3c953decbbc --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Dialog" owl="1"> + <div class="o_Dialog"> + <t t-if="dialog"> + <t t-if="dialog.record"> + <t + t-component="{{ dialog.record['constructor'].name }}" + class="o_Dialog_component" + t-props="{ localId: dialog.record.localId }" + t-ref="component" + /> + </t> + <t t-else=""> + <span>Only dialog linked to a record is currently supported!</span> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.js b/addons/mail/static/src/components/dialog_manager/dialog_manager.js new file mode 100644 index 00000000000..810b7247fe2 --- /dev/null +++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.js @@ -0,0 +1,58 @@ +odoo.define('mail/static/src/components/dialog_manager/dialog_manager.js', function (require) { +'use strict'; + +const components = { + Dialog: require('mail/static/src/components/dialog/dialog.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class DialogManager extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + return { + dialogManager: this.env.messaging.dialogManager.__state, + }; + }); + } + + mounted() { + this._checkDialogOpen(); + } + + patched() { + this._checkDialogOpen(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _checkDialogOpen() { + if (this.env.messaging.dialogManager.dialogs.length > 0) { + document.body.classList.add('modal-open'); + } else { + document.body.classList.remove('modal-open'); + } + } + +} + +Object.assign(DialogManager, { + components, + props: {}, + template: 'mail.DialogManager', +}); + +return DialogManager; + +}); diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.xml b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml new file mode 100644 index 00000000000..994d7d9c9d7 --- /dev/null +++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DialogManager" owl="1"> + <div class="o_DialogManager"> + <t t-foreach="env.messaging.dialogManager.allOrdered" t-as="dialog" t-key="dialog.localId"> + <Dialog + class="o_DialogManager_dialog" + dialogLocalId="dialog.localId" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss/discuss.js b/addons/mail/static/src/components/discuss/discuss.js new file mode 100644 index 00000000000..271235bccb0 --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.js @@ -0,0 +1,277 @@ +odoo.define('mail/static/src/components/discuss/discuss.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + Composer: require('mail/static/src/components/composer/composer.js'), + DiscussMobileMailboxSelection: require('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js'), + DiscussSidebar: require('mail/static/src/components/discuss_sidebar/discuss_sidebar.js'), + MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'), + ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'), + ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'), + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Discuss extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const discuss = this.env.messaging.discuss; + return { + checkedMessages: discuss.threadViewer.checkedMessages.map(message => message.__state), + discuss: discuss.__state, + isDeviceMobile: this.env.messaging.device.isMobile, + isMessagingInitialized: this.env.messaging.isInitialized, + thread: discuss.thread ? discuss.thread.__state : undefined, + threadCache: discuss.threadViewer.threadCache + ? discuss.threadViewer.threadCache.__state + : undefined, + uncheckedMessages: discuss.threadViewer.uncheckedMessages.map(message => message.__state), + }; + }, { + compareDepth: { + checkedMessages: 1, + uncheckedMessages: 1, + }, + }); + this._updateLocalStoreProps(); + /** + * Reference of the thread. Useful to update scroll position correctly + * on patch. AKU TODO: this made sense when composer was outside of + * thread, but this may no longer be necessary?? + */ + this._threadRef = useRef('thread'); + + // bind since passed as props + this._onMobileAddItemHeaderInputSelect = this._onMobileAddItemHeaderInputSelect.bind(this); + this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this); + } + + mounted() { + this.discuss.update({ isOpen: true }); + if (this.discuss.thread) { + this.trigger('o-push-state-action-manager'); + } else if (this.env.messaging.isInitialized) { + this.discuss.openInitThread(); + } + this._updateLocalStoreProps(); + } + + patched() { + this.trigger('o-update-control-panel'); + if (this.discuss.thread) { + this.trigger('o-push-state-action-manager'); + } + if ( + this.discuss.thread && + this.discuss.thread === this.env.messaging.inbox && + this._lastThreadCache === this.discuss.threadViewer.threadCache.localId && + this._lastThreadCounter > 0 && this.discuss.thread.counter === 0 + ) { + this.trigger('o-show-rainbow-man'); + } + this._activeThreadCache = this.discuss.threadViewer.threadCache; + this._updateLocalStoreProps(); + } + + willUnmount() { + if (this.discuss) { + this.discuss.close(); + } + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get addChannelInputPlaceholder() { + return this.env._t("Create or search channel..."); + } + + /** + * @returns {string} + */ + get addChatInputPlaceholder() { + return this.env._t("Search user..."); + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {Object[]} + */ + mobileNavbarTabs() { + return [{ + icon: 'fa fa-inbox', + id: 'mailbox', + label: this.env._t("Mailboxes"), + }, { + icon: 'fa fa-user', + id: 'chat', + label: this.env._t("Chat"), + }, { + icon: 'fa fa-users', + id: 'channel', + label: this.env._t("Channel"), + }]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _updateLocalStoreProps() { + /** + * Locally tracked store props `activeThreadCache`. + * Useful to set scroll position from last stored one and to display + * rainbox man on inbox. + */ + this._lastThreadCache = ( + this.discuss.threadViewer.threadCache && + this.discuss.threadViewer.threadCache.localId + ); + /** + * Locally tracked store props `threadCounter`. + * Useful to display the rainbow man on inbox. + */ + this._lastThreadCounter = ( + this.discuss.thread && + this.discuss.thread.counter + ); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onDialogClosedModerationDiscard() { + this.discuss.update({ hasModerationDiscardDialog: false }); + } + + /** + * @private + */ + _onDialogClosedModerationReject() { + this.discuss.update({ hasModerationRejectDialog: false }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideMobileAddItemHeader(ev) { + ev.stopPropagation(); + this.discuss.clearIsAddingItem(); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onMobileAddItemHeaderInputSelect(ev, ui) { + const discuss = this.discuss; + if (discuss.isAddingChannel) { + discuss.handleAddChannelAutocompleteSelect(ev, ui); + } else { + discuss.handleAddChatAutocompleteSelect(ev, ui); + } + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onMobileAddItemHeaderInputSource(req, res) { + if (this.discuss.isAddingChannel) { + this.discuss.handleAddChannelAutocompleteSource(req, res); + } else { + this.discuss.handleAddChatAutocompleteSource(req, res); + } + } + + /** + * @private + */ + _onReplyingToMessageMessagePosted() { + this.env.services['notification'].notify({ + message: _.str.sprintf( + this.env._t(`Message posted on "%s"`), + owl.utils.escape(this.discuss.replyingToMessage.originThread.displayName) + ), + type: 'warning', + }); + this.discuss.clearReplyingToMessage(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.tabId + */ + _onSelectMobileNavbarTab(ev) { + ev.stopPropagation(); + if (this.discuss.activeMobileNavbarTabId === ev.detail.tabId) { + return; + } + this.discuss.clearReplyingToMessage(); + this.discuss.update({ activeMobileNavbarTabId: ev.detail.tabId }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.thread} ev.detail.thread + */ + _onSelectThread(ev) { + this.discuss.threadViewer.update({ thread: [['link', ev.detail.thread]] }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onThreadRendered(ev) { + this.trigger('o-update-control-panel'); + } + +} + +Object.assign(Discuss, { + components, + props: {}, + template: 'mail.Discuss', +}); + +return Discuss; + +}); diff --git a/addons/mail/static/src/components/discuss/discuss.scss b/addons/mail/static/src/components/discuss/discuss.scss new file mode 100644 index 00000000000..5cddf1017ba --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.scss @@ -0,0 +1,114 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_action_manager { + // bug with scrollable inside discuss mobile without this... + min-height: 0; +} + +.o-autogrow { + flex: 1 1 auto; +} + +.o_Discuss { + display: flex; + height: 100%; + min-height: 0; + + &.o-mobile { + flex-flow: column; + align-items: center; + } +} + +.o_Discuss_chatWindowHeader { + width: 100%; + flex: 0 0 auto; +} + +.o_Discuss_content { + height: 100%; + overflow: auto; + flex: 1 1 auto; + display: flex; + flex-flow: column; +} + +.o_Discuss_messagingNotInitialized { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.o_Discuss_messagingNotInitializedIcon { + margin-right: 3px; +} + +.o_Discuss_mobileAddItemHeader { + display: flex; + justify-content: center; + width: 100%; + padding: 0 10px; +} + +.o_Discuss_mobileAddItemHeaderInput { + flex: 1 1 auto; + margin-bottom: 8px; + padding: 8px; +} + +.o_Discuss_mobileMailboxSelection { + width: 100%; +} + +.o_Discuss_mobileNavbar { + width: 100%; +} + +.o_Discuss_noThread { + display: flex; + flex: 1 1 auto; + width: 100%; + align-items: center; + justify-content: center; +} + +.o_Discuss_replyingToMessageComposer { + width: 100%; +} + +.o_Discuss_sidebar { + height: 100%; + overflow: auto; + padding-top: 10px; + flex: 0 0 auto; +} + +.o_Discuss_thread { + flex: 1 1 auto; + + &.o-mobile { + width: 100%; + } +} + +.o_Discuss_notificationList { + width: 100%; + flex: 1 1 auto; +} +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Discuss.o-mobile { + background-color: white; +} + +.o_Discuss_mobileAddItemHeaderInput { + appearance: none; + border: 1px solid gray('400'); + border-radius: 5px; + outline: none; +} diff --git a/addons/mail/static/src/components/discuss/discuss.xml b/addons/mail/static/src/components/discuss/discuss.xml new file mode 100644 index 00000000000..789c7a465c8 --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.xml @@ -0,0 +1,106 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Discuss" owl="1"> + <div class="o_Discuss" + t-att-class="{ + 'o-adding-item': discuss.isAddingChannel or discuss.isAddingChat, + 'o-mobile': env.messaging.device.isMobile, + }" + > + <t t-if="!env.messaging.isInitialized"> + <div class="o_Discuss_messagingNotInitialized"><i class="o_Discuss_messagingNotInitializedIcon fa fa-spinner fa-spin"/>Please wait...</div> + </t> + <t t-else=""> + <t t-if="!env.messaging.device.isMobile"> + <DiscussSidebar class="o_Discuss_sidebar"/> + </t> + <t t-if="env.messaging.device.isMobile" t-call="mail.Discuss.content"/> + <t t-else=""> + <div class="o_Discuss_content"> + <t t-call="mail.Discuss.content"/> + </div> + </t> + </t> + + <t t-if="discuss.hasModerationDiscardDialog"> + <ModerationDiscardDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationDiscard"/> + </t> + <t t-if="discuss.hasModerationRejectDialog"> + <ModerationRejectDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationReject"/> + </t> + </div> + </t> + + <t t-name="mail.Discuss.content" owl="1"> + <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox'"> + <DiscussMobileMailboxSelection class="o_Discuss_mobileMailboxSelection"/> + </t> + <t t-if="env.messaging.device.isMobile and (discuss.isAddingChannel or discuss.isAddingChat)"> + <div class="o_Discuss_mobileAddItemHeader"> + <AutocompleteInput + class="o_Discuss_mobileAddItemHeaderInput" + isFocusOnMount="true" + isHtml="discuss.isAddingChannel" + placeholder="discuss.isAddingChannel ? addChannelInputPlaceholder : addChatInputPlaceholder" + select="_onMobileAddItemHeaderInputSelect" + source="_onMobileAddItemHeaderInputSource" + t-on-o-hide="_onHideMobileAddItemHeader" + /> + </div> + </t> + <t t-if="(env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox' and discuss.thread and discuss.thread.model === 'mail.box') or (!env.messaging.device.isMobile and discuss.thread)"> + <ThreadViewer + class="o_Discuss_thread" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + composerAttachmentsDetailsMode="'card'" + hasComposer="discuss.thread.model !== 'mail.box'" + hasComposerCurrentPartnerAvatar="!env.messaging.device.isMobile" + hasComposerThreadTyping="true" + hasMessageCheckbox="true" + hasSquashCloseMessages="discuss.thread.model !== 'mail.box'" + haveMessagesAuthorRedirect="discuss.thread.channel_type !== 'chat'" + haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox" + haveMessagesReplyIcon="discuss.thread === env.messaging.inbox" + selectedMessageLocalId=" + discuss.replyingToMessage and + discuss.replyingToMessage.localId" + threadViewerLocalId="discuss.threadViewer.localId" + t-on-o-rendered="_onThreadRendered" + /> + </t> + <t t-if="!discuss.thread and (!env.messaging.device.isMobile or discuss.activeMobileNavbarTabId === 'mailbox')"> + <div class="o_Discuss_noThread"> + No conversation selected. + </div> + </t> + <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId !== 'mailbox'"> + <NotificationList + class="o_Discuss_notificationList" + filter="discuss.activeMobileNavbarTabId" + t-on-o-select-thread="_onSelectThread" + /> + </t> + <t t-if="env.messaging.device.isMobile and !discuss.isReplyingToMessage"> + <MobileMessagingNavbar + class="o_Discuss_mobileNavbar" + activeTabId="discuss.activeMobileNavbarTabId" + tabs="mobileNavbarTabs()" + t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab" + /> + </t> + <t t-if="discuss.isReplyingToMessage"> + <Composer + class="o_Discuss_replyingToMessageComposer" + composerLocalId="discuss.replyingToMessage.originThread.composer.localId" + hasCurrentPartnerAvatar="!env.messaging.device.isMobile" + hasDiscardButton="true" + hasThreadName="true" + isDiscardOnClickAway="true" + isFocusOnMount="true" + t-on-o-message-posted="_onReplyingToMessageMessagePosted" + /> + </t> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss/discuss_tests.js b/addons/mail/static/src/components/discuss/discuss_tests.js new file mode 100644 index 00000000000..7a37349041f --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss_tests.js @@ -0,0 +1,5523 @@ +odoo.define('mail/static/src/components/discuss/discuss_tests.js', function (require) { +'use strict'; + +const BusService = require('bus.BusService'); + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + inputFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); +const { makeTestPromise, file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.widget) { + this.widget.destroy(); + } + }, +}); + +QUnit.test('messaging not initialized', async function (assert) { + assert.expect(1); + + await this.start({ + async mockRPC(route) { + if (route === '/mail/init_messaging') { + // simulate messaging never initialized + return new Promise(resolve => {}); + } + return this._super(...arguments); + }, + waitUntilMessagingInitialized: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 1, + "should display messaging not initialized" + ); +}); + +QUnit.test('messaging becomes initialized', async function (assert) { + assert.expect(2); + + const messagingInitializedProm = makeTestPromise(); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingInitializedProm; + } + return _super(); + }, + waitUntilMessagingInitialized: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 1, + "should display messaging not initialized" + ); + + await afterNextRender(() => messagingInitializedProm.resolve()); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 0, + "should no longer display messaging not initialized" + ); +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(4); + + await this.start(); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_sidebar').length, + 1, + "should have a sidebar section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_content').length, + 1, + "should have content section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_thread').length, + 1, + "should have thread section inside content" + ); + assert.ok( + document.querySelector('.o_Discuss_thread').classList.contains('o_ThreadViewer'), + "thread section should use ThreadViewer component" + ); +}); + +QUnit.test('basic rendering: sidebar', async function (assert) { + assert.expect(20); + + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_group`).length, + 3, + "should have 3 groups in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupMailbox`).length, + 1, + "should have group 'Mailbox' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_groupHeader + `).length, + 0, + "mailbox category should not have any header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_item + `).length, + 3, + "should have 3 mailbox items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).length, + 1, + "should have inbox mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).length, + 1, + "should have starred mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).length, + 1, + "should have history mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_sidebar .o_DiscussSidebar_separator`).length, + 1, + "should have separator (between mailboxes and channels, but that's not tested)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel`).length, + 1, + "should have group 'Channel' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeader + `).length, + 1, + "channel category should have a header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle + `).length, + 1, + "should have title in channel header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle + `).textContent.trim(), + "Channels" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_list`).length, + 1, + "channel category should list items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 0, + "channel category should have no item by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat`).length, + 1, + "should have group 'Chat' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeader`).length, + 1, + "channel category should have a header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle`).length, + 1, + "should have title in chat header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle + `).textContent.trim(), + "Direct Messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_list`).length, + 1, + "chat category should list items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 0, + "chat category should have no item by default" + ); +}); + +QUnit.test('sidebar: basic mailbox rendering', async function (assert) { + assert.expect(6); + + await this.start(); + const inbox = document.querySelector(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "mailbox should have active indicator" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "mailbox should have an icon" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_ThreadIcon_mailboxInbox`).length, + 1, + "inbox should have 'inbox' icon" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "mailbox should have a name" + ); + assert.strictEqual( + inbox.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Inbox", + "inbox should have name 'Inbox'" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "should have no counter when equal to 0 (default value)" + ); +}); + +QUnit.test('sidebar: default active inbox', async function (assert) { + assert.expect(1); + + await this.start(); + const inbox = document.querySelector(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `); + assert.ok( + inbox.querySelector(` + :scope .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox should be active by default" + ); +}); + +QUnit.test('sidebar: change item', async function (assert) { + assert.expect(4); + + await this.start(); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox should be active by default" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred should be inactive by default" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox mailbox should become inactive" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active"); +}); + +QUnit.test('sidebar: inbox with counter', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + needaction_inbox_counter: 100, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 1, + "should display a counter (= have a counter when different from 0)" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "100", + "should have counter value" + ); +}); + +QUnit.test('sidebar: add channel', async function (assert) { + assert.expect(3); + + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_groupHeaderItemAdd + `).length, + 1, + "should be able to add channel from header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_groupHeaderItemAdd + `).title, + "Add or join a channel"); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_itemNew`).length, + 1, + "should have item to add a new channel" + ); +}); + +QUnit.test('sidebar: basic channel rendering', async function (assert) { + assert.expect(14); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 1, + "should have one channel item"); + let channel = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item + `); + assert.strictEqual( + channel.dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId, + "should have channel with Id 20" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "should have active indicator" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length, + 0, + "should not be active by default" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have an icon" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "should have a name" + ); + assert.strictEqual( + channel.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "General", + "should have name value" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length, + 1, + "should have commands" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 2, + "should have 2 commands" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length, + 1, + "should have 'settings' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length, + 1, + "should have 'leave' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 0, + "should have a counter when equals 0 (default value)" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).click() + ); + channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length, + 1, + "channel should become active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_composer`).length, + 1, + "should have composer section inside thread content (can post message in channel)" + ); +}); + +QUnit.test('sidebar: channel rendering with needaction counter', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + message_needaction_counter: 10, + }], + }, + }); + await this.start(); + const channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 1, + "should have a counter when different from 0" + ); + assert.strictEqual( + channel.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent, + "10", + "should have counter value" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 1, + "should have single command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length, + 1, + "should have 'settings' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length, + 0, + "should not have 'leave' command" + ); +}); + +QUnit.test('sidebar: public/private channel rendering', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 100, + name: "channel1", + public: 'public', + }], + channel_private_group: [{ + channel_type: "channel", + id: 101, + name: "channel2", + public: 'private', + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 2, + "should have 2 channel items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have channel1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 101 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have channel2 (Id 101)" + ); + const channel1 = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + ).localId + }"] + `); + const channel2 = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 101 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.strictEqual( + channel1.querySelectorAll(`:scope .o_ThreadIcon_channelPublic`).length, + 1, + "channel1 (public) has hashtag icon" + ); + assert.strictEqual( + channel2.querySelectorAll(`:scope .o_ThreadIcon_channelPrivate`).length, + 1, + "channel2 (private) has lock icon" + ); +}); + +QUnit.test('sidebar: basic chat rendering', async function (assert) { + assert.expect(11); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 1, + "should have one chat item" + ); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId, + "should have chat with Id 20" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "should have active indicator" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have an icon" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "should have a name" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Demo", + "should have correspondent name as name" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length, + 1, + "should have commands" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 2, + "should have 2 commands" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length, + 1, + "should have 'rename' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length, + 1, + "should have 'unpin' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 0, + "should have a counter when equals 0 (default value)" + ); +}); + +QUnit.test('sidebar: chat rendering with unread counter', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + message_unread_counter: 100, + }], + }, + }); + await this.start(); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 1, + "should have a counter when different from 0" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent, + "100", + "should have counter value" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 1, + "should have single command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length, + 1, + "should have 'rename' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length, + 0, + "should not have 'unpin' command" + ); +}); + +QUnit.test('sidebar: chat im_status rendering', async function (assert) { + assert.expect(7); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 101, + im_status: 'offline', + name: "Partner1", + }], + id: 11, + }, { + channel_type: "chat", + direct_partner: [{ + id: 102, + im_status: 'online', + name: "Partner2", + }], + id: 12, + }, { + channel_type: "chat", + direct_partner: [{ + id: 103, + im_status: 'away', + name: "Partner3", + }], + id: 13, + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 3, + "should have 3 chat items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 11 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have Partner1 (Id 11)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have Partner2 (Id 12)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 13 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have Partner3 (Id 13)" + ); + const chat1 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 11 && + thread.model === 'mail.channel' + ).localId + }"] + `); + const chat2 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId + }"] + `); + const chat3 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 13 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.strictEqual( + chat1.querySelectorAll(`:scope .o_ThreadIcon_offline`).length, + 1, + "chat1 should have offline icon" + ); + assert.strictEqual( + chat2.querySelectorAll(`:scope .o_ThreadIcon_online`).length, + 1, + "chat2 should have online icon" + ); + assert.strictEqual( + chat3.querySelectorAll(`:scope .o_ThreadIcon_away`).length, + 1, + "chat3 should have away icon" + ); +}); + +QUnit.test('sidebar: chat custom name', async function (assert) { + assert.expect(1); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + custom_channel_name: "Marc", + direct_partner: [{ + id: 7, + name: "Marc Demo", + }], + id: 10, + }], + }, + }); + await this.start(); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Marc", + "chat should have custom name as name" + ); +}); + +QUnit.test('sidebar: rename chat', async function (assert) { + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + custom_channel_name: "Marc", + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Marc Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_set_custom_name') { + return; + } + return this._super(...arguments); + }, + }); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Marc", + "chat should have custom name as name" + ); + assert.notOk( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat name should not be editable" + ); + + await afterNextRender(() => + chat.querySelector(`:scope .o_DiscussSidebarItem_commandRename`).click() + ); + assert.ok( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat should have editable name" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_nameInput`).length, + 1, + "chat should have editable name input" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value, + "Marc", + "editable name input should have custom chat name as value by default" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).placeholder, + "Marc Demo", + "editable name input should have partner name as placeholder" + ); + + await afterNextRender(() => { + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value = "Demo"; + const kevt = new window.KeyboardEvent('keydown', { key: "Enter" }); + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).dispatchEvent(kevt); + }); + assert.notOk( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat should no longer show editable name" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Demo", + "chat should have renamed name as name" + ); +}); + +QUnit.test('default thread rendering', async function (assert) { + assert.expect(16); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).length, + 1, + "should have inbox mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).length, + 1, + "should have starred mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).length, + 1, + "should have history mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have 'general' channel in the sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in inbox" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).textContent.trim(), + "Congratulations, your inbox is empty New messages appear here." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).classList.contains('o-active'), + "starred mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).textContent.trim(), + "No starred messages You can mark any message as 'starred', and it shows up in this mailbox." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "history mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_MessageList_empty`).textContent.trim(), + "No history messages Messages marked as read will appear in the history." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-active'), + "channel 'general' should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).textContent.trim(), + "There are no messages in this conversation." + ); +}); + +QUnit.test('initially load messages from inbox', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.step('message_fetch'); + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + assert.strictEqual( + args.args.length, + 1, + "should have a single item in args" + ); + assert.deepEqual( + args.args[0], + [["needaction", "=", true]], + "should fetch needaction messages" + ); + } + return this._super(...arguments); + }, + }); + assert.verifySteps(['message_fetch']); +}); + +QUnit.test('default select thread in discuss params', async function (assert) { + assert.expect(1); + + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_starred', + }, + } + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active" + ); +}); + +QUnit.test('auto-select thread in discuss context', async function (assert) { + assert.expect(1); + + await this.start({ + discuss: { + context: { + active_id: 'mail.box_starred', + }, + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active" + ); +}); + +QUnit.test('load single message from channel initially', async function (assert) { + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + assert.strictEqual( + args.args.length, + 1, + "should have a single item in args" + ); + assert.deepEqual( + args.args[0], + [["channel_ids", "in", [20]]], + "should fetch messages from channel" + ); + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_messageList`).length, + 1, + "should have list of messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_separatorDate`).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_MessageList_separatorLabelDate`).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 1, + "should have a single message" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should have message with Id 100" + ); +}); + +QUnit.test('basic rendering of message', async function (assert) { + // AKU TODO: should be in message-only tests + assert.expect(13); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + const message = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_sidebar`).length, + 1, + "should have message sidebar of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "should have author avatar in sidebar of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorAvatar`).dataset.src, + "/web/image/res.partner/11/image_128", + "should have url of message in author avatar sidebar" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_core`).length, + 1, + "should have core part of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header`).length, + 1, + "should have header in core part of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_authorName`).length, + 1, + "should have author name in header of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorName`).textContent, + "Demo", + "should have textually author name in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_date`).length, + 1, + "should have date in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_commands`).length, + 1, + "should have commands in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_command`).length, + 1, + "should have a single command in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "should have command to star message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "should have content in core part of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_content`).textContent.trim(), + "body", + "should have body of message in content part of message" + ); +}); + +QUnit.test('basic rendering of squashed message', async function (assert) { + // messages are squashed when "close", e.g. less than 1 minute has elapsed + // from messages of same author and same thread. Note that this should + // be working in non-mailboxes + // AKU TODO: should be message and/or message list-only tests + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [11, "Demo"], + body: "<p>body1</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }, { + author_id: [11, "Demo"], + body: "<p>body2</p>", + channel_ids: [20], + date: "2019-04-20 10:00:30", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 2, + "should have 2 messages" + ); + const message1 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + const message2 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `); + assert.notOk( + message1.classList.contains('o-squashed'), + "message 1 should not be squashed" + ); + assert.notOk( + message1.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'), + "message 1 should not have squashed sidebar" + ); + assert.ok( + message2.classList.contains('o-squashed'), + "message 2 should be squashed" + ); + assert.ok( + message2.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'), + "message 2 should have squashed sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_date`).length, + 1, + "message 2 should have date in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commands`).length, + 1, + "message 2 should have some commands in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commandStar`).length, + 1, + "message 2 should have star command in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_core`).length, + 1, + "message 2 should have core part" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_header`).length, + 0, + "message 2 should have a header in core part" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "message 2 should have some content in core part" + ); + assert.strictEqual( + message2.querySelector(`:scope .o_Message_content`).textContent.trim(), + "body2", + "message 2 should have body in content part" + ); +}); + +QUnit.test('inbox messages are never squashed', async function (assert) { + assert.expect(3); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + // fetching messages from inbox + return [{ + author_id: [11, "Demo"], + body: "<p>body1</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: 'General', + res_id: 20, + }, { + author_id: [11, "Demo"], + body: "<p>body2</p>", + channel_ids: [20], + date: "2019-04-20 10:00:30", + id: 101, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 2, + "should have 2 messages" + ); + const message1 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + const message2 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `); + assert.notOk( + message1.classList.contains('o-squashed'), + "message 1 should not be squashed" + ); + assert.notOk( + message2.classList.contains('o-squashed'), + "message 2 should not be squashed" + ); +}); + +QUnit.test('load all messages from channel initially, less than fetch limit (29 < 30)', async function (assert) { + // AKU TODO: thread specific test + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.strictEqual(args.kwargs.limit, 30, "should fetch up to 30 messages"); + let messagesData = []; + // 29 messages + for (let i = 28; i >= 0; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate + `).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate + `).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 29, + "should have 29 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 0, + "should not have load more link" + ); +}); + +QUnit.test('load more messages from channel', async function (assert) { + // AKU: thread specific test + assert.expect(8); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel (initial load) + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + let messagesData = []; + // 30 messages + for (let i = 39; i >= 10; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + if (step === 2) { + // fetching more messages from channel (load more) + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + let messagesData = []; + // 10 messages + for (let i = 9; i >= 0; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate + `).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate + `).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 30, + "should have 30 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 1, + "should have load more link" + ); + + await afterNextRender(() => + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).click() + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 40, + "should have 40 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 0, + "should not longer have load more link (all messages loaded)" + ); +}); + +QUnit.test('auto-scroll to bottom of thread', async function (assert) { + // AKU TODO: thread specific test + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages" + ); + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`); + assert.strictEqual( + messageList.scrollTop + messageList.clientHeight, + messageList.scrollHeight, + "should have scrolled to bottom of thread" + ); +}); + +QUnit.test('load more messages from channel (auto-load on scroll)', async function (assert) { + // AKU TODO: thread specific test + assert.expect(3); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel (initial load) + let messagesData = []; + // 30 messages + for (let i = 39; i >= 10; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + if (step === 2) { + // fetching more messages from channel (load more) + let messagesData = []; + // 10 messages + for (let i = 9; i >= 0; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 30, + "should have 30 messages" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 40, + "should have 40 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Dsiscuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 0, + "should not longer have load more link (all messages loaded)" + ); +}); + +QUnit.test('new messages separator', async function (assert) { + // this test requires several messages so that the last message is not + // visible. This is necessary in order to display 'new messages' and not + // remove from DOM right away from seeing last message. + // AKU TODO: thread specific test + assert.expect(5); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + seen_message_id: 125, + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel (initial load) + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + if (step === 2) { + throw new Error("should not fetch more messages"); + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages + `).length, + 0, + "should not display 'new messages' separator" + ); + + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + // simulate receiving a new message + const data = { + author_id: [36, "User26"], + body: "<p>boddy26</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }; + await afterNextRender(() => { + const notifications = [[['my-db', 'mail.channel', 20], data]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 26, + "should have 26 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages + `).length, + 1, + "should display 'new messages' separator" + ); + + // scroll to bottom + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollHeight; + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages + `).length, + 0, + "should no longer display 'new messages' separator (message seen)" + ); +}); + +QUnit.test('restore thread scroll position', async function (assert) { + assert.expect(4); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "channel1", + }, { + channel_type: "channel", + id: 2, + name: "channel2", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_1', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel1 (initial load) + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [1], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'channel1', + res_id: 1, + }); + } + return messagesData; + } + if (step === 2) { + // fetching messages from channel2 (initial load) + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [2], + date: "2019-04-20 10:00:00", + id: 200 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'channel2', + res_id: 2, + }); + } + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages" + ); + + // scroll to top of channel1 + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop, + 0, + "should have scrolled to top of thread" + ); + + // select channel2 + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + // select channel1 + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop, + 0, + "should have recovered scroll position of channel1 (scroll to top)" + ); + + // select channel2 + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + const messageList = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + `); + assert.strictEqual( + messageList.scrollTop + messageList.clientHeight, + messageList.scrollHeight, + "should have recovered scroll position of channel2 (scroll to bottom)" + ); +}); + +QUnit.test('message origin redirect to channel', async function (assert) { + assert.expect(15); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 1, + name: "channel1", + }, { + channel_type: 'channel', + id: 2, + name: "channel2", + }], + }, + }); + let messagesData = [{ + author_id: [10, "User1"], + body: `<p>message1</p>`, + channel_ids: [1, 2], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "channel1", + res_id: 1, + }, { + author_id: [11, "User2"], + body: `<p>message2</p>`, + channel_ids: [1, 2], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: "channel2", + res_id: 2, + }]; + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_1', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel1 (initial load) + return messagesData; + } + if (step === 2) { + // fetching messages from channel2 (initial load) + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_thread .o_Message').length, + 2, + "should have 2 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should have message1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `).length, + 1, + "should have message2 (Id 101)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThread + `).length, + 0, + "message1 should not have origin part in channel1 (same origin as channel)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThread + `).length, + 1, + "message2 should have origin part (origin is channel2 !== channel1)" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThread + `).textContent.trim(), + "(from #channel2)", + "message2 should display name of origin channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThreadLink + `).length, + 1, + "message2 should have link to redirect to origin" + ); + + // click on origin link of message2 (= channel2) + await afterNextRender(() => + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThreadLink + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel2 should be active channel on redirect from discuss app" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should have message1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `).length, + 1, + "should have message2 (Id 101)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThread + `).length, + 1, + "message1 should have origin thread part (= channel1 !== channel2)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThread + `).length, + 0, + "message2 should not have origin thread part in channel2 (same as current channel)" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThread + `).textContent.trim(), + "(from #channel1)", + "message1 should display name of origin channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThreadLink + `).length, + 1, + "message1 should have link to redirect to origin channel" + ); +}); + +QUnit.test('redirect to author (open chat)', async function (assert) { + assert.expect(9); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_1', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from General (initial load) + return [{ + author_id: [7, "Demo"], + body: `<p>message1</p>`, + channel_ids: [1], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 1, + }, { + author_id: [3, "Me"], + body: `<p>message2</p>`, + channel_ids: [1], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 1, + }]; + } + if (step === 2) { + // fetching messages from DM (initial load) + return []; + } + } + if (args.model === 'res.users' && args.method === 'search') { + return [2]; + } + return this._super(...arguments); + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel 'General' should be active" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "Chat 'Demo' should not be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages" + ); + const msg1 = document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + const msg2 = document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `); + assert.strictEqual( + msg1.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "message1 should have author image" + ); + assert.ok( + msg1.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'), + "message1 should have redirect to author" + ); + assert.strictEqual( + msg2.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "message2 should have author image" + ); + assert.notOk( + msg2.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'), + "message2 should not have redirect to author (self-author)" + ); + + await afterNextRender(() => + msg1.querySelector(`:scope .o_Message_authorAvatar`).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel 'General' should become inactive after author redirection" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "chat 'Demo' should become active after author redirection" + ); +}); + +QUnit.test('sidebar quick search', async function (assert) { + // feature enables at 20 or more channels + assert.expect(6); + + let channelsData = []; + for (let id = 1; id <= 20; id++) { + channelsData.push({ + channel_type: 'channel', + id, + name: `channel${id}`, + }); + } + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: channelsData, + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 20, + "should have 20 channel items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_sidebar input.o_DiscussSidebar_quickSearch`).length, + 1, + "should have quick search in sidebar" + ); + + const quickSearch = document.querySelector(` + .o_Discuss_sidebar input.o_DiscussSidebar_quickSearch + `); + await afterNextRender(() => { + quickSearch.value = "1"; + const kevt1 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt1); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 11, + "should have filtered to 11 channel items" + ); + + await afterNextRender(() => { + quickSearch.value = "12"; + const kevt2 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt2); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 1, + "should have filtered to a single channel item" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item + `).dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId, + "should have filtered to a single channel item with Id 12" + ); + + await afterNextRender(() => { + quickSearch.value = "123"; + const kevt3 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt3); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 0, + "should have filtered to no channel item" + ); +}); + +QUnit.test('basic control panel rendering', async function (assert) { + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "Inbox", + "display inbox in the breadcrumb" + ); + const markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.isVisible( + markAllReadButton, + "should have visible button 'Mark all read' in the control panel of inbox" + ); + assert.ok( + markAllReadButton.disabled, + "should have disabled button 'Mark all read' in the control panel of inbox (no messages)" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "Starred", + "display starred in the breadcrumb" + ); + const unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.isVisible( + unstarAllButton, + "should have visible button 'Unstar all' in the control panel of starred" + ); + assert.ok( + unstarAllButton.disabled, + "should have disabled button 'Unstar all' in the control panel of starred (no messages)" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "#General", + "display general in the breadcrumb" + ); + const inviteButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonInvite`); + assert.isVisible( + inviteButton, + "should have visible button 'Invite' in the control panel of channel" + ); +}); + +QUnit.test('inbox: mark all messages as read', async function (assert) { + assert.expect(8); + + const self = this; + Object.assign(this.data.initMessaging, { + needaction_inbox_counter: 2, + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_needaction_counter: 2, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [7, "Demo"], + body: `<p>message1</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: "General", + res_id: 20, + }, { + author_id: [8, "Other"], + body: `<p>message2</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: "General", + res_id: 20, + }]; + } + if (args.method === 'mark_all_as_read') { + // simulate mark as read notification + const data = { + message_ids: [100, 101], + type: 'mark_as_read', + }; + const notifications = [[['my-db', 'res.partner'], data]]; + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "inbox should have counter of 2" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "channel should have counter of 2" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 2, + "should have 2 messages in inbox" + ); + let markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.notOk( + markAllReadButton.disabled, + "should have enabled button 'Mark all read' in the control panel of inbox (has messages)" + ); + + await afterNextRender(() => markAllReadButton.click()); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "inbox should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "channel should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 0, + "should have no message in inbox" + ); + markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.ok( + markAllReadButton.disabled, + "should have disabled button 'Mark all read' in the control panel of inbox (no messages)" + ); +}); + +QUnit.test('starred: unstar all', async function (assert) { + assert.expect(6); + + const self = this; + Object.assign(this.data.initMessaging, { starred_counter: 2 }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_starred', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [7, "Demo"], + body: `<p>message1</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 20, + starred: true, + starred_partner_ids: [3], + }, { + author_id: [8, "Other"], + body: `<p>message2</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 20, + starred: true, + starred_partner_ids: [3], + }]; + } + if (args.method === 'unstar_all') { + // simulate toggle_star notification + const data = { + message_ids: [100, 101], + starred: false, + type: 'toggle_star', + }; + const notifications = [[['my-db', 'res.partner'], data]]; + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "starred should have counter of 2" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 2, + "should have 2 messages in starred" + ); + let unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.notOk( + unstarAllButton.disabled, + "should have enabled button 'Unstar all' in the control panel of starred (has messages)" + ); + + await afterNextRender(() => unstarAllButton.click()); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 0, + "should have no message in starred" + ); + unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.ok( + unstarAllButton.disabled, + "should have disabled button 'Unstar all' in the control panel of starred (no messages)" + ); +}); + +QUnit.test('toggle_star message', async function (assert) { + assert.expect(16); + + const self = this; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + let messageData = { + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + starred: false, + starred_partner_ids: [], + }; + + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [messageData]; + } + if (args.method === 'toggle_message_starred') { + assert.step('rpc:toggle_message_starred'); + assert.strictEqual( + args.args[0][0], + 100, + "should have message Id in args" + ); + // simulate toggle_star notification + messageData.starred = !messageData.starred; + const data = { + message_ids: [100], + starred: messageData.starred, + type: 'toggle_star', + }; + const notifications = [[['my-db', 'res.partner'], data]]; + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should have 1 message in channel" + ); + let message = document.querySelector(`.o_Discuss .o_Message`); + assert.notOk( + message.classList.contains('o-starred'), + "message should not be starred" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "message should have star command" + ); + + await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + assert.verifySteps(['rpc:toggle_message_starred']); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "1", + "starred should display a counter of 1" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should have kept 1 message in channel" + ); + message = document.querySelector(`.o_Discuss .o_Message`); + assert.ok( + message.classList.contains('o-starred'), + "message should be starred" + ); + + await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + assert.verifySteps(['rpc:toggle_message_starred']); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should no longer display a counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should still have 1 message in channel" + ); + message = document.querySelector(`.o_Discuss .o_Message`); + assert.notOk( + message.classList.contains('o-starred'), + "message should no longer be starred" + ); +}); + +QUnit.test('composer state: text save and restore', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }, { + channel_type: 'channel', + id: 21, + name: "Special", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + return this._super(...arguments); + }, + }); + // Write text in composer for #general + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "A message"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('input')); + }); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "An other message"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('input')); + }); + // Switch back to #general + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="General"]`).click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "A message", + "should restore the input text" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "An other message", + "should restore the input text" + ); +}); + +QUnit.test('composer state: attachments save and restore', async function (assert) { + assert.expect(6); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }, { + channel_type: 'channel', + id: 21, + name: "Special", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + return this._super(...arguments); + }, + }); + const channels = document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item + `); + // Add attachment in a message for #general + await afterNextRender(async () => { + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ); + }); + // Switch to #special + await afterNextRender(() => channels[1].click()); + // Add attachments in a message for #special + const files = [ + await createFile({ + content: 'hello2, world', + contentType: 'text/plain', + name: 'text2.txt', + }), + await createFile({ + content: 'hello3, world', + contentType: 'text/plain', + name: 'text3.txt', + }), + await createFile({ + content: 'hello4, world', + contentType: 'text/plain', + name: 'text4.txt', + }), + ]; + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + files + ) + ); + // Switch back to #general + await afterNextRender(() => channels[0].click()); + // Check attachment is reloaded + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 1, + "should have 1 attachment in the composer" + ); + assert.strictEqual( + document.querySelector(`.o_Composer .o_Attachment`).dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 1).localId, + "should have correct 1st attachment in the composer" + ); + + // Switch back to #special + await afterNextRender(() => channels[1].click()); + // Check attachments are reloaded + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 3, + "should have 3 attachments in the composer" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[0].dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 2).localId, + "should have attachment with id 2 as 1st attachment" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[1].dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 3).localId, + "should have attachment with id 3 as 2nd attachment" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[2].dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 4).localId, + "should have attachment with id 4 as 3rd attachment" + ); +}); + +QUnit.test('post a simple message', async function (assert) { + assert.expect(15); + + const self = this; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + let messagesData = []; + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return messagesData; + } + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.args[0], + 20, + "should post message to channel Id 20" + ); + assert.strictEqual( + args.kwargs.body, + "Test", + "should post with provided content in composer input" + ); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + assert.strictEqual( + args.kwargs.subtype_xmlid, + "mail.mt_comment", + "should set subtype_xmlid as 'comment'" + ); + // simulate receiving a new message + const data = { + author_id: [3, "Admin"], + body: args.kwargs.body, + channel_ids: [20], + date: "2019-04-20 11:00:00", + id: 101, + message_type: args.kwargs.message_type, + model: 'mail.channel', + subtype_xmlid: args.kwargs.subtype_xmlid, + record_name: 'General', + res_id: 20, + }; + const notifications = [ + [['my-db', 'mail.channel', 20], data] + ]; + messagesData.push(data); + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_MessageList_empty`).length, + 1, + "should display thread with no message initially" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 0, + "should display no message initially" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have empty content initially" + ); + + // insert some HTML in editable + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Test", + "should have inserted text in editable" + ); + + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 1, + "should display a message after posting message" + ); + const message = document.querySelector(`.o_Message`); + assert.strictEqual( + message.dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 101).localId, + "new message in thread should be linked to newly created message from message post" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorName`).textContent, + "Admin", + "new message in thread should be from Admin" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_content`).textContent, + "Test", + "new message in thread should have content typed from composer text input" + ); +}); + +QUnit.test('rendering of inbox message', async function (assert) { + // AKU TODO: kinda message specific test + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'project.task', + needaction_partner_ids: [44], + record_name: 'Refactoring', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a message" + ); + const message = document.querySelector('.o_Message'); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_originThread`).length, + 1, + "should display origin thread of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_originThread`).textContent, + " on Refactoring", + "should display origin thread name" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_command`).length, + 3, + "should display 3 commands" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "should display star command" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandReply`).length, + 1, + "should display reply command" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandMarkAsRead`).length, + 1, + "should display mark as read command" + ); +}); + +QUnit.test('mark channel as seen on last message visible', async function (assert) { + assert.expect(3); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 10, + message_unread_counter: 1, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + return []; + } + if (step === 2) { + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 10, + }]; + } + throw new Error("should not call 'message_fetch' more than twice"); + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have discuss sidebar item with the channel" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-unread'), + "sidebar item of channel ID 10 should be unread" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-unread'), + "sidebar item of channel ID 10 should not longer be unread" + ); +}); + +QUnit.test('receive new needaction messages', async function (assert) { + assert.expect(12); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + return this._super(...arguments); + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `), + "should have inbox in sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox should be current discuss thread" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `), + "inbox item in sidebar should not have any counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 0, + "should have no messages in inbox initially" + ); + + // simulate receiving a new needaction message + await afterNextRender(() => { + const data = { + author_id: [7, "Demo"], + body: "<p>Test</p>", + date: "2019-04-20 11:00:00", + id: 100, + message_type: 'comment', + needaction_partner_ids: [3], + model: 'project.task', + record_name: 'Refactoring', + res_id: 20, + }; + const notifications = [[['my-db', 'ir.needaction', 3], data]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `), + "inbox item in sidebar should now have counter" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + '1', + "inbox item in sidebar should have counter of '1'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 1, + "should have one message in inbox" + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_Message`).dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should display newly received needaction message" + ); + + // simulate receiving another new needaction message + await afterNextRender(() => { + const data2 = { + author_id: [7, "Demo"], + body: "<p>Test2</p>", + date: "2019-04-20 11:00:00", + id: 101, + message_type: 'comment', + needaction_partner_ids: [3], + model: 'project.task', + record_name: 'Refactoring', + res_id: 20, + }; + const notifications2 = [[['my-db', 'ir.needaction', 3], data2]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications2); + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + '2', + "inbox item in sidebar should have counter of '2'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages in inbox" + ); + assert.ok( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `), + "should still display 1st needaction message" + ); + assert.ok( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `), + "should display 2nd needaction message" + ); +}); + +QUnit.test('reply to message from inbox (message linked to document)', async function (assert) { + assert.expect(20); + + Object.assign(this.data.initMessaging, { needaction_inbox_counter: 1 }); + + let messagesData = []; + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [7, "Demo"], + body: "<p>Test</p>", + date: "2019-04-20 11:00:00", + id: 100, + message_type: 'comment', + needaction_partner_ids: [3], + model: 'project.task', + record_name: 'Refactoring', + res_id: 20, + }]; + } + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.model, + 'project.task', + "should post message to record with model 'project.task'" + ); + assert.strictEqual( + args.args[0], + 20, + "should post message to record with Id 20" + ); + assert.strictEqual( + args.kwargs.body, + "Test", + "should post with provided content in composer input" + ); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + assert.strictEqual( + args.kwargs.subtype_xmlid, + "mail.mt_comment", + "should set subtype_xmlid as 'comment'" + ); + messagesData.push({ + author_id: [3, "Admin"], + body: args.kwargs.body, + date: "2019-04-20 11:00:00", + id: 101, + message_type: args.kwargs.message_type, + model: args.model, + subtype_xmlid: args.kwargs.subtype_xmlid, + record_name: 'Refactoring', + res_id: 20, + }); + return; + } + if (args.method === 'message_format') { + return messagesData; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a single message" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should display message with ID 100" + ); + assert.strictEqual( + document.querySelector('.o_Message_originThread').textContent, + " on Refactoring", + "should display message originates from record 'Refactoring'" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.ok( + document.querySelector('.o_Message').classList.contains('o-selected'), + "message should be selected after clicking on reply icon" + ); + assert.ok( + document.querySelector('.o_Composer'), + "should have composer after clicking on reply to message" + ); + assert.strictEqual( + document.querySelector(`.o_Composer_threadName`).textContent, + " on: Refactoring", + "composer should display origin thread name of message" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer text input should be auto-focus" + ); + + await afterNextRender(() => { + document.execCommand('insertText', false, "Test"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })); + }); + assert.verifySteps(['message_post']); + assert.notOk( + document.querySelector('.o_Composer'), + "should no longer have composer after posting reply to message" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should still display a single message after posting reply" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should still display message with ID 100 after posting reply" + ); + assert.notOk( + document.querySelector('.o_Message').classList.contains('o-selected'), + "message should not longer be selected after posting reply" + ); + assert.ok( + document.querySelector('.o_notification'), + "should display a notification after posting reply" + ); + assert.strictEqual( + document.querySelector('.o_notification_content').textContent, + "Message posted on \"Refactoring\"", + "notification should tell that message has been posted to the record 'Refactoring'" + ); +}); + +QUnit.skip('load recent messages from thread (already loaded some old messages)', async function (assert) { + // TODO FIXME should be restored as part of task-2171881 (robust "load more" with "holes") + // Load old message that has been needaction, when opening this thread for + // the first time, it should load messages that are more recent. + assert.expect(17); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }], + }, + needaction_inbox_counter: 1, + }); + let step = 0; + let mailMessages = {}; + for (let i = 0; i < 50; i++) { + mailMessages[100 + i] = { + author_id: [7, "Demo"], + body: `<p>Test${i}</p>`, + date: `2019-04-20 11:00:${i < 10 ? '0' + i : i}`, + id: 100 + i, + message_type: 'comment', + needaction_partner_ids: i === 0 ? [3] : [], + model: 'mail.channel', + record_name: "General", + res_id: 20, + }; + } + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + assert.step('message_fetch:load_inbox'); + assert.deepEqual( + args.args[0], + [['needaction', '=', true]], + "should fetch needaction messages from inbox" + ); + const needactionMessages = Object + .values(mailMessages) + .filter(mailMessage => + mailMessage.needaction_partner_ids.length > 0 + ); + return needactionMessages; + } + if (step === 2) { + assert.step('message_fetch:load_channel_20'); + assert.deepEqual( + args.args[0], + [['channel_ids', 'in', [20]]], + "should fetch messages from channel ID 20" + ); + assert.strictEqual( + args.kwargs.limit, + 30, + "should limit fetch to 30 messages (load)" + ); + const moreRecentMessages = Object + .values(mailMessages) + .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1) + .splice(0, 30); + return moreRecentMessages; + } + if (step === 3) { + assert.step('message_fetch:load_more_channel_20'); + assert.deepEqual( + args.args[0], + // loaded 30 messages, so 150 - 30 = 120 = ID of last message fetched + // => load more messages starting from this ID + [['id', '<', 120], ['channel_ids', 'in', [20]]], + "should fetch more messages from 30 more recent messages" + ); + assert.strictEqual( + args.kwargs.limit, + 30, + "should limit fetch to 30 messages (load more)" + ); + const loadMoreMessages = Object + .values(mailMessages) + .filter(mailMessage => mailMessage.id < 120) + .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1) + .splice(0, 30); + return loadMoreMessages; + } + throw new Error("should not fetch more than twice"); + } + return this._super(...arguments); + }, + }); + assert.verifySteps( + ['message_fetch:load_inbox'], + "should initially have fetched messages from inbox" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should fetch a single message from inbox" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should have fetched 1st message of channel 'General' as needaction from inbox" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.verifySteps( + ['message_fetch:load_channel_20'], + "should initially have fetched messages from channel 'General' (channel 20)" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 31, + "should display 30 messages from channel 'General' (only fetched ones)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should not display 1st message of 'General' as needaction from inbox" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.verifySteps( + ['message_fetch:load_more_channel_20'], + "should have fetched more messages from channel 'General' (channel 20)" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 50, + "should display 50 messages from channel 'General' (all fetched)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should include 1st message of 'General' as needaction from inbox in the fetched more messages" + ); +}); + +QUnit.test('messages marked as read move to "History" mailbox', async function (assert) { + assert.expect(10); + + const self = this; + this.data['mail.message'].records = [{ + author_id: [5, 'Demo User'], + body: '<p>test 1</p>', + id: 1, + needaction: true, + needaction_partner_ids: [3], + }, { + author_id: [6, 'Test User'], + body: '<p>test 2</p>', + id: 2, + needaction: true, + needaction_partner_ids: [3], + }]; + this.data['mail.notification'].records = [{ + id: 50, + is_read: false, + mail_message_id: 1, + res_partner_id: 3, + }, { + id: 51, + is_read: false, + mail_message_id: 1, + res_partner_id: 3, + }]; + this.data.initMessaging.needaction_inbox_counter = 2; + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_history', + }, + }, + async mockRPC(route, args) { + if (args.method === 'mark_all_as_read') { + for (const message of this.data['mail.message'].records) { + message.history_partner_ids = [3]; + message.needaction_partner_ids = []; + } + const notificationData = { + type: 'mark_as_read', + message_ids: [1, 2], + }; + const notification = [[false, 'res.partner', 3], notificationData]; + self.widget.call('bus_service', 'trigger', 'notification', [notification]); + return 3; + } + return this._super(...arguments); + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "History mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 1, + "should have empty thread in history" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "Inbox mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 0, + "Inbox mailbox should not be empty" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 2, + "Inbox mailbox should have 2 messages" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead').click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "Inbox mailbox should still be active after mark as read" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 1, + "Inbox mailbox should now be empty after mark as read" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "History mailbox should be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 0, + "History mailbox should not be empty after mark as read" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 2, + "History mailbox should have 2 messages" + ); +}); + +QUnit.test('all messages in "Inbox" in "History" after marked all as read', async function (assert) { + assert.expect(10); + + const self = this; + const messagesData = []; + const messageOffset = 200; + const partnerOffset = 100; + for (let i = messageOffset; i < messageOffset + 40; i++) { + messagesData.push({ + author_id: [partnerOffset + i, 'User ' + (partnerOffset + i)], + body: '<p>test ' + i + '</p>', + id: i, + needaction: true, + needaction_partner_ids: [3], + }); + } + + this.data['mail.message'].records = messagesData; + + let messageFetchCount = 0; + const initDef = makeTestPromise(); + const markAllReadDef = makeTestPromise(); + const clickHistoryDef = makeTestPromise(); + const loadMoreDef = makeTestPromise(); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'mark_all_as_read') { + const messageIDs = []; + for (let i = 0; i < messagesData.length; i++) { + this.data['mail.message'].records[i].history_partner_ids = [3]; + this.data['mail.message'].records[i].needaction_partner_ids = []; + this.data['mail.message'].records[i].needaction = false; + messageIDs.push(messageOffset + i); + } + const notificationData = { + type: 'mark_as_read', + message_ids: messageIDs, + }; + const notification = [[false, 'res.partner', 3], notificationData]; + self.widget.call('bus_service', 'trigger', 'notification', [notification]); + markAllReadDef.resolve(); + return 3; + } + if (args.method === 'message_fetch') { + // 1st message_fetch: 'Inbox' initially + // 2nd message_fetch: 'History' initially + // 3rd message_fetch: 'History' load more + assert.step(args.method); + + messageFetchCount++; + if (messageFetchCount === 1) { + initDef.resolve(); + } + if (messageFetchCount === 2) { + clickHistoryDef.resolve(); + } + if (messageFetchCount === 3) { + loadMoreDef.resolve(); + } + } + return this._super(...arguments); + }, + }); + + await initDef; + assert.verifySteps( + ['message_fetch'], + "should fetch messages once for needaction messages (Inbox)" + ); + assert.containsN( + document.body, + '.o_Message', + 30, + "there should be 30 messages that are loaded in Inbox" + ); + + await afterNextRender(async () => { + const markAllReadButton = document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead'); + markAllReadButton.click(); + await markAllReadDef; + }); + assert.containsNone( + document.body, + '.o_Message', + "there should no message in inbox anymore" + ); + + await afterNextRender(async () => { + document.querySelector(` + .o_DiscussSidebarItem[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click(); + await clickHistoryDef; + }); + assert.verifySteps( + ['message_fetch'], + "should fetch messages once for history" + ); + assert.containsN( + document.body, + '.o_Message', + 30, + "there should be 30 messages in History" + ); + + // simulate a scroll to top to load more messages + await afterNextRender(async () => { + document.querySelector('.o_MessageList').scrollTop = 0; + await loadMoreDef; + }); + assert.verifySteps( + ['message_fetch'], + "should fetch more messages in history for loadMore" + ); + assert.containsN( + document.body, + '.o_Message', + 40, + "there should be 40 messages in History" + ); +}); + +QUnit.test('receive new channel message: out of odoo focus (notification, channel)', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + }], + }, + }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + assert.strictEqual(payload.title, "1 Message"); + }); + + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message with odoo focused + await afterNextRender(() => { + const messageData = { + author_id: [7, "Demo User"], + body: "<p>Test</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }; + const notifications = [[['my-db', 'mail.channel', 20], messageData]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('receive new channel message: out of odoo focus (notification, chat)', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo User", + }], + id: 10, + message_unread_counter: 0, + }], + }, + }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + assert.strictEqual(payload.title, "1 Message"); + }); + + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message with odoo focused + await afterNextRender(() => { + const messageData = { + author_id: [7, "Demo User"], + body: "<p>Test</p>", + channel_ids: [10], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 10, + }; + const notifications = [[['my-db', 'mail.channel', 10], messageData]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('receive new channel messages: out of odoo focus (tab title)', async function (assert) { + assert.expect(12); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo User", + }], + id: 10, + message_unread_counter: 0, + }], + }, + }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + step++; + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + if (step === 1) { + assert.strictEqual(payload.title, "1 Message"); + } + if (step === 2) { + assert.strictEqual(payload.title, "2 Messages"); + } + if (step === 3) { + assert.strictEqual(payload.title, "3 Messages"); + } + }); + + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message in general with odoo focused + await afterNextRender(() => { + const messageData1 = { + author_id: [7, "Demo User"], + body: "<p>Test1</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }; + const notifications1 = [[['my-db', 'mail.channel', 20], messageData1]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications1); + }); + assert.verifySteps(['set_title_part']); + + // simulate receiving a new message in chat with odoo focused + await afterNextRender(() => { + const messageData2 = { + author_id: [7, "Demo User"], + body: "<p>Test2</p>", + channel_ids: [10], + date: "2019-04-20 10:00:00", + id: 127, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 10, + }; + const notifications2 = [[['my-db', 'mail.channel', 10], messageData2]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications2); + }); + assert.verifySteps(['set_title_part']); + + // simulate receiving another new message in chat with odoo focused + await afterNextRender(() => { + const messageData3 = { + author_id: [7, "Demo User"], + body: "<p>Test3</p>", + channel_ids: [10], + date: "2019-04-20 10:00:00", + id: 128, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 10, + }; + const notifications3 = [[['my-db', 'mail.channel', 20], messageData3]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications3); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('auto-focus composer on opening thread', async function (assert) { + assert.expect(14); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo User", + }], + id: 10, + message_unread_counter: 0, + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="Inbox"] + `).length, + 1, + "should have mailbox 'Inbox' in the sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Inbox"] + `).classList.contains('o-active'), + "mailbox 'Inbox' should be active initially" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).length, + 1, + "should have channel 'General' in the sidebar" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).classList.contains('o-active'), + "channel 'General' should not be active initially" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).length, + 1, + "should have chat 'Demo User' in the sidebar" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).classList.contains('o-active'), + "chat 'Demo User' should not be active initially" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 0, + "there should be no composer when active thread of discuss is mailbox 'Inbox'" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_item[data-thread-name="General"]`).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).classList.contains('o-active'), + "channel 'General' should become active after selecting it from the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 1, + "there should be a composer when active thread of discuss is channel 'General'" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of channel 'General' should be automatically focused on opening" + ); + + document.querySelector(`.o_ComposerTextInput_textarea`).blur(); + assert.notOk( + document.activeElement === document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of channel 'General' should no longer focused on click away" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_item[data-thread-name="Demo User"]`).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).classList.contains('o-active'), + "chat 'Demo User' should become active after selecting it from the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 1, + "there should be a composer when active thread of discuss is chat 'Demo User'" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of chat 'Demo User' should be automatically focused on opening" + ); +}); + +QUnit.test('moderation: moderated channel with pending moderation message', async function (assert) { + assert.expect(37); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start(); + + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `), + "should display the moderation box" + ); + const mailboxCounter = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + .o_DiscussSidebarItem_counter + `); + assert.ok( + mailboxCounter, + "there should be a counter next to the moderation mailbox in the sidebar" + ); + assert.strictEqual( + mailboxCounter.textContent.trim(), + "1", + "the mailbox counter of the moderation mailbox should display '1'" + ); + + // 1. go to moderation mailbox + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `).click() + ); + // check message + assert.containsOnce( + document.body, + '.o_Message', + "should be only one message in moderation box" + ); + assert.strictEqual( + document.querySelector('.o_Message_content').textContent, + "test", + "this message pending moderation should have the correct content" + ); + assert.containsOnce( + document.body, + '.o_Message_originThreadLink', + "thee message should have one origin" + ); + assert.strictEqual( + document.querySelector('.o_Message_originThreadLink').textContent, + "#general", + "the message pending moderation should have correct origin as its linked document" + ); + assert.containsOnce( + document.body, + '.o_Message_checkbox', + "there should be a moderation checkbox next to the message" + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should be unchecked by default" + ); + // check select all (enabled) / unselect all (disabled) buttons + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonSelectAll', + "there should be a 'Select All' button in the control panel" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'), + 'disabled', + "the 'Select All' button should not be disabled" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonUnselectAll', + "there should be a 'Unselect All' button in the control panel" + ); + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'), + 'disabled', + "the 'Unselect All' button should be disabled" + ); + // check moderate all buttons (invisible) + assert.containsN( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration', + 3, + "there should be 3 buttons to moderate selected messages in the control panel" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-accept', + "there should one moderate button to accept messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + "the moderate button 'Accept' should be invisible by default" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-reject', + "there should one moderate button to reject messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'), + "the moderate button 'Reject' should be invisible by default" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-discard', + "there should one moderate button to discard messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'), + "the moderate button 'Discard' should be invisible by default" + ); + + // click on message moderation checkbox + await afterNextRender(() => document.querySelector('.o_Message_checkbox').click()); + assert.ok( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become checked after click" + ); + // check select all (disabled) / unselect all buttons (enabled) + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'), + 'disabled', + "the 'Select All' button should be disabled" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'), + 'disabled', + "the 'Unselect All' button should not be disabled" + ); + // check moderate all buttons updated (visible) + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + "the moderate button 'Accept' should be visible" + ); + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'), + "the moderate button 'Reject' should be visible" + ); + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'), + "the moderate button 'Discard' should be visible" + ); + + // test select buttons + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click() + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become unchecked after click" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click() + ); + assert.ok( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become checked again after click" + ); + + // 2. go to channel 'general' + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + // check correct message + assert.containsOnce( + document.body, + '.o_Message', + "should be only one message in general channel" + ); + assert.containsOnce( + document.body, + '.o_Message_checkbox', + "there should be a moderation checkbox next to the message" + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should not be checked here" + ); + await afterNextRender(() => document.querySelector('.o_Message_checkbox').click()); + // Don't test moderation actions visibility, since it is similar to moderation box. + + // 3. test discard button + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard').click() + ); + assert.containsOnce( + document.body, + '.o_ModerationDiscardDialog', + "discard dialog should be open" + ); + // the dialog will be tested separately + await afterNextRender(() => + document.querySelector('.o_ModerationDiscardDialog .o-cancel').click() + ); + assert.containsNone( + document.body, + '.o_ModerationDiscardDialog', + "discard dialog should be closed" + ); + + // 4. test reject button + await afterNextRender(() => + document.querySelector(` + .o_widget_Discuss_controlPanelButtonModeration.o-reject + `).click() + ); + assert.containsOnce( + document.body, + '.o_ModerationRejectDialog', + "reject dialog should be open" + ); + // the dialog will be tested separately + await afterNextRender(() => + document.querySelector('.o_ModerationRejectDialog .o-cancel').click() + ); + assert.containsNone( + document.body, + '.o_ModerationRejectDialog', + "reject dialog should be closed" + ); + + // 5. test accept button + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "should still be only one message in general channel" + ); + assert.containsNone( + document.body, + '.o_Message_checkbox', + "there should not be a moderation checkbox next to the message" + ); +}); + +QUnit.test('moderation: accept pending moderation message', async function (assert) { + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + assert.strictEqual( + messageIDs.length, + 1, + "should moderate one message" + ); + assert.strictEqual( + messageIDs[0], + 100, + "should moderate message with ID 100" + ); + assert.strictEqual( + decision, + 'accept', + "should accept the message" + ); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + assert.ok( + document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `), + "should display the message to moderate" + ); + const acceptButton = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationAction.o-accept + `); + assert.ok(acceptButton, "should display the accept button"); + + await afterNextRender(() => acceptButton.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const message = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + message, + "should display the accepted message" + ); + assert.containsNone( + message, + '.o_Message_moderationPending', + "the message should not be pending moderation" + ); +}); + +QUnit.test('moderation: reject pending moderation message (reject with explanation)', async function (assert) { + assert.expect(23); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + const kwargs = args.kwargs; + assert.strictEqual( + messageIDs.length, + 1, + "should moderate one message" + ); + assert.strictEqual( + messageIDs[0], + 100, + "should moderate message with ID 100" + ); + assert.strictEqual( + decision, + 'reject', + "should reject the message" + ); + assert.strictEqual( + kwargs.title, + "Message Rejected", + "should have correct reject message title" + ); + assert.strictEqual( + kwargs.comment, + "Your message was rejected by moderator.", + "should have correct reject message body / comment" + ); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + const pendingMessage = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + pendingMessage, + "should display the message to moderate" + ); + const rejectButton = pendingMessage.querySelector(':scope .o_Message_moderationAction.o-reject'); + assert.ok( + rejectButton, + "should display the reject button" + ); + + await afterNextRender(() => rejectButton.click()); + const dialog = document.querySelector('.o_ModerationRejectDialog'); + assert.ok( + dialog, + "a dialog should be prompt to the moderator on click reject" + ); + assert.strictEqual( + dialog.querySelector('.modal-title').textContent, + // TODO FIXME, this should be a proper title "Send explanation to author" + // see https://github.com/odoo/owl/issues/670 + "[object Object]", + "dialog should have correct title" + ); + + const messageTitle = dialog.querySelector(':scope .o_ModerationRejectDialog_title'); + assert.ok( + messageTitle, + "should have a title for rejecting" + ); + assert.hasAttrValue( + messageTitle, + 'placeholder', + "Subject", + "title for reject reason should have correct placeholder" + ); + assert.strictEqual( + messageTitle.value, + "Message Rejected", + "title for reject reason should have correct default value" + ); + + const messageComment = dialog.querySelector(':scope .o_ModerationRejectDialog_comment'); + assert.ok( + messageComment, + "should have a comment for rejecting" + ); + assert.hasAttrValue( + messageComment, + 'placeholder', + "Mail Body", + "comment for reject reason should have correct placeholder" + ); + assert.strictEqual( + messageComment.value, + "Your message was rejected by moderator.", + "comment for reject reason should have correct default text content" + ); + const confirmReject = dialog.querySelector(':scope .o-reject'); + assert.ok( + confirmReject, + "should have reject button" + ); + assert.strictEqual( + confirmReject.textContent, + "Reject" + ); + + await afterNextRender(() => confirmReject.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + 'should display the general channel' + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should now have no message in channel" + ); +}); + +QUnit.test('moderation: discard pending moderation message (reject without explanation)', async function (assert) { + assert.expect(16); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + assert.strictEqual(messageIDs.length, 1, "should moderate one message"); + assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100"); + assert.strictEqual(decision, 'discard', "should discard the message"); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + const pendingMessage = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + pendingMessage, + "should display the message to moderate" + ); + + const discardButton = pendingMessage.querySelector(` + :scope .o_Message_moderationAction.o-discard + `); + assert.ok( + discardButton, + "should display the discard button" + ); + + await afterNextRender(() => discardButton.click()); + const dialog = document.querySelector('.o_ModerationDiscardDialog'); + assert.ok( + dialog, + "a dialog should be prompt to the moderator on click discard" + ); + assert.strictEqual( + dialog.querySelector('.modal-title').textContent, + // TODO FIXME, this should be a proper title "Confirmation" + // see https://github.com/odoo/owl/issues/670 + "[object Object]", + "dialog should have correct title" + ); + assert.strictEqual( + dialog.textContent, + "[object Object]×You are going to discard 1 message.Do you confirm the action?DiscardCancel", + "should warn the user on discard action" + ); + + const confirmDiscard = dialog.querySelector(':scope .o-discard'); + assert.ok( + confirmDiscard, + "should have discard button" + ); + assert.strictEqual( + confirmDiscard.textContent, + "Discard" + ); + + await afterNextRender(() => confirmDiscard.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should now have no message in channel" + ); +}); + +QUnit.test('moderation: send message in moderated channel', async function (assert) { + assert.expect(4); + + const self = this; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + }); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + const message = { + id: 100, + author_id: [13, 'Someone'], + body: args.kwargs.body, + channel_ids: [20], + message_type: args.kwargs.message_type, + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + }; + const notificationData = { + type: 'author', + message: message, + }; + const notification = [[false, 'res.partner', 13], notificationData]; + self.widget.call('bus_service', 'trigger', 'notification', [notification]); + + return message.id; + } + return this._super(...arguments); + }, + }); + + // go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should have no message in channel" + ); + + // post a message + await afterNextRender(() => { + const textInput = document.querySelector('.o_ComposerTextInput_textarea'); + textInput.focus(); + document.execCommand('insertText', false, "Some Text"); + }); + await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); +}); + +QUnit.test('moderation: sent message accepted in moderated channel', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + }); + this.data['mail.message'].records = [{ + author_id: [13, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start(); + + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); + + // simulate accepted message + await afterNextRender(() => { + const messageData = { + author_id: [13, "Someone"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + model: 'mail.channel', + moderation_status: 'accepted', + res_id: 20, + }; + const notification = [[false, 'mail.channel', 20], messageData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + + // check message is accepted + const message = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + message, + "should still display the message" + ); + assert.containsNone( + message, + '.o_Message_moderationPending', + "the message should not be in pending moderation anymore" + ); +}); + +QUnit.test('moderation: sent message rejected in moderated channel', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + }); + this.data['mail.message'].records = [{ + author_id: [13, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start(); + + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); + + // simulate reject from moderator + await afterNextRender(() => { + const notifData = { + type: 'deletion', + message_ids: [100], + }; + const notification = [[false, 'res.partner', 13], notifData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + // check no message + assert.containsNone( + document.body, + '.o_Message', + "message should be removed from channel after reject" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js new file mode 100644 index 00000000000..258d7770066 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class DiscussMobileMailboxSelection extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + return { + allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state), + discussThread: this.env.messaging.discuss.thread + ? this.env.messaging.discuss.thread.__state + : undefined, + }; + }, { + compareDepth: { + allOrderedAndPinnedMailboxes: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread[]} + */ + get orderedMailboxes() { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on a mailbox selection item. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const { mailbox } = ev.currentTarget.dataset; + this.discuss.threadViewer.update({ thread: [['link', mailbox]] }); + } + +} + +Object.assign(DiscussMobileMailboxSelection, { + props: {}, + template: 'mail.DiscussMobileMailboxSelection', +}); + +return DiscussMobileMailboxSelection; + +}); diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss new file mode 100644 index 00000000000..b620e2f1328 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussMobileMailboxSelection { + display: flex; + flex: 0 0 auto; +} + +.o_DiscussMobileMailboxSelection_button { + flex: 1 1 0; + padding: 8px; + z-index: 1; + + &.o-active { + z-index: 2; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussMobileMailboxSelection_button { + box-shadow: 0 2px 4px gray('400'); +} diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml new file mode 100644 index 00000000000..e996a203966 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussMobileMailboxSelection" owl="1"> + <div class="o_DiscussMobileMailboxSelection"> + <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId"> + <button class="o_DiscussMobileMailboxSelection_button btn" + t-att-class="{ + 'btn-primary': discuss.thread === mailbox, + 'btn-secondary': discuss.thread !== mailbox, + 'o-active': discuss.thread === mailbox, + }" t-on-click="_onClick" t-att-data-mailbox-local-id="mailbox.localId" type="button" + > + <t t-esc="mailbox.name"/> + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js new file mode 100644 index 00000000000..11737fa04dc --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js @@ -0,0 +1,307 @@ +odoo.define('mail/static/src/components/discuss_sidebar/discuss_sidebar.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class DiscussSidebar extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore( + (...args) => this._useStoreSelector(...args), + { compareDepth: () => this._useStoreCompareDepth() } + ); + + /** + * Reference of the quick search input. Useful to filter channels and + * chats based on this input content. + */ + this._quickSearchInputRef = useRef('quickSearchInput'); + + // bind since passed as props + this._onAddChannelAutocompleteSelect = this._onAddChannelAutocompleteSelect.bind(this); + this._onAddChannelAutocompleteSource = this._onAddChannelAutocompleteSource.bind(this); + this._onAddChatAutocompleteSelect = this._onAddChatAutocompleteSelect.bind(this); + this._onAddChatAutocompleteSource = this._onAddChatAutocompleteSource.bind(this); + } + + mounted() { + this._update(); + } + + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {mail.thread[]} + */ + get orderedMailboxes() { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } + + /** + * Return the list of chats that match the quick search value input. + * + * @returns {mail.thread[]} + */ + get quickSearchPinnedAndOrderedChats() { + const allOrderedAndPinnedChats = this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'chat' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + if (!this.discuss.sidebarQuickSearchValue) { + return allOrderedAndPinnedChats; + } + const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase(); + return allOrderedAndPinnedChats.filter(chat => { + const nameVal = chat.displayName.toLowerCase(); + return nameVal.includes(qsVal); + }); + } + + /** + * Return the list of channels that match the quick search value input. + * + * @returns {mail.thread[]} + */ + get quickSearchOrderedAndPinnedMultiUserChannels() { + const allOrderedAndPinnedMultiUserChannels = this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + if (!this.discuss.sidebarQuickSearchValue) { + return allOrderedAndPinnedMultiUserChannels; + } + const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase(); + return allOrderedAndPinnedMultiUserChannels.filter(channel => { + const nameVal = channel.displayName.toLowerCase(); + return nameVal.includes(qsVal); + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + if (this._quickSearchInputRef.el) { + this._quickSearchInputRef.el.value = this.discuss.sidebarQuickSearchValue; + } + } + + /** + * @private + * @returns {Object} + */ + _useStoreCompareDepth() { + return { + allOrderedAndPinnedChats: 1, + allOrderedAndPinnedMailboxes: 1, + allOrderedAndPinnedMultiUserChannels: 1, + allPinnedChannelAmount: 1, + }; + } + + /** + * @private + * @param {Object} props + * @returns {Object} + */ + _useStoreSelector(props) { + return { + allOrderedAndPinnedChats: + this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'chat' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1) + .map(chat => chat.__state), + allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state), + allOrderedAndPinnedMultiUserChannels: + this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1) + .map(channel => channel.__state), + allPinnedChannelAmount: + this.env.models['mail.thread'] + .all(thread => + thread.isPinned && + thread.model === 'mail.channel' + ).length, + discuss: this.env.messaging.discuss.__state, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAddChannelAutocompleteSelect(ev, ui) { + this.discuss.handleAddChannelAutocompleteSelect(ev, ui); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAddChannelAutocompleteSource(req, res) { + this.discuss.handleAddChannelAutocompleteSource(req, res); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAddChatAutocompleteSelect(ev, ui) { + this.discuss.handleAddChatAutocompleteSelect(ev, ui); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAddChatAutocompleteSource(req, res) { + this.discuss.handleAddChatAutocompleteSource(req, res); + } + + /** + * Called when clicking on add channel icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChannelAdd(ev) { + ev.stopPropagation(); + this.discuss.update({ isAddingChannel: true }); + } + + /** + * Called when clicking on channel title. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChannelTitle(ev) { + ev.stopPropagation(); + return this.env.bus.trigger('do-action', { + action: { + name: this.env._t("Public Channels"), + type: 'ir.actions.act_window', + res_model: 'mail.channel', + views: [[false, 'kanban'], [false, 'form']], + domain: [['public', '!=', 'private']] + }, + }); + } + + /** + * Called when clicking on add chat icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChatAdd(ev) { + ev.stopPropagation(); + this.discuss.update({ isAddingChat: true }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideAddingItem(ev) { + ev.stopPropagation(); + this.discuss.clearIsAddingItem(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onInputQuickSearch(ev) { + ev.stopPropagation(); + this.discuss.update({ + sidebarQuickSearchValue: this._quickSearchInputRef.el.value, + }); + } + +} + +Object.assign(DiscussSidebar, { + components, + props: {}, + template: 'mail.DiscussSidebar', +}); + +return DiscussSidebar; + +}); diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss new file mode 100644 index 00000000000..3e49cddf7dd --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussSidebar { + display: flex; + flex-flow: column; + width: $o-mail-chat-sidebar-width; + + @include media-breakpoint-up(xl) { + width: $o-mail-chat-sidebar-width + 50px; + } +} + +.o_DiscussSidebar_group { + display: flex; + flex-flow: column; + flex: 0 0 auto; +} + +.o_DiscussSidebar_groupHeader { + display: flex; + align-items: center; + margin: 5px 0; +} + +.o_DiscussSidebar_groupHeaderItem { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right; + } + + &:last-child { + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebar_itemNew { + display: flex; + justify-content: center; +} + +.o_DiscussSidebar_itemNewInput { + flex: 1 1 auto; + margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right + 3px; + margin-right: $o-mail-discuss-sidebar-scrollbar-width; +} + +.o_DiscussSidebar_quickSearch { + border-radius: 10px; + margin: 0 $o-mail-discuss-sidebar-scrollbar-width 10px; + padding: 3px 10px; +} + +.o_DiscussSidebar_separator { + width: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussSidebar { + background-color: gray('900'); + color: gray('300'); +} + +.o_DiscussSidebar_groupHeader { + font-size: $font-size-sm; + text-transform: uppercase; + font-weight: bolder; +} + +.o_DiscussSidebar_groupHeaderItemAdd { + cursor: pointer; + + &:not(:hover) { + color: gray('600'); + } +} + +.o_DiscussSidebar_groupTitle { + + &:not(.o-clickable) { + color: gray('600'); + } + + &.o-clickable { + cursor: pointer; + + &:not(:hover) { + color: gray('600'); + } + } +} + +.o_DiscussSidebar_itemNewInput { + outline: none; +} + +.o_DiscussSidebar_quickSearch { + border: none; + outline: none; +} + +.o_DiscussSidebar_separator { + background-color: gray('600'); +} diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml new file mode 100644 index 00000000000..ac4ac02bd46 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussSidebar" owl="1"> + <div name="root" class="o_DiscussSidebar"> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupMailbox"> + <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="mailbox.localId" + /> + </t> + </div> + <hr class="o_DiscussSidebar_separator"/> + <t t-if="env.models['mail.thread'].all(thread => thread.isPinned and thread.model === 'mail.channel').length > 19"> + <input class="o_DiscussSidebar_quickSearch" t-on-input="_onInputQuickSearch" placeholder="Quick search..." t-ref="quickSearchInput" t-esc="discuss.sidebarQuickSearchValue"/> + </t> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChannel"> + <div class="o_DiscussSidebar_groupHeader"> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle o-clickable" t-on-click="_onClickChannelTitle"> + Channels + </div> + <div class="o-autogrow"/> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChannelAdd" title="Add or join a channel"/> + </div> + <div class="o_DiscussSidebar_list"> + <t t-if="discuss.isAddingChannel"> + <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew"> + <AutocompleteInput + class="o_DiscussSidebar_itemNewInput" + isFocusOnMount="true" + isHtml="true" + placeholder="'Find or create a channel...'" + select="_onAddChannelAutocompleteSelect" + source="_onAddChannelAutocompleteSource" + t-on-o-hide="_onHideAddingItem" + /> + </div> + </t> + <t t-foreach="quickSearchOrderedAndPinnedMultiUserChannels" t-as="channel" t-key="channel.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="channel.localId" + /> + </t> + </div> + </div> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChat"> + <div class="o_DiscussSidebar_groupHeader"> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle"> + Direct Messages + </div> + <div class="o-autogrow"/> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChatAdd" title="Start a conversation"/> + </div> + <div class="o_DiscussSidebar_list"> + <t t-if="discuss.isAddingChat"> + <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew"> + <AutocompleteInput + class="o_DiscussSidebar_itemNewInput" + isFocusOnMount="true" + placeholder="'Find or start a conversation...'" + select="_onAddChatAutocompleteSelect" + source="_onAddChatAutocompleteSource" + t-on-o-hide="_onHideAddingItem" + /> + </div> + </t> + <t t-foreach="quickSearchPinnedAndOrderedChats" t-as="chat" t-key="chat.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="chat.localId" + /> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js new file mode 100644 index 00000000000..db22f9f2116 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js @@ -0,0 +1,203 @@ +odoo.define('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', function (require) { +'use strict'; + +const components = { + EditableText: require('mail/static/src/components/editable_text/editable_text.js'), + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.Dialog'); + +const { Component } = owl; + +class DiscussSidebarItem extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const correspondent = thread ? thread.correspondent : undefined; + return { + correspondent: correspondent ? correspondent.__state : undefined, + discuss: this.env.messaging.discuss.__state, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the counter of this discuss item, which is based on the thread type. + * + * @returns {integer} + */ + get counter() { + if (this.thread.model === 'mail.box') { + return this.thread.counter; + } else if (this.thread.channel_type === 'channel') { + return this.thread.message_needaction_counter; + } else if (this.thread.channel_type === 'chat') { + return this.thread.message_unread_counter; + } + return 0; + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {boolean} + */ + hasUnpin() { + return this.thread.channel_type === 'chat'; + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Promise} + */ + _askAdminConfirmation() { + return new Promise(resolve => { + Dialog.confirm(this, + this.env._t("You are the administrator of this channel. Are you sure you want to leave?"), + { + buttons: [ + { + text: this.env._t("Leave"), + classes: 'btn-primary', + close: true, + click: resolve + }, + { + text: this.env._t("Discard"), + close: true + } + ] + } + ); + }); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onCancelRenaming(ev) { + this.discuss.cancelThreadRenaming(this.thread); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.discuss.threadViewer.update({ thread: [['link', this.thread]] }); + } + + /** + * Stop propagation to prevent selecting this item. + * + * @private + * @param {CustomEvent} ev + */ + _onClickedEditableText(ev) { + ev.stopPropagation(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickLeave(ev) { + ev.stopPropagation(); + if (this.thread.creator === this.env.messaging.currentUser) { + await this._askAdminConfirmation(); + } + this.thread.unsubscribe(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRename(ev) { + ev.stopPropagation(); + this.discuss.setThreadRenaming(this.thread); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSettings(ev) { + ev.stopPropagation(); + return this.env.bus.trigger('do-action', { + action: { + type: 'ir.actions.act_window', + res_model: this.thread.model, + res_id: this.thread.id, + views: [[false, 'form']], + target: 'current' + }, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnpin(ev) { + ev.stopPropagation(); + this.thread.unsubscribe(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.newName + */ + _onValidateEditableText(ev) { + ev.stopPropagation(); + this.discuss.renameThread(this.thread, ev.detail.newName); + } + +} + +Object.assign(DiscussSidebarItem, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.DiscussSidebarItem', +}); + +return DiscussSidebarItem; + +}); diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss new file mode 100644 index 00000000000..649297585f9 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussSidebarItem { + display: flex; + align-items: center; + padding: map-get($spacers, 1) 0; + + &:hover .o_DiscussSidebarItem_commands { + display: flex; + } +} + +.o_DiscussSidebarItem_activeIndicator { + width: $o-mail-discuss-sidebar-active-indicator-width; + align-self: stretch; +} + +.o_DiscussSidebarItem_command { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: 0px; + } + + &:last-child { + margin-right: 0px; + } +} + +.o_DiscussSidebarItem_commands { + display: none; +} + +.o_DiscussSidebarItem_item { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: 0px; + margin-right: $o-mail-discuss-sidebar-active-indicator-margin-right; + } + + &:last-child { + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebarItem_name { + overflow: hidden; + text-overflow: ellipsis; + + &.o-editable { + margin-left: $o-mail-discuss-sidebar-active-indicator-width + $o-mail-discuss-sidebar-active-indicator-margin-right; + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebarItem_nameInput { + width: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussSidebarItem { + cursor: pointer; + + &:hover { + background-color: darken(gray('900'), 5%); + } + + &.o-starred-box { + .o_DiscussSidebarItem_counter { + border-color: gray('600'); + background-color: gray('600'); + } + } +} + +.o_DiscussSidebarItem_activeIndicator.o-item-active { + background-color: $o-brand-primary; +} + +.o_DiscussSidebarItem_command:not(:hover) { + color: gray('600'); +} + +.o_DiscussSidebarItem_counter { + background-color: $o-brand-primary; +} + +.o_DiscussSidebarItem_name { + + &.o-item-unread { + font-weight: bold; + } +} + +.o_DiscussSidebarItem_nameInput { + outline: none; + border: none; + border-radius: 2px; +} diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml new file mode 100644 index 00000000000..2db165b4c34 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussSidebarItem" owl="1"> + <div class="o_DiscussSidebarItem" + t-att-class="{ + 'o-active': thread and discuss.thread === thread, + 'o-starred-box': thread and thread === env.messaging.starred, + 'o-unread': thread and thread.message_unread_counter > 0, + }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined" t-att-data-thread-name="thread ? thread.displayName : undefined" + > + <t t-if="thread"> + <div class=" o_DiscussSidebarItem_activeIndicator o_DiscussSidebarItem_item" t-att-class="{ 'o-item-active': discuss.thread === thread }"/> + <ThreadIcon class="o_DiscussSidebarItem_item" threadLocalId="thread.localId"/> + <t t-if="thread.channel_type === 'chat' and discuss.renamingThreads.includes(thread)"> + <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name o-editable"> + <EditableText + class="o_DiscussSidebarItem_nameInput" + placeholder="thread.correspondent.name" + value="thread.displayName" + t-on-o-cancel="_onCancelRenaming" + t-on-o-clicked="_onClickedEditableText" + t-on-o-validate="_onValidateEditableText" + /> + </div> + </t> + <t t-else=""> + <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name" t-att-class="{ 'o-item-unread': thread.message_unread_counter > 0 }"> + <t t-esc="thread.displayName"/> + </div> + </t> + <div class="o-autogrow o_DiscussSidebarItem_item"/> + <t t-if="thread.model !== 'mail.box'"> + <div class="o_DiscussSidebarItem_commands o_DiscussSidebarItem_item"> + <t t-if="thread.channel_type === 'channel'"> + <div class="fa fa-cog o_DiscussSidebarItem_command o_DiscussSidebarItem_commandSettings" t-on-click="_onClickSettings" title="Channel settings" role="img"/> + <t t-if="!thread.message_needaction_counter and !thread.group_based_subscription"> + <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandLeave fa fa-times" t-on-click="_onClickLeave" title="Leave this channel" role="img"/> + </t> + </t> + <t t-if="thread.channel_type === 'chat'"> + <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandRename fa fa-cog" t-on-click="_onClickRename" title="Rename conversation" role="img"/> + </t> + <t t-if="hasUnpin()"> + <t t-if="!thread.message_unread_counter"> + <div class="fa fa-times o_DiscussSidebarItem_command o_DiscussSidebarItem_commandUnpin" t-on-click="_onClickUnpin" title="Unpin conversation" role="img"/> + </t> + </t> + </div> + </t> + <t t-if="counter > 0"> + <div class="o_DiscussSidebarItem_counter o_DiscussSidebarItem_item badge badge-pill"> + <t t-esc="counter"/> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.js b/addons/mail/static/src/components/drop_zone/drop_zone.js new file mode 100644 index 00000000000..2eb4dc8ef74 --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.js @@ -0,0 +1,136 @@ +odoo.define('mail/static/src/components/drop_zone/drop_zone.js', function (require) { +'use strict'; + +const { Component, useState } = owl; + +class DropZone extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + /** + * Determine whether the user is dragging files over the dropzone. + * Useful to provide visual feedback in that case. + */ + isDraggingInside: false, + }); + /** + * Counts how many drag enter/leave happened on self and children. This + * ensures the drop effect stays active when dragging over a child. + */ + this._dragCount = 0; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns whether the given node is self or a children of self. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + return this.el.contains(node); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Making sure that dragging content is external files. + * Ignoring other content dragging like text. + * + * @private + * @param {DataTransfer} dataTransfer + * @returns {boolean} + */ + _isDragSourceExternalFile(dataTransfer) { + const dragDataType = dataTransfer.types; + if (dragDataType.constructor === window.DOMStringList) { + return dragDataType.contains('Files'); + } + if (dragDataType.constructor === Array) { + return dragDataType.includes('Files'); + } + return false; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Shows a visual drop effect when dragging inside the dropzone. + * + * @private + * @param {DragEvent} ev + */ + _onDragenter(ev) { + ev.preventDefault(); + if (this._dragCount === 0) { + this.state.isDraggingInside = true; + } + this._dragCount++; + } + + /** + * Hides the visual drop effect when dragging outside the dropzone. + * + * @private + * @param {DragEvent} ev + */ + _onDragleave(ev) { + this._dragCount--; + if (this._dragCount === 0) { + this.state.isDraggingInside = false; + } + } + + /** + * Prevents default (from the template) in order to receive the drop event. + * The drop effect cursor works only when set on dragover. + * + * @private + * @param {DragEvent} ev + */ + _onDragover(ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + } + + /** + * Triggers the `o-dropzone-files-dropped` event when new files are dropped + * on the dropzone, and then removes the visual drop effect. + * + * The parents should handle this event to process the files as they wish, + * such as uploading them. + * + * @private + * @param {DragEvent} ev + */ + _onDrop(ev) { + ev.preventDefault(); + if (this._isDragSourceExternalFile(ev.dataTransfer)) { + this.trigger('o-dropzone-files-dropped', { + files: ev.dataTransfer.files, + }); + } + this.state.isDraggingInside = false; + } + +} + +Object.assign(DropZone, { + props: {}, + template: 'mail.DropZone', +}); + +return DropZone; + +}); diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.scss b/addons/mail/static/src/components/drop_zone/drop_zone.scss new file mode 100644 index 00000000000..e1a901cb12e --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.scss @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DropZone { + display: flex; + position: absolute; + left: 0; + bottom: 0; + height: 100%; + width: 100%; + z-index: 1; + align-items: center; + justify-content: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DropZone { + color: $o-enterprise-primary-color; + background: rgba(255, 255, 255, 0.9); + border: 2px dashed $o-enterprise-primary-color; + + &.o-dragging-inside { + border-width: 5px; + } +} diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.xml b/addons/mail/static/src/components/drop_zone/drop_zone.xml new file mode 100644 index 00000000000..b3db940fde9 --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DropZone" owl="1"> + <div class="o_DropZone" t-att-class="{ 'o-dragging-inside': state.isDraggingInside }" t-on-dragenter="_onDragenter" t-on-dragleave="_onDragleave" t-on-dragover="_onDragover" t-on-drop="_onDrop"> + <h4> + Drag Files Here <i class="fa fa-download"/> + </h4> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/editable_text/editable_text.js b/addons/mail/static/src/components/editable_text/editable_text.js new file mode 100644 index 00000000000..4a4b926741e --- /dev/null +++ b/addons/mail/static/src/components/editable_text/editable_text.js @@ -0,0 +1,82 @@ +odoo.define('mail/static/src/components/editable_text/editable_text.js', function (require) { +'use strict'; + +const { Component } = owl; + +class EditableText extends Component { + + mounted() { + this.el.focus(); + this.el.setSelectionRange(0, (this.el.value && this.el.value.length) || 0); + } + + willUnmount() { + this.trigger('o-cancel'); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onBlur(ev) { + this.trigger('o-cancel'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.trigger('o-clicked'); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + switch (ev.key) { + case 'Enter': + this._onKeydownEnter(ev); + break; + case 'Escape': + this.trigger('o-cancel'); + break; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownEnter(ev) { + const value = this.el.value; + const newName = value || this.props.placeholder; + if (this.props.value !== newName) { + this.trigger('o-validate', { newName }); + } else { + this.trigger('o-cancel'); + } + } + +} + +Object.assign(EditableText, { + defaultProps: { + placeholder: "", + value: "", + }, + props: { + placeholder: String, + value: String, + }, + template: 'mail.EditableText', +}); + +return EditableText; + +}); diff --git a/addons/mail/static/src/components/editable_text/editable_text.xml b/addons/mail/static/src/components/editable_text/editable_text.xml new file mode 100644 index 00000000000..5e3aa52abe6 --- /dev/null +++ b/addons/mail/static/src/components/editable_text/editable_text.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.EditableText" owl="1"> + <input class="o_EditableText" t-att-value="props.value" t-on-blur="_onBlur" t-on-click="_onClick" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/> + </t> + +</templates> diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.js b/addons/mail/static/src/components/emojis_popover/emojis_popover.js new file mode 100644 index 00000000000..2e8ee6c6ae8 --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js @@ -0,0 +1,42 @@ +odoo.define('mail/static/src/components/emojis_popover/emojis_popover.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); + +const { Component } = owl; + +class EmojisPopover extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + this.emojis = emojis; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEmoji(ev) { + this.trigger('o-popover-close'); + this.trigger('o-emoji-selection', { + unicode: ev.currentTarget.dataset.unicode, + }); + } + +} + +Object.assign(EmojisPopover, { + props: {}, + template: 'mail.EmojisPopover', +}); + +return EmojisPopover; + +}); diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.scss b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss new file mode 100644 index 00000000000..e49edff659d --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_EmojisPopover { + display: flex; + flex-flow: row wrap; +} + +.o_EmojisPopover_emoji { + font-size: 1.1em; + margin: 3px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_EmojisPopover_emoji { + cursor: pointer; +} diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.xml b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml new file mode 100644 index 00000000000..cac840bbedc --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.EmojisPopover" owl="1"> + <div class="o_EmojisPopover"> + <t t-foreach="emojis" t-as="emoji" t-key="emoji.unicode"> + <span class="o_EmojisPopover_emoji" t-on-click="_onClickEmoji" t-att-title="emoji.description" t-att-data-source="emoji.sources[0]" t-att-data-unicode="emoji.unicode"> + <t t-esc="emoji.unicode"/> + </span> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.js b/addons/mail/static/src/components/file_uploader/file_uploader.js new file mode 100644 index 00000000000..732eb312126 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.js @@ -0,0 +1,206 @@ +odoo.define('mail/static/src/components/file_uploader/file_uploader.js', function (require) { +'use strict'; + +const core = require('web.core'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class FileUploader extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this._fileInputRef = useRef('fileInput'); + this._fileUploadId = _.uniqueId('o_FileUploader_fileupload'); + this._onAttachmentUploaded = this._onAttachmentUploaded.bind(this); + } + + mounted() { + $(window).on(this._fileUploadId, this._onAttachmentUploaded); + } + + willUnmount() { + $(window).off(this._fileUploadId); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {FileList|Array} files + * @returns {Promise} + */ + async uploadFiles(files) { + await this._unlinkExistingAttachments(files); + this._createTemporaryAttachments(files); + await this._performUpload(files); + this._fileInputRef.el.value = ''; + } + + openBrowserFileUploader() { + this._fileInputRef.el.click(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} fileData + * @returns {mail.attachment} + */ + _createAttachment(fileData) { + return this.env.models['mail.attachment'].create(Object.assign( + {}, + fileData, + this.props.newAttachmentExtraData + )); + } + + /** + * @private + * @param {File} file + * @returns {FormData} + */ + _createFormData(file) { + let formData = new window.FormData(); + formData.append('callback', this._fileUploadId); + formData.append('csrf_token', core.csrf_token); + formData.append('id', this.props.uploadId); + formData.append('model', this.props.uploadModel); + formData.append('ufile', file, file.name); + return formData; + } + + /** + * @private + * @param {FileList|Array} files + */ + _createTemporaryAttachments(files) { + for (const file of files) { + this._createAttachment({ + filename: file.name, + isTemporary: true, + name: file.name + }); + } + } + /** + * @private + * @param {FileList|Array} files + * @returns {Promise} + */ + async _performUpload(files) { + for (const file of files) { + const response = await window.fetch('/web/binary/upload_attachment', { + method: 'POST', + body: this._createFormData(file), + }); + let html = await response.text(); + const template = document.createElement('template'); + template.innerHTML = html.trim(); + window.eval(template.content.firstChild.textContent); + } + } + + /** + * @private + * @param {FileList|Array} files + * @returns {Promise} + */ + async _unlinkExistingAttachments(files) { + for (const file of files) { + const attachment = this.props.attachmentLocalIds + .map(attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId)) + .find(attachment => attachment.name === file.name && attachment.size === file.size); + // if the files already exits, delete the file before upload + if (attachment) { + attachment.remove(); + } + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQuery.Event} ev + * @param {...Object} filesData + */ + async _onAttachmentUploaded(ev, ...filesData) { + for (const fileData of filesData) { + const { error, filename, id, mimetype, name, size } = fileData; + if (error || !id) { + this.env.services['notification'].notify({ + type: 'danger', + message: owl.utils.escape(error), + }); + const relatedTemporaryAttachments = this.env.models['mail.attachment'] + .find(attachment => + attachment.filename === filename && + attachment.isTemporary + ); + for (const attachment of relatedTemporaryAttachments) { + attachment.delete(); + } + return; + } + // FIXME : needed to avoid problems on uploading + // Without this the useStore selector of component could be not called + // E.g. in attachment_box_tests.js + await new Promise(resolve => setTimeout(resolve)); + const attachment = this._createAttachment({ + filename, + id, + mimetype, + name, + size, + }); + this.trigger('o-attachment-created', { attachment }); + } + } + + /** + * Called when there are changes in the file input. + * + * @private + * @param {Event} ev + * @param {EventTarget} ev.target + * @param {FileList|Array} ev.target.files + */ + async _onChangeAttachment(ev) { + await this.uploadFiles(ev.target.files); + } + +} + +Object.assign(FileUploader, { + defaultProps: { + uploadId: 0, + uploadModel: 'mail.compose.message' + }, + props: { + attachmentLocalIds: { + type: Array, + element: String, + }, + newAttachmentExtraData: { + type: Object, + optional: true, + }, + uploadId: Number, + uploadModel: String, + }, + template: 'mail.FileUploader', +}); + +return FileUploader; + +}); diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.scss b/addons/mail/static/src/components/file_uploader/file_uploader.scss new file mode 100644 index 00000000000..32792313ed8 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.scss @@ -0,0 +1,3 @@ +.o_FileUploader_input { + display: none !important; +} diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.xml b/addons/mail/static/src/components/file_uploader/file_uploader.xml new file mode 100644 index 00000000000..bf144037511 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FileUploader" owl="1"> + <div class="o_FileUploader"> + <input class="o_FileUploader_input" t-on-change="_onChangeAttachment" multiple="true" type="file" t-ref="fileInput" t-key="'fileInput'"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/file_uploader/file_uploader_tests.js b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js new file mode 100644 index 00000000000..a5831a89efb --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js @@ -0,0 +1,104 @@ +odoo.define('mail/static/src/components/file_uploader/file_uploader_tests.js', function (require) { +"use strict"; + +const components = { + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + inputFiles, + nextAnimationFrame, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('file_uploader', {}, function () { +QUnit.module('file_uploader_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.components = []; + + this.createFileUploaderComponent = async props => { + const FileUploaderComponent = components.FileUploader; + FileUploaderComponent.env = this.env; + const fileUploader = new FileUploaderComponent( + null, + Object.assign({ attachmentLocalIds: [] }, props) + ); + await fileUploader.mount(this.widget.el); + this.components.push(fileUploader); + return fileUploader; + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + const { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + for (const fileUploader of this.components) { + fileUploader.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.FileUploader.env; + this.env = undefined; + }, +}); + +QUnit.test('no conflicts between file uploaders', async function (assert) { + assert.expect(2); + + await this.start(); + const fileUploader1 = await this.createFileUploaderComponent(); + const fileUploader2 = await this.createFileUploaderComponent(); + const file1 = await createFile({ + name: 'text1.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + inputFiles( + fileUploader1.el.querySelector('.o_FileUploader_input'), + [file1] + ); + await nextAnimationFrame(); // we can't use afterNextRender as fileInput are display:none + assert.strictEqual( + this.env.models['mail.attachment'].all().length, + 1, + 'Uploaded file should be the only attachment created' + ); + + const file2 = await createFile({ + name: 'text2.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + inputFiles( + fileUploader2.el.querySelector('.o_FileUploader_input'), + [file2] + ); + await nextAnimationFrame(); + assert.strictEqual( + this.env.models['mail.attachment'].all().length, + 2, + 'Uploaded file should be the only attachment added' + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follow_button/follow_button.js b/addons/mail/static/src/components/follow_button/follow_button.js new file mode 100644 index 00000000000..b1639567c14 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.js @@ -0,0 +1,91 @@ +odoo.define('mail/static/src/components/follow_button/follow_button.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useState } = owl.hooks; + +class FollowButton extends Component { + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + /** + * Determine whether the unfollow button is highlighted or not. + */ + isUnfollowButtonHighlighted: false, + }); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollow(ev) { + this.thread.follow(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnfollow(ev) { + this.thread.unfollow(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseLeaveUnfollow(ev) { + this.state.isUnfollowButtonHighlighted = false; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseEnterUnfollow(ev) { + this.state.isUnfollowButtonHighlighted = true; + } + +} + +Object.assign(FollowButton, { + defaultProps: { + isDisabled: false, + }, + props: { + isDisabled: Boolean, + threadLocalId: String, + }, + template: 'mail.FollowButton', +}); + +return FollowButton; + +}); diff --git a/addons/mail/static/src/components/follow_button/follow_button.scss b/addons/mail/static/src/components/follow_button/follow_button.scss new file mode 100644 index 00000000000..431de0e8577 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.scss @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_FollowButton_follow { + color: gray('600'); +} + +.o_FollowButton_unfollow { + color: gray('600'); + + &.o-following { + color: $green; + } + + &.o-unfollow { + color: $orange; + } +} diff --git a/addons/mail/static/src/components/follow_button/follow_button.xml b/addons/mail/static/src/components/follow_button/follow_button.xml new file mode 100644 index 00000000000..00fc8d65310 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowButton" owl="1"> + <div class="o_FollowButton"> + <t t-if="thread.isCurrentPartnerFollowing"> + <button class="o_FollowButton_unfollow btn btn-link" t-att-class="{ 'o-following': !state.isUnfollowButtonHighlighted, 'o-unfollow': state.isUnfollowButtonHighlighted }" t-att-disabled="props.isDisabled" t-on-click="_onClickUnfollow" t-on-mouseenter="_onMouseEnterUnfollow" t-on-mouseleave="_onMouseLeaveUnfollow"> + <t t-if="state.isUnfollowButtonHighlighted"> + <i class="fa fa-times"/> Unfollow + </t> + <t t-else=""> + <i class="fa fa-check"/> Following + </t> + </button> + </t> + <t t-else=""> + <button class="o_FollowButton_follow btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollow"> + Follow + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follow_button/follow_button_tests.js b/addons/mail/static/src/components/follow_button/follow_button_tests.js new file mode 100644 index 00000000000..a32cdb76e9d --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button_tests.js @@ -0,0 +1,331 @@ +odoo.define('mail/static/src/components/follow_button/follow_button_tests.js', function (require) { +'use strict'; + +const components = { + FollowButton: require('mail/static/src/components/follow_button/follow_button.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follow_button', {}, function () { +QUnit.module('follow_button_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowButtonComponent = async (thread, otherProps = {}) => { + const FollowButtonComponent = components.FollowButton; + FollowButtonComponent.env = this.env; + this.component = new FollowButtonComponent(null, + Object.assign(otherProps, { threadLocalId: thread.localId }) + ); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.FollowButton.env; + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread, { isDisabled: true }); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have 'Follow' button" + ); + assert.ok( + document.querySelector('.o_FollowButton_follow').disabled, + "'Follow' button should be disabled" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have 'Follow' button" + ); + assert.notOk( + document.querySelector('.o_FollowButton_follow').disabled, + "'Follow' button should be disabled" + ); +}); + +QUnit.test('hover following button', async function (assert) { + assert.expect(8); + const self = this; + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + return [{ + id: 100, + message_follower_ids: [1], + }]; + } else if (route.includes('message_subscribe')) { + return; + } else if (route.includes('message_unsubscribe')) { + return; + } else if (route.includes('mail/read_followers')) { + return { + followers: [{ + partner_id: self.env.session.partner_id, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + thread.follow(); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have 'Unfollow' button" + ); + assert.strictEqual( + document.querySelector('.o_FollowButton_unfollow').textContent.trim(), + 'Following', + "'unfollow' button should display 'Following' as text when not hovered" + ); + assert.containsNone( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-times', + "'unfollow' button should not contain a cross icon when not hovered" + ); + assert.containsOnce( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-check', + "'unfollow' button should contain a check icon when not hovered" + ); + + await afterNextRender(() => { + document + .querySelector('.o_FollowButton_unfollow') + .dispatchEvent(new window.MouseEvent('mouseenter')); + } + ); + assert.strictEqual( + document.querySelector('.o_FollowButton_unfollow').textContent.trim(), + 'Unfollow', + "'unfollow' button should display 'Unfollow' as text when hovered" + ); + assert.containsOnce( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-times', + "'unfollow' button should contain a cross icon when hovered" + ); + assert.containsNone( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-check', + "'unfollow' button should not contain a check icon when hovered" + ); +}); + +QUnit.test('click on "follow" button', async function (assert) { + assert.expect(8); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + assert.step('rpc:read_follower_ids'); + return [{ + id: 100, + message_follower_ids: [1], + }]; + } else if (route.includes('message_subscribe')) { + assert.step('rpc:message_subscribe'); + return; + } else if (route.includes('mail/read_followers')) { + assert.step('rpc:read_followers_details'); + return { + followers: [{ + partner_id: 3, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have button follow" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowButton_follow').click(); + }); + assert.verifySteps([ + 'rpc:message_subscribe', + 'rpc:read_follower_ids', + 'rpc:read_followers_details', + ]); + assert.containsNone( + document.body, + '.o_FollowButton_follow', + "should not have follow button after clicked on follow" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have unfollow button after clicked on follow" + ); +}); + +QUnit.test('click on "unfollow" button', async function (assert) { + assert.expect(7); + const self = this; + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + return [{ + id: 100, + message_follower_ids: [1], + }]; + } else if (route.includes('message_subscribe')) { + return; + } else if (route.includes('message_unsubscribe')) { + assert.step('rpc:message_unsubscribe'); + return; + } else if (route.includes('mail/read_followers')) { + return { + followers: [{ + partner_id: self.env.session.partner_id, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + thread.follow(); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsNone( + document.body, + '.o_FollowButton_follow', + "should not have button follow" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have button unfollow" + ); + + await afterNextRender(() => document.querySelector('.o_FollowButton_unfollow').click()); + assert.verifySteps(['rpc:message_unsubscribe']); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have follow button after clicked on unfollow" + ); + assert.containsNone( + document.body, + '.o_FollowButton_unfollow', + "should not have unfollow button after clicked on unfollow" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower/follower.js b/addons/mail/static/src/components/follower/follower.js new file mode 100644 index 00000000000..f446c098914 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.js @@ -0,0 +1,81 @@ +odoo.define('mail/static/src/components/follower/follower.js', function (require) { +'use strict'; + +const components = { + FollowerSubtypeList: require('mail/static/src/components/follower_subtype_list/follower_subtype_list.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class Follower extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const follower = this.env.models['mail.follower'].get(props.followerLocalId); + return [follower ? follower.__state : undefined]; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower} + */ + get follower() { + return this.env.models['mail.follower'].get(this.props.followerLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDetails(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this.env.messaging.openDocument({ + id: this.follower.resId, + model: this.follower.resModel, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEdit(ev) { + ev.preventDefault(); + this.follower.showSubtypes(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRemove(ev) { + this.follower.remove(); + } + +} + +Object.assign(Follower, { + components, + props: { + followerLocalId: String, + }, + template: 'mail.Follower', +}); + +return Follower; + +}); diff --git a/addons/mail/static/src/components/follower/follower.scss b/addons/mail/static/src/components/follower/follower.scss new file mode 100644 index 00000000000..509a119fb61 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.scss @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Follower { + display: flex; + flex-flow: row; + justify-content: space-between; + padding: map-get($spacers, 0); +} + +.o_Follower_avatar { + width: 24px; + height: 24px; + margin-inline-end: map-get($spacers, 2); +} + +.o_Follower_details { + align-items: center; + display: flex; + flex: 1; + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Follower_avatar { + border-radius: 50%; +} + +.o_Follower_button { + border-radius: 0; + + &:hover { + background: gray('400'); + color: $black; + } +} + +.o_Follower_details { + color: gray('700'); + + &:hover { + background: gray('400'); + color: $black; + } + + &.o-inactive { + opacity: 0.25; + font-style: italic; + } +} diff --git a/addons/mail/static/src/components/follower/follower.xml b/addons/mail/static/src/components/follower/follower.xml new file mode 100644 index 00000000000..26a26ece3f9 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Follower" owl="1"> + <div class="o_Follower"> + <t t-if="follower"> + <a class="o_Follower_details" t-att-class="{ 'o-inactive': !follower.isActive }" href="#" t-on-click="_onClickDetails"> + <img class="o_Follower_avatar" t-attf-src="/web/image/{{ follower.resModel }}/{{ follower.resId }}/image_128" alt="Avatar"/> + <span class="o_Follower_name" t-esc="follower.name"/> + </a> + <t t-if="follower.isEditable"> + <button class="btn btn-icon o_Follower_button o_Follower_editButton" title="Edit subscription" t-on-click="_onClickEdit"> + <i class="fa fa-pencil"/> + </button> + <button class="btn btn-icon o_Follower_button o_Follower_removeButton" title="Remove this follower" t-on-click="_onClickRemove"> + <i class="fa fa-remove"/> + </button> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower/follower_tests.js b/addons/mail/static/src/components/follower/follower_tests.js new file mode 100644 index 00000000000..87e09dda2df --- /dev/null +++ b/addons/mail/static/src/components/follower/follower_tests.js @@ -0,0 +1,354 @@ +odoo.define('mail/static/src/components/follower/follower_tests.js', function (require) { +'use strict'; + +const components = { + Follower: require('mail/static/src/components/follower/follower.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower', {}, function () { +QUnit.module('follower_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowerComponent = async (follower) => { + const FollowerComponent = components.Follower; + FollowerComponent.env = this.env; + this.component = new FollowerComponent(null, { followerLocalId: follower.localId }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.Follower.env; + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/image/mail.channel/1/image_128') { + return; + } + return this._super(...arguments); + }, + }); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: false, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + assert.containsOnce( + document.body, + '.o_Follower_avatar', + "should display the avatar of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_name', + "should display the name of the follower" + ); + assert.containsNone( + document.body, + '.o_Follower_button', + "should have no button as follower is not editable" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route === 'web/image/mail.channel/1/image_128') { + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + assert.containsOnce( + document.body, + '.o_Follower_avatar', + "should display the avatar of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_name', + "should display the name of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_editButton', + "should have an edit button" + ); + assert.containsOnce( + document.body, + '.o_Follower_removeButton', + "should have a remove button" + ); +}); + +QUnit.test('click on channel follower details', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.res_id, + 1, + "The redirect action should redirect to the right res id (1)" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.channel', + "The redirect action should redirect to the right res model (mail.channel)" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The redirect action should be of type 'ir.actions.act_window'" + ); + }); + + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route === 'web/image/mail.channel/1/image_128') { + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "channel" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + + document.querySelector('.o_Follower_details').click(); + assert.verifySteps( + ['do_action'], + "clicking on channel should redirect to channel form view" + ); +}); + +QUnit.test('click on partner follower details', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.res_id, + 3, + "The redirect action should redirect to the right res id (3)" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "The redirect action should redirect to the right res model (res.partner)" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The redirect action should be of type 'ir.actions.act_window'" + ); + }); + + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route === 'web/image/res.partner/3/image_128') { + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.session.partner_id, + name: "François Perusse", + }]], + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + + document.querySelector('.o_Follower_details').click(); + assert.verifySteps( + ['do_action'], + "clicking on follower should redirect to partner form view" + ); +}); + +QUnit.test('click on edit follower', async function (assert) { + assert.expect(5); + + await this.start({ + hasDialog: true, + async mockRPC(route, args) { + if (route === 'web/image/res.partner/3/image_128') { + return; + } + if (route.includes('/mail/read_subscription_data')) { + assert.step('fetch_subtypes'); + return [{ + default: true, + followed: true, + internal: false, + id: 1, + name: "Dummy test", + res_model: 'res.partner' + }]; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.session.partner_id, + name: "François Perusse", + }]], + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_editButton', + "should display an edit button" + ); + + await afterNextRender(() => document.querySelector('.o_Follower_editButton').click()); + assert.verifySteps( + ['fetch_subtypes'], + "clicking on edit follower should fetch subtypes" + ); + + assert.containsOnce( + document.body, + '.o_FollowerSubtypeList', + "A dialog allowing to edit follower subtypes should have been created" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js new file mode 100644 index 00000000000..54eac6e284c --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js @@ -0,0 +1,152 @@ +odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu.js', function (require) { +'use strict'; + +const components = { + Follower: require('mail/static/src/components/follower/follower.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef, useState } = owl.hooks; + +class FollowerListMenu extends Component { + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + /** + * Determine whether the dropdown is open or not. + */ + isDropdownOpen: false, + }); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const followers = thread ? thread.followers : []; + return { + followers: followers.map(follower => follower.__state), + thread: thread ? thread.__state : undefined, + }; + }, { + compareDepth: { + followers: 1, + }, + }); + this._dropdownRef = useRef('dropdown'); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _hide() { + this.state.isDropdownOpen = false; + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + ev.stopPropagation(); + switch (ev.key) { + case 'Escape': + ev.preventDefault(); + this._hide(); + break; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAddChannels(ev) { + ev.preventDefault(); + this._hide(); + this.thread.promptAddChannelFollower(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAddFollowers(ev) { + ev.preventDefault(); + this._hide(); + this.thread.promptAddPartnerFollower(); + } + + /** + * Close the dropdown when clicking outside of it. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + // since dropdown is conditionally shown based on state, dropdownRef can be null + if (this._dropdownRef.el && !this._dropdownRef.el.contains(ev.target)) { + this._hide(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollowersButton(ev) { + this.state.isDropdownOpen = !this.state.isDropdownOpen; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollower(ev) { + this._hide(); + } +} + +Object.assign(FollowerListMenu, { + components, + defaultProps: { + isDisabled: false, + }, + props: { + isDisabled: Boolean, + threadLocalId: String, + }, + template: 'mail.FollowerListMenu', +}); + +return FollowerListMenu; + +}); diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss new file mode 100644 index 00000000000..2fa52819c59 --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerListMenu { + position: relative; +} + +.o_FollowerListMenu_dropdown { + display: flex; + flex-flow: column; + padding-top: map-get($spacers, 0); + padding-bottom: map-get($spacers, 0); + overflow-y: auto; +} + +.o_FollowerListMenu_followers { + display: flex; +} diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml new file mode 100644 index 00000000000..5bf6c129dda --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerListMenu" owl="1"> + <div class="o_FollowerListMenu" t-on-keydown="_onKeydown"> + <div class="o_FollowerListMenu_followers" t-ref="dropdown"> + <button class="o_FollowerListMenu_buttonFollowers btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollowersButton" title="Show Followers"> + <i class="fa fa-user"/> + <span class="o_FollowerListMenu_buttonFollowersCount pl-1" t-esc="thread.followers.length"/> + </button> + + <t t-if="state.isDropdownOpen"> + <div class="o_FollowerListMenu_dropdown dropdown-menu dropdown-menu-right" role="menu"> + <a class="o_FollowerListMenu_addFollowersButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddFollowers"> + Add Followers + </a> + <a class="o_FollowerListMenu_addChannelsButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddChannels"> + Add Channels + </a> + <t t-if="thread.followers.length > 0"> + <div role="separator" class="dropdown-divider"/> + <t t-foreach="thread.followers" t-as="follower" t-key="follower.localId"> + <Follower + class="o_FollowerMenu_follower dropdown-item" + followerLocalId="follower.localId" + t-on-click="_onClickFollower" + /> + </t> + </t> + </div> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js new file mode 100644 index 00000000000..b67c87398ed --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js @@ -0,0 +1,465 @@ +odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu_tests.js', function (require) { +'use strict'; + +const components = { + FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower_list_menu', {}, function () { +QUnit.module('follower_list_menu_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowerListMenuComponent = async (thread, otherProps = {}) => { + const FollowerListMenuComponent = components.FollowerListMenu; + FollowerListMenuComponent.env = this.env; + this.component = new FollowerListMenuComponent(null, + Object.assign(otherProps, { threadLocalId: thread.localId }) + ); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.FollowerListMenu.env; + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread, { isDisabled: true }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.ok( + document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled, + "followers button should be disabled" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should not be opened" + ); + + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should still be closed as button is disabled" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.notOk( + document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled, + "followers button should not be disabled" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should not be opened" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); +}); + +QUnit.test('click on "add followers" button', async function (assert) { + assert.expect(18); + + const followerIds = []; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('action:open_view'); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + "'The 'add followers' action should contain thread model in context'" + ); + assert.notOk( + payload.action.context.mail_invite_follower_channel_only, + "The 'add followers' action should not be restricted to channels only" + ); + assert.strictEqual( + payload.action.context.default_res_id, + 100, + "The 'add followers' action should contain thread id in context" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.wizard.invite', + "The 'add followers' action should be a wizard invite of mail module" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The 'add followers' action should be of type 'ir.actions.act_window'" + ); + followerIds.push(1); + payload.options.on_close(); + }); + + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + assert.step('rpc:read_follower_ids'); + return [{ + id: 100, + message_follower_ids: followerIds, + }]; + } else if (route.includes('mail/read_followers')) { + assert.step('rpc:read_followers_details'); + return { + followers: [{ + partner_id: 42, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "0", + "Followers counter should be equal to 0" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_addFollowersButton', + "followers dropdown should contain a 'Add followers' button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_addFollowersButton').click(); + }); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be closed after click on 'Add followers'" + ); + assert.verifySteps([ + 'action:open_view', + 'rpc:read_follower_ids', + 'rpc:read_followers_details', + ]); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "1", + "Followers counter should now be equal to 1" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerMenu_follower', + "Follower list should be refreshed and contain a follower" + ); + assert.strictEqual( + document.querySelector('.o_Follower_name').textContent, + "François Perusse", + "Follower added in follower list should be the one added" + ); +}); + +QUnit.test('click on "add channels" button', async function (assert) { + assert.expect(18); + + const followerIds = []; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('action:open_view'); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + "'The 'add channels' action should contain thread model in context'" + ); + assert.ok( + payload.action.context.mail_invite_follower_channel_only, + "The 'add channels' action should be restricted to channels only" + ); + assert.strictEqual( + payload.action.context.default_res_id, + 100, + "The 'add channels' action should contain thread id in context" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.wizard.invite', + "The 'add channels' action should be a wizard invite of mail module" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The 'add channels' action should be of type 'ir.actions.act_window'" + ); + followerIds.push(1); + payload.options.on_close(); + }); + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + assert.step('rpc:read_follower_ids'); + return [{ + id: 100, + message_follower_ids: followerIds, + }]; + } else if (route.includes('mail/read_followers')) { + assert.step('rpc:read_followers_details'); + return { + followers: [{ + channel_id: 42, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "Supa channel", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "0", + "Followers counter should be equal to 0" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_addChannelsButton', + "followers dropdown should contain a 'Add channels' button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_addChannelsButton').click(); + }); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be closed after click on 'add channels'" + ); + assert.verifySteps([ + 'action:open_view', + 'rpc:read_follower_ids', + 'rpc:read_followers_details', + ]); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "1", + "Followers counter should now be equal to 1" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerMenu_follower', + "Follower list should be refreshed and contain a follower" + ); + assert.strictEqual( + document.querySelector('.o_Follower_name').textContent, + "Supa channel", + "Follower added in follower list should be the one added" + ); +}); + +QUnit.test('click on remove follower', async function (assert) { + assert.expect(6); + + const self = this; + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('message_unsubscribe')) { + assert.step('message_unsubscribe'); + // FIXME "integration" test, should be replaced ... maybe by a tour. + assert.deepEqual( + args.args, + [[100],[self.env.session.partner_id]], + "message unsubscribe should be called with right argument" + ); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.session.partner_id, + name: "François Perusse", + }]], + }); + await this.createFollowerListMenuComponent(thread); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_removeButton', + "should display a remove button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Follower_removeButton').click(); + }); + assert.verifySteps( + ['message_unsubscribe'], + "clicking on remove button should call 'message_unsubscribe' route" + ); + assert.containsNone( + document.body, + '.o_Follower', + "should no more have follower component" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.js b/addons/mail/static/src/components/follower_subtype/follower_subtype.js new file mode 100644 index 00000000000..8ff47ada34f --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.js @@ -0,0 +1,69 @@ +odoo.define('mail/static/src/components/follower_subtype/follower_subtype.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class FollowerSubtype extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const followerSubtype = this.env.models['mail.follower_subtype'].get(props.followerSubtypeLocalId); + return [followerSubtype ? followerSubtype.__state : undefined]; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower|undefined} + */ + get follower() { + return this.env.models['mail.follower'].get(this.props.followerLocalId); + } + + /** + * @returns {mail.follower_subtype} + */ + get followerSubtype() { + return this.env.models['mail.follower_subtype'].get(this.props.followerSubtypeLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on cancel button. + * + * @private + * @param {Event} ev + */ + _onChangeCheckbox(ev) { + if (ev.target.checked) { + this.follower.selectSubtype(this.followerSubtype); + } else { + this.follower.unselectSubtype(this.followerSubtype); + } + } + +} + +Object.assign(FollowerSubtype, { + props: { + followerLocalId: String, + followerSubtypeLocalId: String, + }, + template: 'mail.FollowerSubtype', +}); + +return FollowerSubtype; + +}); diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.scss b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss new file mode 100644 index 00000000000..3be0ad4656e --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerSubtype_checkbox { + margin-inline-end: map-get($spacers, 2); +} + +.o_FollowerSubtype_label { + display: flex; + flex: 1; + flex-direction: row; + align-items: center; + margin-bottom: map-get($spacers, 0); + padding: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_FollowerSubtype_label { + cursor: pointer; + &:hover { + background-color: gray('200'); + } +} diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.xml b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml new file mode 100644 index 00000000000..b23800099d6 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerSubtype" owl="1"> + <div class="o_FollowerSubtype"> + <label class="o_FollowerSubtype_label"> + <input class="o_FollowerSubtype_checkbox" type="checkbox" t-att-checked="follower.selectedSubtypes.includes(followerSubtype) ? 'checked': ''" t-on-change="_onChangeCheckbox"/> + <t t-esc="followerSubtype.name"/> + </label> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js new file mode 100644 index 00000000000..ac5baa13ec3 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js @@ -0,0 +1,241 @@ +odoo.define('mail/static/src/components/follower_subtype/follower_subtype_tests.js', function (require) { +'use strict'; + +const components = { + FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower_subtype', {}, function () { +QUnit.module('follower_subtype_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowerSubtypeComponent = async ({ follower, followerSubtype }) => { + const FollowerSubtypeComponent = components.FollowerSubtype; + FollowerSubtypeComponent.env = this.env; + this.component = new FollowerSubtypeComponent(null, { + followerLocalId: follower.localId, + followerSubtypeLocalId: followerSubtype.localId, + }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.FollowerSubtype.env; + } +}); + +QUnit.test('simplest layout of a followed subtype', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ + selectedSubtypes: [['link', followerSubtype]], + subtypes: [['link', followerSubtype]], + }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_label', + "should have a label" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.strictEqual( + document.querySelector('.o_FollowerSubtype_label').textContent, + "Dummy test", + "should have the name of the subtype as label" + ); + assert.ok( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should be checked as follower subtype is followed" + ); +}); + +QUnit.test('simplest layout of a not followed subtype', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ subtypes: [['link', followerSubtype]] }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_label', + "should have a label" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.strictEqual( + document.querySelector('.o_FollowerSubtype_label').textContent, + "Dummy test", + "should have the name of the subtype as label" + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should not be checked as follower subtype is not followed" + ); +}); + +QUnit.test('toggle follower subtype checkbox', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ subtypes: [['link', followerSubtype]] }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should not be checked as follower subtype is not followed" + ); + + await afterNextRender(() => + document.querySelector('.o_FollowerSubtype_checkbox').click() + ); + assert.ok( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should now be checked" + ); + + await afterNextRender(() => + document.querySelector('.o_FollowerSubtype_checkbox').click() + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should be no more checked" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js new file mode 100644 index 00000000000..851382861ea --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js @@ -0,0 +1,87 @@ +odoo.define('mail/static/src/components/follower_subtype_list/follower_subtype_list.js', function (require) { +'use strict'; + +const components = { + FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component, QWeb } = owl; + +class FollowerSubtypeList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const followerSubtypeList = this.env.models['mail.follower_subtype_list'].get(props.localId); + const follower = followerSubtypeList + ? followerSubtypeList.follower + : undefined; + const followerSubtypes = follower ? follower.subtypes : []; + return { + follower: follower ? follower.__state : undefined, + followerSubtypeList: followerSubtypeList + ? followerSubtypeList.__state + : undefined, + followerSubtypes: followerSubtypes.map(subtype => subtype.__state), + }; + }, { + compareDepth: { + followerSubtypes: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower_subtype_list} + */ + get followerSubtypeList() { + return this.env.models['mail.follower_subtype_list'].get(this.props.localId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on cancel button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCancel(ev) { + this.followerSubtypeList.follower.closeSubtypes(); + } + + /** + * Called when clicking on apply button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickApply(ev) { + this.followerSubtypeList.follower.updateSubtypes(); + } + +} + +Object.assign(FollowerSubtypeList, { + components, + props: { + localId: String, + }, + template: 'mail.FollowerSubtypeList', +}); + +QWeb.registerComponent('FollowerSubtypeList', FollowerSubtypeList); + +return FollowerSubtypeList; + +}); diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss new file mode 100644 index 00000000000..82aef9fc974 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss @@ -0,0 +1,8 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerSubtypeList_subtypes { + display: flex; + flex-flow: column; +} diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml new file mode 100644 index 00000000000..9ab73328a9e --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerSubtypeList" owl="1"> + <div class="o_FollowerSubtypeList modal-dialog"> + <t t-if="followerSubtypeList"> + <div class="modal-content"> + <header class="modal-header"> + <h4 class="modal-title"> + Edit Subscription of <t t-esc="followerSubtypeList.follower.name"/> + </h4> + <i class="close fa fa-times" aria-label="Close" t-on-click="_onClickCancel"/> + </header> + <main class="modal-body"> + <div class="o_FollowerSubtypeList_subtypes"> + <t t-foreach="followerSubtypeList.follower.subtypes" t-as="subtype" t-key="subtype.id"> + <FollowerSubtype + class="o_FollowerSubtypeList_subtype" + followerLocalId="followerSubtypeList.follower.localId" + followerSubtypeLocalId="subtype.localId" + /> + </t> + </div> + </main> + <div class="modal-footer"> + <button class="o-apply btn btn-primary" t-on-click="_onClickApply"> + Apply + </button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel"> + Cancel + </button> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/mail_template/mail_template.js b/addons/mail/static/src/components/mail_template/mail_template.js new file mode 100644 index 00000000000..45dc8c344c6 --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.js @@ -0,0 +1,79 @@ +odoo.define('mail/static/src/components/mail_template/mail_template.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MailTemplate extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + const mailTemplate = this.env.models['mail.mail_template'].get(props.mailTemplateLocalId); + return { + activity: activity ? activity.__state : undefined, + mailTemplate: mailTemplate ? mailTemplate.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {mail.mail_template} + */ + get mailTemplate() { + return this.env.models['mail.mail_template'].get(this.props.mailTemplateLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickPreview(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.mailTemplate.preview(this.activity); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSend(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.mailTemplate.send(this.activity); + } + +} + +Object.assign(MailTemplate, { + props: { + activityLocalId: String, + mailTemplateLocalId: String, + }, + template: 'mail.MailTemplate', +}); + +return MailTemplate; + +}); diff --git a/addons/mail/static/src/components/mail_template/mail_template.scss b/addons/mail/static/src/components/mail_template/mail_template.scss new file mode 100644 index 00000000000..7800ab62bc5 --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MailTemplate { + display: flex; + flex: 0 0 auto; + align-items: center; +} + +.o_MailTemplate_button { + padding-top: map-get($spacers, 0); + padding-bottom: map-get($spacers, 0); +} + +.o_MailTemplate_name { + margin-inline-start: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MailTemplate_text { + color: gray('500'); + font-style: italic; +} diff --git a/addons/mail/static/src/components/mail_template/mail_template.xml b/addons/mail/static/src/components/mail_template/mail_template.xml new file mode 100644 index 00000000000..48f7c050fcc --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MailTemplate" owl="1"> + <div class="o_MailTemplate"> + <t t-if="mailTemplate"> + <i class="fa fa-envelope-o" title="Mail" role="img"/> + <span class="o_MailTemplate_name" t-esc="mailTemplate.name"/> + <span>:</span> + <button + class="o_MailTemplate_button o_MailTemplate_preview btn btn-link" + t-att-data-mail-template-id="mailTemplate.id" + t-on-click="_onClickPreview" + > + Preview + </button> + <span class="o_MailTemplate_text">or</span> + <button + class="o_MailTemplate_button o_MailTemplate_send btn btn-link" + t-att-data-mail-template-id="mailTemplate.id" + t-on-click="_onClickSend" + > + Send Now + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js new file mode 100644 index 00000000000..f4940a1e566 --- /dev/null +++ b/addons/mail/static/src/components/message/message.js @@ -0,0 +1,621 @@ +odoo.define('mail/static/src/components/message/message.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + MessageSeenIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'), + ModerationBanDialog: require('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js'), + ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'), + ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'), + NotificationPopover: require('mail/static/src/components/notification_popover/notification_popover.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const { timeFromNow } = require('mail.utils'); + +const { _lt } = require('web.core'); +const { getLangDatetimeFormat } = require('web.time'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +const READ_MORE = _lt("read more"); +const READ_LESS = _lt("read less"); + +class Message extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + // Determine if the moderation ban dialog is displayed. + hasModerationBanDialog: false, + // Determine if the moderation discard dialog is displayed. + hasModerationDiscardDialog: false, + // Determine if the moderation reject dialog is displayed. + hasModerationRejectDialog: false, + /** + * Determine whether the message is clicked. When message is in + * clicked state, it keeps displaying the commands. + */ + isClicked: false, + /** + * Time elapsed from message datetime to current datetime. + */ + timeElapsed: null, + }); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const author = message ? message.author : undefined; + const partnerRoot = this.env.messaging.partnerRoot; + const originThread = message ? message.originThread : undefined; + const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId); + const thread = threadViewer ? threadViewer.thread : undefined; + const threadStringifiedDomain = threadViewer + ? threadViewer.stringifiedDomain + : undefined; + return { + attachments: message + ? message.attachments.map(attachment => attachment.__state) + : undefined, + author: author ? author.__state : undefined, + hasMessageCheckbox: message ? message.hasCheckbox : false, + isDeviceMobile: this.env.messaging.device.isMobile, + isMessageChecked: message && threadViewer + ? message.isChecked(thread, threadStringifiedDomain) + : false, + message: message ? message.__state : undefined, + notifications: message ? message.notifications.map(notif => notif.__state) : [], + originThread: originThread ? originThread.__state : undefined, + partnerRoot: partnerRoot ? partnerRoot.__state : undefined, + thread: thread ? thread.__state : undefined, + threadViewer: threadViewer ? threadViewer.__state : undefined, + }; + }, { + compareDepth: { + notifications: 1, + }, + }); + /** + * Reference to the content of the message. + */ + this._contentRef = useRef('content'); + /** + * To get checkbox state. + */ + this._checkboxRef = useRef('checkbox'); + /** + * Id of setInterval used to auto-update time elapsed of message at + * regular time. + */ + this._intervalId = undefined; + this._constructor(); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + mounted() { + this._insertReadMoreLess($(this._contentRef.el)); + this._update(); + } + + patched() { + this._update(); + } + + willUnmount() { + clearInterval(this._intervalId); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get avatar() { + if ( + this.message.author && + this.message.author === this.env.messaging.partnerRoot + ) { + return '/mail/static/src/img/odoobot.png'; + } else if (this.message.author) { + // TODO FIXME for public user this might not be accessible. task-2223236 + // we should probably use the correspondig attachment id + access token + // or create a dedicated route to get message image, checking the access right of the message + return `/web/image/res.partner/${this.message.author.id}/image_128`; + } else if (this.message.message_type === 'email') { + return '/mail/static/src/img/email_icon.png'; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + } + + /** + * Get the date time of the message at current user locale time. + * + * @returns {string} + */ + get datetime() { + return this.message.date.format(getLangDatetimeFormat()); + } + + /** + * Determine whether author redirect feature is enabled on message. + * Click on message author should redirect to author. + * + * @returns {boolean} + */ + get hasAuthorRedirect() { + if (!this.props.hasAuthorRedirect) { + return false; + } + if (!this.message.author) { + return false; + } + if (this.message.author === this.env.messaging.currentPartner) { + return false; + } + return true; + } + + /** + * Determine whether the message origin thread is the same as the context + * of displaying this message. In other word, if the enclosing thread + * component of this message component is linked to the origin thread of + * this message, then the origin is the same. + * + * @returns {boolean} + */ + get hasDifferentOriginThread() { + if (!this.threadViewer) { + return false; + } + if (!this.message.originThread) { + return false; + } + return this.message.originThread !== this.threadViewer.thread; + } + + /** + * @returns {mail.attachment[]} + */ + get imageAttachments() { + return this.message.attachments.filter(attachment => attachment.fileType === 'image'); + } + + /** + * Tell whether the bottom of this message is visible or not. + * + * @param {Object} param0 + * @param {integer} [offset=0] + * @returns {boolean} + */ + isBottomVisible({ offset=0 } = {}) { + if (!this.el) { + return false; + } + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // bottom with (double) 10px offset + return ( + elRect.bottom < parentRect.bottom + offset && + parentRect.top < elRect.bottom + offset + ); + } + + /** + * Tell whether the message is partially visible on browser window or not. + * + * @returns {boolean} + */ + isPartiallyVisible() { + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // intersection with 5px offset + return ( + elRect.top < parentRect.bottom + 5 && + parentRect.top < elRect.bottom + 5 + ); + } + + /** + * Determine whether the message is starred. + * + * @returns {boolean} + */ + get isStarred() { + return this.message.allThreads.includes(this.env.messaging.starred); + } + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.attachment[]} + */ + get nonImageAttachments() { + return this.message.attachments.filter(attachment => attachment.fileType !== 'image'); + } + + /** + * Make this message viewable in its enclosing scroll environment (usually + * message list). + * + * @param {Object} [param0={}] + * @param {string} [param0.behavior='auto'] + * @param {string} [param0.block='end'] + * @returns {Promise} + */ + async scrollIntoView({ behavior='auto', block='end' }={}) { + this.el.scrollIntoView({ + behavior, + block, + inline: 'nearest', + }); + if (behavior === 'smooth') { + return new Promise(resolve => setTimeout(resolve, 500)); + } else { + return Promise.resolve(); + } + } + + /** + * Get the shorttime format of the message date. + * + * @returns {string} + */ + get shortTime() { + return this.message.date.format('hh:mm'); + } + + /** + * @returns {mail.thread_viewer} + */ + get threadViewer() { + return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId); + } + + /** + * @returns {Object} + */ + get trackingValues() { + return this.message.tracking_value_ids.map(trackingValue => { + const value = Object.assign({}, trackingValue); + value.changed_field = _.str.sprintf(this.env._t("%s:"), value.changed_field); + if (value.field_type === 'datetime') { + if (value.old_value) { + value.old_value = + moment.utc(value.old_value).local().format('LLL'); + } + if (value.new_value) { + value.new_value = + moment.utc(value.new_value).local().format('LLL'); + } + } else if (value.field_type === 'date') { + if (value.old_value) { + value.old_value = + moment(value.old_value).local().format('LL'); + } + if (value.new_value) { + value.new_value = + moment(value.new_value).local().format('LL'); + } + } + return value; + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Modifies the message to add the 'read more/read less' functionality + * All element nodes with 'data-o-mail-quote' attribute are concerned. + * All text nodes after a ``#stopSpelling`` element are concerned. + * Those text nodes need to be wrapped in a span (toggle functionality). + * All consecutive elements are joined in one 'read more/read less'. + * + * @private + * @param {jQuery} $element + */ + _insertReadMoreLess($element) { + const groups = []; + let readMoreNodes; + + // nodeType 1: element_node + // nodeType 3: text_node + const $children = $element.contents() + .filter((index, content) => + content.nodeType === 1 || (content.nodeType === 3 && content.nodeValue.trim()) + ); + + for (const child of $children) { + let $child = $(child); + + // Hide Text nodes if "stopSpelling" + if ( + child.nodeType === 3 && + $child.prevAll('[id*="stopSpelling"]').length > 0 + ) { + // Convert Text nodes to Element nodes + $child = $('<span>', { + text: child.textContent, + 'data-o-mail-quote': '1', + }); + child.parentNode.replaceChild($child[0], child); + } + + // Create array for each 'read more' with nodes to toggle + if ( + $child.attr('data-o-mail-quote') || + ( + $child.get(0).nodeName === 'BR' && + $child.prev('[data-o-mail-quote="1"]').length > 0 + ) + ) { + if (!readMoreNodes) { + readMoreNodes = []; + groups.push(readMoreNodes); + } + $child.hide(); + readMoreNodes.push($child); + } else { + readMoreNodes = undefined; + this._insertReadMoreLess($child); + } + } + + for (const group of groups) { + // Insert link just before the first node + const $readMoreLess = $('<a>', { + class: 'o_Message_readMore', + href: '#', + text: READ_MORE, + }).insertBefore(group[0]); + + // Toggle All next nodes + let isReadMore = true; + $readMoreLess.click(e => { + e.preventDefault(); + isReadMore = !isReadMore; + for (const $child of group) { + $child.hide(); + $child.toggle(!isReadMore); + } + $readMoreLess.text(isReadMore ? READ_MORE : READ_LESS); + }); + } + } + + /** + * @private + */ + _update() { + if (!this.state.timeElapsed) { + this.state.timeElapsed = timeFromNow(this.message.date); + } + clearInterval(this._intervalId); + this._intervalId = setInterval(() => { + this.state.timeElapsed = timeFromNow(this.message.date); + }, 60 * 1000); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChangeCheckbox() { + this.message.toggleCheck(this.threadViewer.thread, this.threadViewer.stringifiedDomain); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (ev.target.closest('.o_mention')) { + this.env.messaging.redirect({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + ev.preventDefault(); + return; + } + if (ev.target.closest('.o_mail_redirect')) { + this.env.messaging.redirect({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + ev.preventDefault(); + return; + } + ev.stopPropagation(); + this.state.isClicked = !this.state.isClicked; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAuthor(ev) { + if (!this.hasAuthorRedirect) { + return; + } + if (!this.message.author) { + return; + } + this.env.messaging.redirect({ + id: this.message.author.id, + model: this.message.author.model, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFailure(ev) { + this.message.openResendAction(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAccept(ev) { + ev.preventDefault(); + this.message.moderate('accept'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAllow(ev) { + ev.preventDefault(); + this.message.moderate('allow'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationBan(ev) { + ev.preventDefault(); + this.state.hasModerationBanDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationDiscard(ev) { + ev.preventDefault(); + this.state.hasModerationDiscardDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationReject(ev) { + ev.preventDefault(); + this.state.hasModerationRejectDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickOriginThread(ev) { + ev.preventDefault(); + this.env.messaging.redirect({ + id: this.message.originThread.id, + model: this.message.originThread.model, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickStar(ev) { + ev.stopPropagation(); + this.message.toggleStar(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + ev.stopPropagation(); + this.message.markAsRead(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickReply(ev) { + ev.stopPropagation(); + this.message.replyTo(); + } + + /** + * @private + */ + _onDialogClosedModerationBan() { + this.state.hasModerationBanDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationDiscard() { + this.state.hasModerationDiscardDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationReject() { + this.state.hasModerationRejectDialog = false; + } + +} + +Object.assign(Message, { + components, + defaultProps: { + hasAuthorRedirect: false, + hasCheckbox: false, + hasMarkAsReadIcon: false, + hasReplyIcon: false, + isSelected: false, + isSquashed: false, + }, + props: { + attachmentsDetailsMode: { + type: String, + optional: true, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + hasAuthorRedirect: Boolean, + hasCheckbox: Boolean, + hasMarkAsReadIcon: Boolean, + hasReplyIcon: Boolean, + isSelected: Boolean, + isSquashed: Boolean, + messageLocalId: String, + threadViewerLocalId: { + type: String, + optional: true, + }, + }, + template: 'mail.Message', +}); + +return Message; + +}); diff --git a/addons/mail/static/src/components/message/message.scss b/addons/mail/static/src/components/message/message.scss new file mode 100644 index 00000000000..fa7b7474558 --- /dev/null +++ b/addons/mail/static/src/components/message/message.scss @@ -0,0 +1,354 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Message { + display: flex; + flex: 0 0 auto; + padding: map-get($spacers, 2); +} + +.o_Message_authorAvatar { + height: 100%; + width: 100%; + object-fit: cover; +} + +.o_Message_authorAvatarContainer { + position: relative; + height: 36px; + width: 36px; +} + +.o_Message_authorName { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_checkbox { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_commandStar { + font-size: 1.3em; +} + +.o_Message_Composer { + flex: 1 1 auto; +} + +.o_Message_commands { + display: flex; + align-items: center; +} + +.o_Message_content { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + word-wrap: break-word; + word-break: break-word; + + > p:last-of-type { + margin-bottom: 0; + } +} + +.o_Message_core { + margin-inline-end: map-get($spacers, 3); + overflow: auto; +} + +.o_Message_footer { + display: flex; + flex-direction: column; +} + +.o_Message_header { + display: flex; + flex-flow: row wrap; + align-items: baseline; +} + +.o_Message_headerCommands { + margin-inline-end: map-get($spacers, 2); + align-self: center; + + .o_Message_headerCommand { + padding-left: map-get($spacers, 2); + padding-right: map-get($spacers, 2); + + &.o-mobile { + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); + + &:first-child { + padding-left: map-get($spacers, 2); + } + + &:last-child { + padding-right: map-get($spacers, 2); + } + } + } +} + +.o_Message_headerDate { + margin-inline-end: map-get($spacers, 2); + font-size: 0.8em; +} + +.o_Message_moderationAction { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationPending { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationSubHeader { + display: flex; + flex-flow: row wrap; + align-items: center; +} + +.o_Message_originThread { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_partnerImStatusIcon { + @include o-position-absolute($bottom: 0, $right: 0); + display: flex; + align-items: center; + justify-content: center; +} + +.o_Message_readMore { + display: block; +} + +.o_Message_sidebar { + flex: 0 0 50px; + max-width: 50px; + display: flex; + margin-inline-end: map-get($spacers, 2); + justify-content: center; + + &.o-message-squashed { + align-items: flex-start; + } +} + +.o_Message_sidebarItem { + margin-left: map-get($spacers, 1); + margin-right: map-get($spacers, 1); + + &.o-message-squashed { + display: flex; + } +} + +.o_Message_trackingValues { + margin-top: map-get($spacers, 3); +} + +.o_Message_trackingValue { + display: flex; + align-items: center; +} + +.o_Message_trackingValueItem { + margin-inline-end: map-get($spacers, 1); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Message { + background-color: white; + + &:hover, &.o-clicked { + + .o_Message_command { + display: flex; + } + + .o_Message_commands { + display: flex; + } + + .o_Message_sidebarItem.o-message-squashed { + display: flex; + } + + .o_Message_seenIndicator.o-message-squashed { + display: none; + } + } + + .o_Message_partnerImStatusIcon { + color: white; + } + + &.o-not-discussion { + background-color: lighten(gray('300'), 5%); + border-bottom: 1px solid darken(gray('300'), 5%); + + .o_Message_partnerImStatusIcon { + color: lighten(gray('300'), 5%); + } + + &.o-selected { + border-bottom: 1px solid darken(gray('400'), 5%); + } + } + + &.o-selected { + background-color: gray('400'); + + .o_Message_partnerImStatusIcon { + color: gray('400'); + } + } + + &.o-starred { + + .o_Message_commandStar { + display: flex; + } + + .o_Message_commands { + display: flex; + } + } +} + +.o_Message_authorName { + font-weight: bold; +} + +.o_Message_authorRedirect { + cursor: pointer; +} + +.o_Message_command { + cursor: pointer; + color: gray('400'); + + &:not(.o-mobile) { + display: none; + + &:hover { + filter: brightness(0.8); + } + } + + &.o-mobile { + filter: brightness(0.8); + + &:hover { + filter: brightness(0.75); + } + } + + &.o-message-selected { + color: gray('500'); + } +} + +.o_Message_commandStar { + + &.o-message-starred { + color: gold; + + &:hover { + filter: brightness(0.9); + } + } +} + +.o_Message_content .o_mention { + color: $o-brand-primary; + cursor: pointer; + + &:hover { + color: darken($o-brand-primary, 15%); + } +} + +.o_Message_date { + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_headerCommands:not(.o-mobile) { + display: none; +} + +.o_Message_originThread { + font-size: 0.8em; + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_originThreadLink { + font-size: 1.25em; // original size +} + +.o_Message_partnerImStatusIcon:not(.o_Message_partnerImStatusIcon-mobile) { + font-size: x-small; +} + +.o_Message_moderationAction { + font-weight: bold; + font-style: italic; + + &.o-accept, + &.o-allow { + color: $o-mail-moderation-accept-color; + @include hover-focus { + color: darken($o-mail-moderation-accept-color, $emphasized-link-hover-darken-percentage); + } + } + + &.o-ban, + &.o-discard, + &.o-reject { + color: $o-mail-moderation-reject-color; + @include hover-focus { + color: darken($o-mail-moderation-reject-color, $emphasized-link-hover-darken-percentage); + } + } +} + +.o_Message_moderationPending { + font-style: italic; + + &.o-author { + color: theme-color('danger'); + font-weight: bold; + } +} + +.o_Message_notificationIconContainer { + color: gray('600'); + cursor: pointer; + + &.o-error { + color: $red; + } +} + +.o_Message_sidebarCommands { + display: none; +} + +.o_Message_sidebarItem.o-message-squashed { + display: none; +} diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml new file mode 100644 index 00000000000..b55e7a5ccf6 --- /dev/null +++ b/addons/mail/static/src/components/message/message.xml @@ -0,0 +1,220 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Message" owl="1"> + <div class="o_Message" + t-att-class="{ + 'o-clicked': state.isClicked, + 'o-discussion': message and (message.is_discussion or message.is_notification), + 'o-mobile': env.messaging.device.isMobile, + 'o-not-discussion': message and !(message.is_discussion or message.is_notification), + 'o-notification': message and message.message_type === 'notification', + 'o-selected': props.isSelected, + 'o-squashed': props.isSquashed, + 'o-starred': message and isStarred, + }" t-on-click="_onClick" t-att-data-message-local-id="message and message.localId" + > + <t t-if="message" name="rootCondition"> + <div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }"> + <t t-if="!props.isSquashed"> + <div class="o_Message_authorAvatarContainer o_Message_sidebarItem"> + <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorRedirect, o_redirect: hasAuthorRedirect }" t-att-src="avatar" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''" alt=""/> + <t t-if="message.author and message.author.im_status"> + <PartnerImStatusIcon + class="o_Message_partnerImStatusIcon" + t-att-class="{ + 'o-message-not-discussion': !(message.is_discussion or message.is_notification), + 'o-message-selected': props.isSelected, + 'o_Message_partnerImStatusIcon-mobile': env.messaging.device.isMobile, + }" + partnerLocalId="message.author.localId" + /> + </t> + </div> + </t> + <t t-else=""> + <div class="o_Message_date o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-esc="shortTime"/> + </div> + <div class="o_Message_commands o_Message_sidebarCommands o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected, 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="message.message_type !== 'notification'"> + <div class="o_Message_command o_Message_commandStar fa" + t-att-class="{ + 'fa-star': isStarred, + 'fa-star-o': !isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" + /> + </t> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread"> + <MessageSeenIndicator class="o_Message_seenIndicator o-message-squashed" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/> + </t> + </t> + </div> + <div class="o_Message_core"> + <t t-if="!props.isSquashed"> + <div class="o_Message_header"> + <t t-if="message.author"> + <div class="o_Message_authorName" t-att-class="hasAuthorRedirect ? 'o_Message_authorRedirect o_redirect' : ''" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''"> + <t t-esc="message.author.nameOrDisplayName"/> + </div> + </t> + <t t-elif="message.email_from"> + <a class="o_Message_authorName" t-attf-href="mailto:{{ message.email_from }}?subject=Re: {{ message.subject ? message.subject : '' }}"> + <t t-esc="message.email_from"/> + </a> + </t> + <t t-else=""> + <div class="o_Message_authorName"> + Anonymous + </div> + </t> + <div class="o_Message_date o_Message_headerDate" t-att-class="{ 'o-message-selected': props.isSelected }" t-att-title="datetime"> + - <t t-esc="state.timeElapsed"/> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread"> + <MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/> + </t> + <t t-if="hasDifferentOriginThread"> + <div class="o_Message_originThread" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-if="message.originThread.model === 'mail.channel'"> + (from <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread">#<t t-esc="message.originThread.name"/></a>) + </t> + <t t-else=""> + on <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread"><t t-esc="message.originThread.name"/></a> + </t> + </div> + </t> + <t t-if="message.moderation_status === 'pending_moderation' and !message.isModeratedByCurrentPartner"> + <span class="o_Message_moderationPending o-author" title="Your message is pending moderation.">Pending moderation</span> + </t> + <t t-if="message.notifications.length > 0"> + <t t-if="message.failureNotifications.length > 0"> + <span class="o_Message_notificationIconContainer o-error" t-on-click="_onClickFailure"> + <i name="failureIcon" class="o_Message_notificationIcon fa fa-envelope"/> + </span> + </t> + <t t-else=""> + <Popover> + <span class="o_Message_notificationIconContainer"> + <i name="notificationIcon" class="o_Message_notificationIcon fa fa-envelope-o"/> + </span> + <t t-set="opened"> + <NotificationPopover + notificationLocalIds="message.notifications.map(notification => notification.localId)" + /> + </t> + </Popover> + </t> + </t> + <div class="o_Message_commands o_Message_headerCommands" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!message.isTemporary and ((message.message_type !== 'notification' and message.originThread and message.originThread.model === 'mail.channel') or !message.isTransient) and message.moderation_status !== 'pending_moderation'"> + <span class="o_Message_command o_Message_commandStar o_Message_headerCommand fa" + t-att-class="{ + 'fa-star': isStarred, + 'fa-star-o': !isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" title="Mark as Todo" + /> + </t> + <t t-if="props.hasReplyIcon"> + <span class="o_Message_command o_Message_commandReply o_Message_headerCommand fa fa-reply" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickReply" title="Reply" + /> + </t> + <t t-if="props.hasMarkAsReadIcon"> + <span class="o_Message_command o_Message_commandMarkAsRead o_Message_headerCommand fa fa-check" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickMarkAsRead" title="Mark as Read" + /> + </t> + </div> + </div> + <t t-if="message.isModeratedByCurrentPartner"> + <div class="o_Message_moderationSubHeader"> + <t t-if="threadViewer and props.hasCheckbox and message.hasCheckbox"> + <input class="o_Message_checkbox" type="checkbox" t-att-checked="message.isChecked(threadViewer.thread, threadViewer.stringifiedDomain) ? 'checked': ''" t-on-change="_onChangeCheckbox" t-ref="checkbox"/> + </t> + <span class="o_Message_moderationPending">Pending moderation:</span> + <a class="o_Message_moderationAction o-accept" href="#" title="Accept" t-on-click="_onClickModerationAccept">Accept</a> + <a class="o_Message_moderationAction o-reject" href="#" title="Remove message with explanation" t-on-click="_onClickModerationReject">Reject</a> + <a class="o_Message_moderationAction o-discard" href="#" title="Remove message without explanation" t-on-click="_onClickModerationDiscard">Discard</a> + <a class="o_Message_moderationAction o-allow" href="#" title="Add this email address to white list of people" t-on-click="_onClickModerationAllow">Always Allow</a> + <a class="o_Message_moderationAction o-ban" href="#" title="Ban this email address" t-on-click="_onClickModerationBan">Ban</a> + </div> + </t> + </t> + <div class="o_Message_content" t-ref="content"> + <t t-raw="message.prettyBody"/> + <t t-if="message.subtype_description"> + <p t-esc="message.subtype_description"/> + </t> + <t t-if="trackingValues.length > 0"> + <ul class="o_Message_trackingValues"> + <t t-foreach="trackingValues" t-as="value" t-key="value.id"> + <li> + <div class="o_Message_trackingValue"> + <div class="o_Message_trackingValueItem" t-esc="value.changed_field"/> + <t t-if="value.old_value"> + <div class="o_Message_trackingValueItem" t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/> + <t t-if="value.old_value !== value.new_value"> + <div class="o_Message_trackingValueItem fa fa-long-arrow-right" title="Changed" role="img"/> + </t> + </t> + <t t-if="value.old_value !== value.new_value"> + <div class="o_Message_trackingValueItem" t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/> + </t> + </div> + </li> + </t> + </ul> + </t> + </div> + <t t-if="message.attachments and message.attachments.length > 0"> + <div class="o_Message_footer"> + <!-- TODO XDU This does not allow to preview all attachments at once, find somethind else --> + <t t-if="imageAttachments and imageAttachments.length > 0"> + <AttachmentList + class="o_Message_attachmentList" + areAttachmentsDownloadable="true" + areAttachmentsEditable="true" + attachmentLocalIds="imageAttachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="props.attachmentsDetailsMode" + /> + </t> + <t t-if="nonImageAttachments and nonImageAttachments.length > 0"> + <AttachmentList + class="o_Message_attachmentList" + areAttachmentsDownloadable="true" + areAttachmentsEditable="true" + attachmentLocalIds="nonImageAttachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="props.attachmentsDetailsMode" + /> + </t> + </div> + </t> + </div> + <t t-if="state.hasModerationBanDialog"> + <ModerationBanDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationBan"/> + </t> + <t t-if="state.hasModerationDiscardDialog"> + <ModerationDiscardDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationDiscard"/> + </t> + <t t-if="state.hasModerationRejectDialog"> + <ModerationRejectDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationReject"/> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message/message_tests.js b/addons/mail/static/src/components/message/message_tests.js new file mode 100644 index 00000000000..b09d0a181bd --- /dev/null +++ b/addons/mail/static/src/components/message/message_tests.js @@ -0,0 +1,692 @@ +odoo.define('mail/static/src/components/message/message_tests.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message', {}, function () { +QUnit.module('message_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createMessageComponent = async (message, otherProps) => { + const MessageComponent = components.Message; + MessageComponent.env = this.env; + this.component = new MessageComponent(null, Object.assign({ + messageLocalId: message.localId, + }, otherProps)); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.Message.env; + }, +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(12); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a message component" + ); + const messageEl = document.querySelector('.o_Message'); + assert.strictEqual( + messageEl.dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "message component should be linked to message store model" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar`).length, + 1, + "message should have a sidebar" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar .o_Message_authorAvatar`).length, + 1, + "message should have author avatar in the sidebar" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).tagName, + 'IMG', + "message author avatar should be an image" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).dataset.src, + '/web/image/res.partner/7/image_128', + "message author avatar should GET image of the related partner" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_authorName`).length, + 1, + "message should display author name" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorName`).textContent, + "Demo User", + "message should display correct author name" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_date`).length, + 1, + "message should display date" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_commands`).length, + 1, + "message should display list of commands" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "message should display the content" + ); + assert.strictEqual(messageEl.querySelector(`:scope .o_Message_content`).innerHTML, + "<p>Test</p>", + "message should display the correct content" + ); +}); + +QUnit.test('delete attachment linked to message', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "BLAH.jpg", + id: 10, + name: "BLAH", + }]], + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click()); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 10)); +}); + +QUnit.test('moderation: moderated channel with pending moderation message (author)', async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 1, display_name: "Admin" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message); + + assert.strictEqual( + document.querySelectorAll(`.o_Message_moderationPending.o-author`).length, + 1, + "should have the message pending moderation" + ); +}); + +QUnit.test('moderation: moderated channel with pending moderation message (moderator)', async function (assert) { + assert.expect(9); + + Object.assign(this.data.initMessaging, { + moderation_channel_ids: [20], + }); + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message); + const messageEl = document.querySelector('.o_Message'); + assert.ok(messageEl, "should display a message"); + assert.containsOnce(messageEl, `.o_Message_moderationSubHeader`, + "should have the message pending moderation" + ); + assert.containsNone(messageEl, `.o_Message_checkbox`, + "should not have the moderation checkbox by default" + ); + assert.containsN(messageEl, '.o_Message_moderationAction', 5, + "there should be 5 contextual moderation decisions next to the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-accept', + "there should be a contextual moderation decision to accept the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-reject', + "there should be a contextual moderation decision to reject the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-discard', + "there should be a contextual moderation decision to discard the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-allow', + "there should be a contextual moderation decision to allow the user of the message)" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-ban', + "there should be a contextual moderation decision to ban the user of the message" + ); + // The actions are tested as part of discuss tests. +}); + +QUnit.test('Notification Sent', async function (assert) { + assert.expect(9); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'sent', + notification_type: 'email', + partner: [['insert', { id: 12, name: "Someone" }]], + }]], + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconContainer', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope-o', + "icon should represent email success" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconContainer').click(); + }); + assert.containsOnce( + document.body, + '.o_NotificationPopover', + "notification popover should be open" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationIcon', + "popover should have one icon" + ); + assert.hasClass( + document.querySelector('.o_NotificationPopover_notificationIcon'), + 'fa-check', + "popover should have the sent icon" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationPartnerName', + "popover should have the partner name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(), + "Someone", + "partner name should be correct" + ); +}); + +QUnit.test('Notification Error', async function (assert) { + assert.expect(8); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'mail.mail_resend_message_action', + "action should be the one to resend email" + ); + assert.strictEqual( + payload.options.additional_context.mail_message_to_resend, + 10, + "action should have correct message id" + ); + }); + + await this.start({ env: { bus } }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'email', + }]], + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconContainer', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope', + "icon should represent email error" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconContainer').click(); + }); + assert.verifySteps( + ['do_action'], + "should do an action to display the resend email dialog" + ); +}); + +QUnit.test("'channel_fetch' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); +}); + +QUnit.test("'channel_seen' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel seen notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is seen" + ); +}); + +QUnit.test("'channel_fetch' notification then 'channel_seen' received are correctly handled", async function (assert) { + assert.expect(4); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + let notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); + + // Simulate received channel seen notification + notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is now seen" + ); +}); + +QUnit.test('do not show messaging seen indicator if not authored by me', async function (assert) { + assert.expect(2); + + await this.start(); + const author = this.env.models['mail.partner'].create({ + id: 100, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + partnerSeenInfos: [['create', [ + { + lastFetchedMessage: [['insert', {id: 100}]], + partner: [['insert', {id: this.env.session.partner_id}]], + }, + { + lastFetchedMessage: [['insert', {id: 100}]], + partner: [['link', author]], + }, + ]]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', author]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_Message_seenIndicator', + "message component should not have any message seen indicator" + ); +}); + +QUnit.test('do not show messaging seen indicator if before last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(99, 11), + message: [['insert', {id: 99}]], + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const lastSeenMessage = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>You already saw me</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 99, + threadCaches: [['link', thread.mainCache]], + }); + thread.update({ + partnerSeenInfos: [['create', [ + { + lastSeenMessage: [['link', lastSeenMessage]], + partner: [['link', currentPartner]], + }, + { + lastSeenMessage: [['link', lastSeenMessage]], + partner: [['insert', {id: 100}]], + }, + ]]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V)" + ); +}); + +QUnit.test('only show messaging seen indicator if authored by me, after last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + partnerSeenInfos: [['create', [ + { + lastSeenMessage: [['insert', {id: 100}]], + partner: [['link', currentPartner]], + }, + { + partner: [['insert', {id: 100}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 99}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 11), + message: [['insert', {id: 100}]], + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message component should have two checks (V)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js new file mode 100644 index 00000000000..07fb1d054f0 --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js @@ -0,0 +1,65 @@ +odoo.define('mail/static/src/components/message_author_prefix/message_author_prefix.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MessageAuthorPrefix extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const author = message ? message.author : undefined; + const thread = props.threadLocalId + ? this.env.models['mail.thread'].get(props.threadLocalId) + : undefined; + return { + author: author ? author.__state : undefined, + currentPartner: this.env.messaging.currentPartner + ? this.env.messaging.currentPartner.__state + : undefined, + message: message ? message.__state : undefined, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(MessageAuthorPrefix, { + props: { + messageLocalId: String, + threadLocalId: { + type: String, + optional: true, + }, + }, + template: 'mail.MessageAuthorPrefix', +}); + +return MessageAuthorPrefix; + +}); diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss new file mode 100644 index 00000000000..362eaeb5b92 --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss @@ -0,0 +1,11 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageAuthorPrefixIcon { + margin-right: 3px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml new file mode 100644 index 00000000000..de8992e4b8f --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageAuthorPrefix" owl="1"> + <span class="o_MessageAuthorPrefix"> + <t t-if="message"> + <t t-if="message.author and message.author === env.messaging.currentPartner"> + <i class="o_MessageAuthorPrefixIcon fa fa-mail-reply"/>You: + </t> + <t t-elif="thread and thread.channel_type === 'channel'"> + <t t-esc="message.author.nameOrDisplayName"/>: + </t> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js new file mode 100644 index 00000000000..c948689c6d1 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.js @@ -0,0 +1,531 @@ +odoo.define('mail/static/src/components/message_list/message_list.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class MessageList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId); + const thread = threadViewer ? threadViewer.thread : undefined; + const threadCache = threadViewer ? threadViewer.threadCache : undefined; + return { + isDeviceMobile: this.env.messaging.device.isMobile, + messages: threadCache + ? threadCache.orderedMessages.map(message => message.__state) + : [], + thread: thread ? thread.__state : undefined, + threadCache: threadCache ? threadCache.__state : undefined, + threadViewer: threadViewer ? threadViewer.__state : undefined, + }; + }, { + compareDepth: { + messages: 1, + }, + }); + this._getRefs = useRefs(); + /** + * Determine whether the auto-scroll on load is active or not. This + * is useful to disable some times, such as when mounting message list + * in ASC order: the initial scroll position is at the top of the + * conversation, and most of the time the expected initial scroll + * position should be at the bottom of the thread. During this time, + * the programmatical scrolling should not trigger auto-load messages + * on scroll. + */ + this._isAutoLoadOnScrollActive = true; + /** + * Reference of the "load more" item. Useful to trigger load more + * on scroll when it becomes visible. + */ + this._loadMoreRef = useRef('loadMore'); + /** + * Tracked last thread cache rendered. Useful to determine scroll + * position on patch if it is on the same thread cache or not. + */ + this._renderedThreadCache = null; + /** + * Tracked last selected message. Useful to determine when patch comes + * from a message selection on a given thread cache, so that it + * auto-scroll to that message. + */ + this._selectedMessage = null; + /** + * Snapshot computed during willPatch, which is used by patched. + */ + this._willPatchSnapshot = undefined; + this._onScroll = _.throttle(this._onScroll.bind(this), 100); + } + + mounted() { + this._update(); + } + + willPatch() { + const lastMessageRef = this.lastMessageRef; + this._willPatchSnapshot = { + isLastMessageVisible: + lastMessageRef && + lastMessageRef.isBottomVisible({ offset: 10 }), + scrollHeight: this.el.scrollHeight, + scrollTop: this.el.scrollTop, + }; + } + + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Update the scroll position of the message list. + * This is not done in patched/mounted hooks because scroll position is + * dependent on UI globally. To illustrate, imagine following UI: + * + * +----------+ < viewport top = scrollable top + * | message | + * | list | + * | | + * +----------+ < scrolltop = viewport bottom = scrollable bottom + * + * Now if a composer is mounted just below the message list, it is shrinked + * and scrolltop is altered as a result: + * + * +----------+ < viewport top = scrollable top + * | message | + * | list | < scrolltop = viewport bottom <-+ + * | | |-- dist = composer height + * +----------+ < scrollable bottom <-+ + * +----------+ + * | composer | + * +----------+ + * + * Because of this, the scroll position must be changed when whole UI + * is rendered. To make this simpler, this is done when <ThreadViewer/> + * component is patched. This is acceptable when <ThreadViewer/> has a + * fixed height, which is the case for the moment. + */ + async adjustFromComponentHints() { + for (const hint of this.threadViewer.componentHintList) { + switch (hint.type) { + case 'change-of-thread-cache': + this._adjustFromChangeOfThreadCache(hint); + break; + case 'current-partner-just-posted-message': + this._adjustFromCurrentPartnerJustPostedMessage(hint); + break; + case 'more-messages-loaded': + this._adjustFromMoreMessagesLoaded(hint); + break; + default: + this.threadViewer.markComponentHintProcessed(hint); + break; + } + } + this._willPatchSnapshot = undefined; + } + + /** + * @param {mail.message} message + * @returns {string} + */ + getDateDay(message) { + const date = message.date.format('YYYY-MM-DD'); + if (date === moment().format('YYYY-MM-DD')) { + return this.env._t("Today"); + } else if ( + date === moment() + .subtract(1, 'days') + .format('YYYY-MM-DD') + ) { + return this.env._t("Yesterday"); + } + return message.date.format('LL'); + } + + /** + * @returns {integer} + */ + getScrollTop() { + return this.el.scrollTop; + } + + /** + * @returns {mail/static/src/components/message/message.js|undefined} + */ + get mostRecentMessageRef() { + if (this.props.order === 'desc') { + return this.messageRefs[0]; + } + const { length: l, [l - 1]: mostRecentMessageRef } = this.messageRefs; + return mostRecentMessageRef; + } + + /** + * @param {integer} messageId + * @returns {mail/static/src/components/message/message.js|undefined} + */ + messageRefFromId(messageId) { + return this.messageRefs.find(ref => ref.message.id === messageId); + } + + /** + * Get list of sub-components Message, ordered based on prop `order` + * (ASC/DESC). + * + * The asynchronous nature of OWL rendering pipeline may reveal disparity + * between knowledgeable state of store between components. Use this getter + * with extreme caution! + * + * Let's illustrate the disparity with a small example: + * + * - Suppose this component is aware of ordered (record) messages with + * following IDs: [1, 2, 3, 4, 5], and each (sub-component) messages map + * each of these records. + * - Now let's assume a change in store that translate to ordered (record) + * messages with following IDs: [2, 3, 4, 5, 6]. + * - Because store changes trigger component re-rendering by their "depth" + * (i.e. from parents to children), this component may be aware of + * [2, 3, 4, 5, 6] but not yet sub-components, so that some (component) + * messages should be destroyed but aren't yet (the ref with message ID 1) + * and some do not exist yet (no ref with message ID 6). + * + * @returns {mail/static/src/components/message/message.js[]} + */ + get messageRefs() { + const refs = this._getRefs(); + const ascOrderedMessageRefs = Object.entries(refs) + .filter(([refId, ref]) => ( + // Message refs have message local id as ref id, and message + // local ids contain name of model 'mail.message'. + refId.includes(this.env.models['mail.message'].modelName) && + // Component that should be destroyed but haven't just yet. + ref.message + ) + ) + .map(([refId, ref]) => ref) + .sort((ref1, ref2) => (ref1.message.id < ref2.message.id ? -1 : 1)); + if (this.props.order === 'desc') { + return ascOrderedMessageRefs.reverse(); + } + return ascOrderedMessageRefs; + } + + /** + * @returns {mail.message[]} + */ + get orderedMessages() { + const threadCache = this.threadViewer.threadCache; + if (this.props.order === 'desc') { + return [...threadCache.orderedMessages].reverse(); + } + return threadCache.orderedMessages; + } + + /** + * @param {integer} value + */ + async setScrollTop(value) { + this._isAutoLoadOnScrollActive = false; + this.el.scrollTop = value; + await new Promise(resolve => setTimeout(resolve, 0)); + this._isAutoLoadOnScrollActive = true; + } + + /** + * @param {mail.message} prevMessage + * @param {mail.message} message + * @returns {boolean} + */ + shouldMessageBeSquashed(prevMessage, message) { + if (!this.props.hasSquashCloseMessages) { + return false; + } + if (Math.abs(message.date.diff(prevMessage.date)) > 60000) { + // more than 1 min. elasped + return false; + } + if (prevMessage.message_type !== 'comment' || message.message_type !== 'comment') { + return false; + } + if (prevMessage.author !== message.author) { + // from a different author + return false; + } + if (prevMessage.originThread !== message.originThread) { + return false; + } + if ( + prevMessage.moderation_status === 'pending_moderation' || + message.moderation_status === 'pending_moderation' + ) { + return false; + } + if ( + prevMessage.notifications.length > 0 || + message.notifications.length > 0 + ) { + // visual about notifications is restricted to non-squashed messages + return false; + } + const prevOriginThread = prevMessage.originThread; + const originThread = message.originThread; + if ( + prevOriginThread && + originThread && + prevOriginThread.model === originThread.model && + originThread.model !== 'mail.channel' && + prevOriginThread.id !== originThread.id + ) { + // messages linked to different document thread + return false; + } + return true; + } + + /** + * @returns {mail.thread_viewer} + */ + get threadViewer() { + return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} hint + */ + async _adjustFromChangeOfThreadCache(hint) { + const threadCache = this.threadViewer.threadCache; + if (!threadCache.isLoaded) { + return; + } + let isProcessed = false; + if (threadCache.messages.length > 0) { + if (this.threadViewer.threadCacheInitialPosition !== undefined) { + this.el.scrollTop = this.threadViewer.threadCacheInitialPosition; + isProcessed = true; + } else { + const lastMessage = threadCache.lastMessage; + if (this.messageRefFromId(lastMessage.id)) { + this._scrollToMostRecentMessage(); + isProcessed = true; + } + } + } else { + isProcessed = true; + } + if (isProcessed) { + this.threadViewer.markComponentHintProcessed(hint); + } + } + + /** + * @private + * @param {Object} hint + * @param {integer} hint.messageId + */ + async _adjustFromCurrentPartnerJustPostedMessage(hint) { + const threadCache = this.threadViewer.threadCache; + const { messageId } = hint; + if (threadCache.isLoaded) { + const threadCacheMessageIds = threadCache.messages.map(message => message.id); + if (threadCacheMessageIds.includes(messageId) && this.messageRefFromId(messageId)) { + await this._scrollToMessage(messageId); + this.threadViewer.markComponentHintProcessed(hint); + } + } + } + + /** + * @private + * @param {Object} hint + */ + _adjustFromMoreMessagesLoaded(hint) { + if (!this._willPatchSnapshot) { + this.threadViewer.markComponentHintProcessed(hint); + return; + } + const { scrollHeight, scrollTop } = this._willPatchSnapshot; + if (this.props.order === 'asc') { + this.el.scrollTop = this.el.scrollHeight - scrollHeight + scrollTop; + } + this.threadViewer.markComponentHintProcessed(hint); + } + + /** + * @private + */ + _checkThreadMarkAsRead() { + const thread = this.threadViewer.thread; + const threadCache = this.threadViewer.threadCache; + if (!threadCache) { + return; + } + if (threadCache.messages.length === 0) { + return; + } + if (!this.mostRecentMessageRef) { + return; + } + if ( + threadCache === thread.mainCache && + this.mostRecentMessageRef.isPartiallyVisible() + ) { + thread.markAsSeen(); + } + } + + /** + * @private + * @returns {boolean} + */ + _isLoadMoreVisible() { + const loadMore = this._loadMoreRef.el; + if (!loadMore) { + return false; + } + const loadMoreRect = loadMore.getBoundingClientRect(); + const elRect = this.el.getBoundingClientRect(); + const isInvisible = loadMoreRect.top > elRect.bottom || loadMoreRect.bottom < elRect.top; + return !isInvisible; + } + + /** + * @private + */ + _loadMore() { + this.threadViewer.threadCache.loadMoreMessages(); + } + + /** + * @private + * @returns {Promise} + */ + async _scrollToMostRecentMessage() { + if (!this.mostRecentMessageRef) { + return; + } + this._isAutoLoadOnScrollActive = false; + await this.mostRecentMessageRef.scrollIntoView(); + if (!this.el) { + this._isAutoLoadOnScrollActive = true; + return; + } + this.el.scrollTop = this.props.order === 'asc' + ? this.el.scrollTop + 15 + : this.el.scrollTop - 15; + this._isAutoLoadOnScrollActive = true; + } + + /** + * @param {integer} messageId + */ + async _scrollToMessage(messageId) { + const messageRef = this.messageRefFromId(messageId); + if (!messageRef) { + return; + } + this._isAutoLoadOnScrollActive = false; + await messageRef.scrollIntoView(); + if (!this.el) { + this._isAutoLoadOnScrollActive = true; + return; + } + this.el.scrollTop = this.props.order === 'asc' + ? this.el.scrollTop + 15 + : this.el.scrollTop - 15; + this._isAutoLoadOnScrollActive = true; + } + + /** + * @private + */ + _update() { + this._checkThreadMarkAsRead(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickLoadMore(ev) { + ev.preventDefault(); + this._loadMore(); + } + + /** + * @private + * @param {ScrollEvent} ev + */ + _onScroll(ev) { + if (!this.el) { + // could be unmounted in the meantime (due to throttled behavior) + return; + } + this.threadViewer.saveThreadCacheScrollPositionsAsInitial(this.el.scrollTop); + if (!this._isAutoLoadOnScrollActive) { + return; + } + if (this._isLoadMoreVisible()) { + this._loadMore(); + } + this._checkThreadMarkAsRead(); + } + +} + +Object.assign(MessageList, { + components, + defaultProps: { + hasMessageCheckbox: false, + hasSquashCloseMessages: false, + haveMessagesAuthorRedirect: false, + haveMessagesMarkAsReadIcon: false, + haveMessagesReplyIcon: false, + order: 'asc', + }, + props: { + hasMessageCheckbox: Boolean, + hasSquashCloseMessages: Boolean, + haveMessagesAuthorRedirect: Boolean, + haveMessagesMarkAsReadIcon: Boolean, + haveMessagesReplyIcon: Boolean, + order: { + type: String, + validate: prop => ['asc', 'desc'].includes(prop), + }, + selectedMessageLocalId: { + type: String, + optional: true, + }, + threadViewerLocalId: String, + }, + template: 'mail.MessageList', +}); + +return MessageList; + +}); diff --git a/addons/mail/static/src/components/message_list/message_list.scss b/addons/mail/static/src/components/message_list/message_list.scss new file mode 100644 index 00000000000..cb06adda261 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.scss @@ -0,0 +1,135 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageList { + display: flex; + flex-flow: column; + overflow: auto; + + &.o-empty { + align-items: center; + justify-content: center; + } + + &:not(.o-empty) { + padding-bottom: 15px; + } +} + +.o_MessageList_empty { + flex: 1 1 auto; + height: 100%; + width: 100%; + align-self: center; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + padding: 20px; + line-height: 2.5rem; +} + +.o_MessageList_isLoadingMore { + align-self: center; +} + +.o_MessageList_isLoadingMoreIcon { + margin-right: 3px; +} + +.o_MessageList_loadMore { + align-self: center; +} + +.o_MessageList_separator { + display: flex; + align-items: center; + padding: 0 0; + flex: 0 0 auto; +} + +.o_MessageList_separatorDate { + padding: 15px 0; +} + +.o_MessageList_separatorLine { + flex: 1 1 auto; + width: auto; +} + +.o_MessageList_separatorNewMessages { + // bug with safari: container does not auto-grow from child size + padding: 0 0; + margin-right: 15px; +} + +.o_MessageList_separatorLabel { + padding: 0 10px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MessageList { + background-color: white; +} + +.o_MessageList_empty { + text-align: center; +} + +.o_MessageList_emptyTitle { + font-weight: bold; + font-size: 1.3rem; + + &.o-neutral-face-icon:before { + @extend %o-nocontent-init-image; + @include size(120px, 140px); + background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center; + } +} + +.o_MessageList_loadMore { + cursor: pointer; +} + +.o_MessageList_message.o-has-message-selection:not(.o-selected) { + opacity: 0.5; +} + +.o_MessageList_separator { + font-weight: bold; +} + +.o_MessageList_separatorLine { + border-color: gray('400'); +} + +.o_MessageList_separatorLineNewMessages { + border-color: lighten($o-brand-odoo, 15%); +} + +.o_MessageList_separatorNewMessages { + color: lighten($o-brand-odoo, 15%); + +} + +.o_MessageList_separatorLabel { + background-color: white; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_MessageList_separatorNewMessages:not(.o-disable-animation) { + &.fade-leave-active { + transition: opacity 0.5s; + } + + &.fade-leave-to { + opacity: 0; + } +} diff --git a/addons/mail/static/src/components/message_list/message_list.xml b/addons/mail/static/src/components/message_list/message_list.xml new file mode 100644 index 00000000000..4951bcea951 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.xml @@ -0,0 +1,107 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageList" owl="1"> + <div class="o_MessageList" t-att-class="{ 'o-empty': threadViewer and threadViewer.messages.length === 0, 'o-has-message-selection': props.selectedMessageLocalId }" t-on-scroll="_onScroll"> + <t t-if="threadViewer"> + <!-- No result messages --> + <t t-if="threadViewer.messages.length === 0"> + <div class="o_MessageList_empty o_MessageList_item"> + <t t-if="threadViewer.thread === env.messaging.inbox"> + <div class="o_MessageList_emptyTitle"> + Congratulations, your inbox is empty + </div> + New messages appear here. + </t> + <t t-elif="threadViewer.thread === env.messaging.starred"> + <div class="o_MessageList_emptyTitle"> + No starred messages + </div> + You can mark any message as 'starred', and it shows up in this mailbox. + </t> + <t t-elif="threadViewer.thread === env.messaging.history"> + <div class="o_MessageList_emptyTitle o-neutral-face-icon"> + No history messages + </div> + Messages marked as read will appear in the history. + </t> + <t t-elif="threadViewer.thread === env.messaging.moderation"> + <div class="o_MessageList_emptyTitle"> + You have no messages to moderate. + </div> + Messages pending moderation appear here. + </t> + <t t-else=""> + There are no messages in this conversation. + </t> + </div> + </t> + <!-- LOADING (if order asc)--> + <t t-if="props.order === 'asc' and threadViewer.messages.length > 0"> + <t t-call="mail.MessageList.loadMore"/> + </t> + <!-- MESSAGES --> + <t t-set="current_day" t-value="0"/> + <t t-set="prev_message" t-value="0"/> + <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and !threadViewer.thread.seen_message_id"> + <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade"> + <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span> + </div> + </t> + <t t-foreach="orderedMessages" t-as="message" t-key="message.localId"> + <t t-set="message_day" t-value="getDateDay(message)"/> + <t t-if="current_day !== message_day"> + <div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item"> + <hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/> + <t t-set="current_day" t-value="message_day"/> + <t t-set="isMessageSquashed" t-value="false"/> + </div> + </t> + <t t-else=""> + <t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/> + </t> + <Message + class="o_MessageList_item o_MessageList_message" + t-att-class="{ + 'o-has-message-selection': props.selectedMessageLocalId, + }" + hasAuthorRedirect="props.haveMessagesAuthorRedirect" + hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" + hasCheckbox="props.hasMessageCheckbox" + hasReplyIcon="props.haveMessagesReplyIcon" + isSelected="props.selectedMessageLocalId === message.localId" + isSquashed="isMessageSquashed" + messageLocalId="message.localId" + threadViewerLocalId="threadViewer.localId" + t-ref="{{ message.localId }}" + /> + <t t-set="prev_message" t-value="message"/> + <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and threadViewer.thread.seen_message_id === message.id"> + <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade"> + <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span> + </div> + </t> + </t> + <!-- LOADING (if order desc)--> + <t t-if="props.order === 'desc' and threadViewer.messages.length > 0"> + <t t-call="mail.MessageList.loadMore"/> + </t> + </t> + </div> + </t> + + <t t-name="mail.MessageList.loadMore" owl="1"> + <t t-if="threadViewer.threadCache.isLoadingMore"> + <div class="o_MessageList_item o_MessageList_isLoadingMore"> + <i class="o_MessageList_isLoadingMoreIcon fa fa-spin fa-spinner"/> + Loading... + </div> + </t> + <t t-elif="!threadViewer.threadCache.isAllHistoryLoaded"> + <a class="o_MessageList_item o_MessageList_loadMore" href="#" t-on-click="_onClickLoadMore" t-ref="loadMore"> + Load more + </a> + </t> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js new file mode 100644 index 00000000000..8c5239c9e2d --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js @@ -0,0 +1,130 @@ +odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MessageSeenIndicator extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const messageSeenIndicator = this.env.models['mail.message_seen_indicator'].find( + messageSeenIndicator => + messageSeenIndicator.message === message && + messageSeenIndicator.thread === thread + ); + return { + messageSeenIndicator: messageSeenIndicator ? messageSeenIndicator.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get indicatorTitle() { + if (!this.messageSeenIndicator) { + return ''; + } + if (this.messageSeenIndicator.hasEveryoneSeen) { + return this.env._t("Seen by Everyone"); + } + if (this.messageSeenIndicator.hasSomeoneSeen) { + const partnersThatHaveSeen = this.messageSeenIndicator.partnersThatHaveSeen.map( + partner => partner.name + ); + if (partnersThatHaveSeen.length === 1) { + return _.str.sprintf( + this.env._t("Seen by %s"), + partnersThatHaveSeen[0] + ); + } + if (partnersThatHaveSeen.length === 2) { + return _.str.sprintf( + this.env._t("Seen by %s and %s"), + partnersThatHaveSeen[0], + partnersThatHaveSeen[1] + ); + } + return _.str.sprintf( + this.env._t("Seen by %s, %s and more"), + partnersThatHaveSeen[0], + partnersThatHaveSeen[1] + ); + } + if (this.messageSeenIndicator.hasEveryoneFetched) { + return this.env._t("Received by Everyone"); + } + if (this.messageSeenIndicator.hasSomeoneFetched) { + const partnersThatHaveFetched = this.messageSeenIndicator.partnersThatHaveFetched.map( + partner => partner.name + ); + if (partnersThatHaveFetched.length === 1) { + return _.str.sprintf( + this.env._t("Received by %s"), + partnersThatHaveFetched[0] + ); + } + if (partnersThatHaveFetched.length === 2) { + return _.str.sprintf( + this.env._t("Received by %s and %s"), + partnersThatHaveFetched[0], + partnersThatHaveFetched[1] + ); + } + return _.str.sprintf( + this.env._t("Received by %s, %s and more"), + partnersThatHaveFetched[0], + partnersThatHaveFetched[1] + ); + } + return ''; + } + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.message_seen_indicator} + */ + get messageSeenIndicator() { + return this.env.models['mail.message_seen_indicator'].find(messageSeenIndicator => + messageSeenIndicator.message === this.message && + messageSeenIndicator.thread === this.thread + ); + } + + /** + * @returns {mail.Thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } +} + +Object.assign(MessageSeenIndicator, { + props: { + messageLocalId: String, + threadLocalId: String, + }, + template: 'mail.MessageSeenIndicator', +}); + +return MessageSeenIndicator; + +}); diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss new file mode 100644 index 00000000000..3a9d566e399 --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageSeenIndicator { + display: flex; + position: relative; + flex-wrap: nowrap; +} + +.o_MessageSeenIndicator_icon { + + &.o-first { + padding-left: map-get($spacers, 1); + } + + &.o-second { + position: absolute; + top: -1px; + left: -1px; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MessageSeenIndicator { + opacity: 0.6; + + &.o-all-seen { + color: $o-brand-odoo; + } + + &:hover { + cursor: pointer; + opacity: 0.8; + } +} diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml new file mode 100644 index 00000000000..e905afaa392 --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageSeenIndicator" owl="1"> + <span class="o_MessageSeenIndicator" t-att-class="{ 'o-all-seen': messageSeenIndicator and messageSeenIndicator.hasEveryoneSeen }" t-att-title="indicatorTitle"> + <t t-if="messageSeenIndicator and !messageSeenIndicator.isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone"> + <t t-if="messageSeenIndicator.hasSomeoneFetched or messageSeenIndicator.hasSomeoneSeen"> + <i class="o_MessageSeenIndicator_icon o-first fa fa-check"/> + </t> + <t t-if="messageSeenIndicator.hasSomeoneSeen"> + <i class="o_MessageSeenIndicator_icon o-second fa fa-check"/> + </t> + </t> + </span> + </t> +</templates> diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js new file mode 100644 index 00000000000..9184dc0c692 --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js @@ -0,0 +1,293 @@ +odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator_tests', function (require) { +'use strict'; + +const components = { + MessageSendIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message_seen_indicator', {}, function () { +QUnit.module('message_seen_indicator_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createMessageSeenIndicatorComponent = async ({ message, thread }, otherProps) => { + const MessageSeenIndicatorComponent = components.MessageSendIndicator; + MessageSeenIndicatorComponent.env = this.env; + this.component = new MessageSeenIndicatorComponent(null, Object.assign({ + messageLocalId: message.localId, + threadLocalId: thread.localId, + }, otherProps)); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.MessageSendIndicator.env; + }, +}); + +QUnit.test('rendering when just one has received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]] + }, + { + partner: [['create', {id: 100}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "should display only one seen indicator icon" + ); +}); + +QUnit.test('rendering when everyone have received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + lastFetchedMessage: [['insert', {id: 99}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "should display only one seen indicator icon" + ); +}); + +QUnit.test('rendering when just one has seen the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + lastFetchedMessage: [['insert', {id: 99}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +QUnit.test('rendering when just one has seen & received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +QUnit.test('rendering when just everyone has seen the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.hasClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.js b/addons/mail/static/src/components/messaging_menu/messaging_menu.js new file mode 100644 index 00000000000..864c7df97e1 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js @@ -0,0 +1,266 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'), + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class MessagingMenu extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + /** + * global JS generated ID for this component. Useful to provide a + * custom class to autocomplete input, so that click in an autocomplete + * item is not considered as a click away from messaging menu in mobile. + */ + this.id = _.uniqueId('o_messagingMenu_'); + useStore((...args) => this._useStoreSelector(...args)); + + /** + * Reference of the new message input in mobile. Useful to include it + * and autocomplete menu as "inside" the messaging menu, to prevent + * closing the messaging menu otherwise. + */ + this._mobileNewMessageInputRef = useRef('mobileNewMessageInput'); + + // bind since passed as props + this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this); + this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this); + + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {mail.messaging_menu} + */ + get messagingMenu() { + return this.env.messaging.messagingMenu; + } + + /** + * @returns {string} + */ + get mobileNewMessageInputPlaceholder() { + return this.env._t("Search user..."); + } + + /** + * @returns {Object[]} + */ + get tabs() { + return [{ + icon: 'fa fa-envelope', + id: 'all', + label: this.env._t("All"), + }, { + icon: 'fa fa-user', + id: 'chat', + label: this.env._t("Chat"), + }, { + icon: 'fa fa-users', + id: 'channel', + label: this.env._t("Channel"), + }]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _useStoreSelector(props) { + return { + messagingMenu: this.env.messaging.messagingMenu.__state, + isDeviceMobile: this.env.messaging.device.isMobile, + isDiscussOpen: this.env.messaging.discuss.isOpen, + isMessagingInitialized: this.env.messaging.isInitialized, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + // in mobile: keeps the messaging menu open in background + // TODO: maybe need to move this to a mobile component? + // task-2089887 + if ( + this.env.messaging.device.isMobile && + this.env.messaging.chatWindowManager.hasVisibleChatWindows + ) { + return; + } + // closes the menu when clicking outside + if (this.el.contains(ev.target)) { + return; + } + const input = this._mobileNewMessageInputRef.comp; + if (input && input.contains(ev.target)) { + return; + } + this.messagingMenu.close(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDesktopTabButton(ev) { + ev.stopPropagation(); + this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickNewMessage(ev) { + ev.stopPropagation(); + if (!this.env.messaging.device.isMobile) { + this.env.models['mail.thread'].openNewMessage(); + this.messagingMenu.close(); + } else { + this.messagingMenu.toggleMobileNewMessage(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickToggler(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.messagingMenu.toggleOpen(); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideMobileNewMessage(ev) { + ev.stopPropagation(); + this.messagingMenu.toggleMobileNewMessage(); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onMobileNewMessageInputSelect(ev, ui) { + const partnerId = ui.item.id; + const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId); + const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); + if (chat) { + chat.open(); + } else { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + partnerId, + type: 'chat', + }); + } + if (!this.env.messaging.device.isMobile) { + this.messagingMenu.close(); + } + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onMobileNewMessageInputSource(req, res) { + const value = _.escape(req.term); + this.env.models['mail.partner'].imSearch({ + callback: partners => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: value, + limit: 10, + }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.tabId + */ + _onSelectMobileNavbarTab(ev) { + ev.stopPropagation(); + this.messagingMenu.update({ activeTabId: ev.detail.tabId }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.thread} ev.detail.thread + */ + _onSelectThread(ev) { + ev.stopPropagation(); + this.env.models['mail.thread'].get(ev.detail.thread).open(); + if (!this.env.messaging.device.isMobile) { + this.messagingMenu.close(); + } + } + +} + +Object.assign(MessagingMenu, { + components, + props: {}, + template: 'mail.MessagingMenu', +}); + +return MessagingMenu; + +}); diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.scss b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss new file mode 100644 index 00000000000..776d6af5371 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessagingMenu_counter { + position: relative; + transform: translate(-5px, -5px); + margin-right: -10px; // "cancel" right padding of systray items +} + +.o_MessagingMenu_dropdownMenu { + display: flex; + flex-flow: column; + padding-top: 0; + padding-bottom: 0; + overflow-y: auto; + + &.o-messaging-not-initialized { + align-items: center; + justify-content: center; + } + + &:not(.o-mobile) { + flex: 0 1 auto; + width: 350px; + min-height: 50px; + max-height: 400px; + z-index: 1100; // on top of chat windows + } + + &.o-mobile { + flex: 1 1 auto; + position: fixed; + top: $o-mail-chat-window-header-height-mobile; + bottom: 0; + left: 0; + right: 0; + width: 100%; + margin: 0; + max-height: none; + } +} + +.o_MessagingMenu_dropdownMenuHeader { + + &:not(.o-mobile) { + display: flex; + } + + &.o-mobile { + display: grid; + grid-template-areas: + "top" + "bottom"; + grid-template-rows: auto auto; + padding: 5px + } +} + +.o_MessagingMenu_dropdownLoadingIcon { + margin-right: 3px; +} + +.o_MessagingMenu_icon { + font-size: larger +} + +.o_MessagingMenu_loading { + font-size: small; + position: absolute; + bottom: 50%; + right: 0; +} + +.o_MessagingMenu_newMessageButton.o-mobile { + grid-area: top; + justify-self: start; +} + +.o_MessagingMenu_mobileNewMessageInput { + grid-area: bottom; + padding: 8px; + margin-top: 10px +} + +.o_MessagingMenu_notificationList.o-mobile { + flex: 1 1 auto; + overflow-y: auto; +} + + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +// Make hightlight more consistent, due to messaging menu looking quite similar to discuss app in mobile +.o_MessagingMenu.show { + background-color: rgba(black, 0.1); +} + +.o_MessagingMenu_counter { + background-color: $o-brand-primary; +} + +.o_MessagingMenu_dropdownMenu.o-mobile { + border: 0; +} + +.o_MessagingMenu_dropdownMenuHeader { + border-bottom: 1px solid gray('400'); + z-index: 1; +} + +.o_MessagingMenu_mobileNewMessageInput { + appearance: none; + border: 1px solid gray('400'); + border-radius: 5px; + outline: none; +} + +.o_MessagingMenu_tabButton.o-desktop { + + &.o-active { + font-weight: bold; + } + + &:not(:hover) { + + &:not(.o-active) { + color: gray('500'); + } + } +} diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml new file mode 100644 index 00000000000..77c33e5ce7e --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessagingMenu" owl="1"> + <li class="o_MessagingMenu" t-att-class="{ show: messagingMenu.isOpen, 'o-mobile': env.messaging.device.isMobile }"> + <!-- TODO FIXME restore mobile stuff here (activeMobileThread) --> + <t t-if="!env.messaging.device.isMobile or true"> + <a class="o_MessagingMenu_toggler o-no-caret" t-att-class="{ show: messagingMenu.isOpen }" href="#" t-on-click="_onClickToggler" data-toggle="dropdown" data-display="static" title="Conversations" role="button" aria-expanded="false"> + <i class="o_MessagingMenu_icon fa fa-comments-o" role="img" aria-label="Messages"/> + <t t-if="!env.messaging.isInitialized"> + <i class="o_MessagingMenu_loading fa fa-spinner fa-spin"/> + </t> + <t t-elif="messagingMenu.counter > 0"> + <span class="o_MessagingMenu_counter badge badge-pill"> + <t t-esc="messagingMenu.counter"/> + </span> + </t> + </a> + </t> + <!-- TODO FIXME restore mobile stuff here (activeMobileThread) --> + <t t-if="messagingMenu.isOpen and (!env.messaging.device.isMobile or true)"> + <div class="o_MessagingMenu_dropdownMenu dropdown-menu dropdown-menu-right" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-messaging-not-initialized': !env.messaging.isInitialized }" role="menu"> + <t t-if="!env.messaging.isInitialized"> + <span><i class="o_MessagingMenu_dropdownLoadingIcon fa fa-spinner fa-spin"/>Please wait...</span> + </t> + <t t-else=""> + <div class="o_MessagingMenu_dropdownMenuHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!env.messaging.device.isMobile"> + <t t-foreach="['all', 'chat', 'channel']" t-as="tabId" t-key="tabId"> + <button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="_onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId"> + <t t-if="tabId === 'all'">All</t> + <t t-elif="tabId === 'chat'">Chat</t> + <t t-elif="tabId === 'channel'">Channels</t> + </button> + </t> + </t> + <t t-if="env.messaging.device.isMobile"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <div class="o-autogrow"/> + <t t-if="!env.messaging.device.isMobile and !discuss.isOpen"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <t t-if="env.messaging.device.isMobile and messagingMenu.isMobileNewMessageToggled"> + <AutocompleteInput + class="o_MessagingMenu_mobileNewMessageInput" + customClass="id + '_mobileNewMessageInputAutocomplete'" + isFocusOnMount="true" + placeholder="mobileNewMessageInputPlaceholder" + select="_onMobileNewMessageInputSelect" + source="_onMobileNewMessageInputSource" + t-on-o-hide="_onHideMobileNewMessage" + t-ref="mobileNewMessageInput" + /> + </t> + </div> + <NotificationList + class="o_MessagingMenu_notificationList" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + filter="messagingMenu.activeTabId" + t-on-o-select-thread="_onSelectThread" + /> + <t t-if="env.messaging.device.isMobile"> + <MobileMessagingNavbar + class="o_MessagingMenu_mobileNavbar" + activeTabId="messagingMenu.activeTabId" + tabs="tabs" + t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab" + /> + </t> + </t> + </div> + </t> + </li> + </t> + + <t t-name="mail.MessagingMenu.newMessageButton" owl="1"> + <button class="o_MessagingMenu_newMessageButton btn" + t-att-class="{ + 'btn-link': !env.messaging.device.isMobile, + 'btn-secondary': env.messaging.device.isMobile, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickNewMessage" type="button" + > + New message + </button> + </t> + +</templates>
\ No newline at end of file diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js new file mode 100644 index 00000000000..60159360899 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js @@ -0,0 +1,849 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { makeTestPromise } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('messaging_menu', {}, function () { +QUnit.module('messaging_menu_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { discussWidget, env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + hasMessagingMenu: true, + })); + this.discussWidget = discussWidget; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.widget) { + this.widget.destroy(); + } + }, +}); + +QUnit.test('messaging not initialized', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route) { + if (route === '/mail/init_messaging') { + // simulate messaging never initialized + return new Promise(resolve => {}); + } + return this._super(...arguments); + }, + waitUntilMessagingInitialized: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 1, + "should display loading icon on messaging menu when messaging not yet initialized" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent, + "Please wait...", + "should prompt loading when opening messaging menu" + ); +}); + +QUnit.test('messaging becomes initialized', async function (assert) { + assert.expect(2); + + const messagingInitializedProm = makeTestPromise(); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingInitializedProm; + } + return _super(); + }, + waitUntilMessagingInitialized: false, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + + // simulate messaging becomes initialized + await afterNextRender(() => messagingInitializedProm.resolve()); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 0, + "should no longer display loading icon on messaging menu when messaging becomes initialized" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent.includes("Please wait..."), + "should no longer prompt loading when opening messaging menu when messaging becomes initialized" + ); +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(21); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu').length, + 1, + "should have messaging menu" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu').classList.contains('show'), + "should not mark messaging menu item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_toggler`).length, + 1, + "should have clickable element on messaging menu" + ); + assert.notOk( + document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'), + "should not mark messaging menu clickable item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_icon`).length, + 1, + "should have icon on clickable element in messaging menu" + ); + assert.ok( + document.querySelector(`.o_MessagingMenu_icon`).classList.contains('fa-comments-o'), + "should have 'comments' icon on clickable element in messaging menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "should not display any messaging menu dropdown by default" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.ok( + document.querySelector('.o_MessagingMenu').classList.contains('show'), + "should mark messaging menu item as shown" + ); + assert.ok( + document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'), + "should mark messaging menu clickable item as shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 1, + "should display messaging menu dropdown after click" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenuHeader`).length, + 1, + "should have dropdown menu header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenuHeader + .o_MessagingMenu_tabButton + `).length, + 3, + "should have 3 tab buttons to filter items in the header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have button to make a new message" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList + `).length, + 1, + "should display thread preview list" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_noConversation + `).length, + 1, + "should display no conversation in thread preview list" + ); +}); + +QUnit.test('counter is taking into account failure notification', async function (assert) { + assert.expect(2); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Channel", + }]; + await this.start(); + + assert.containsOnce( + document.body, + '.o_MessagingMenu_counter', + "should display a notification counter next to the messaging menu for one notification" + ); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_counter').textContent, + "1", + "should display a counter of '1' next to the messaging menu" + ); +}); + +QUnit.test('switch tab', async function (assert) { + assert.expect(15); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should stay inactive" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should stay active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).click() + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should stay inactive" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become inactive" + ); +}); + +QUnit.test('new message', async function (assert) { + assert.expect(3); + + await this.start({ + hasChatWindow: true, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-new-message'), + "chat window should be for new message" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'), + "chat window should be focused" + ); +}); + +QUnit.test('no new message when discuss is open', async function (assert) { + assert.expect(3); + + await this.start({ + autoOpenDiscuss: true, + hasDiscuss: true, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open" + ); + + // simulate closing discuss app + await afterNextRender(() => this.discussWidget.on_detach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have 'new message' when discuss is closed" + ); + + // simulate opening discuss app + await afterNextRender(() => this.discussWidget.on_attach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open again" + ); +}); + +QUnit.test('channel preview: basic rendering', async function (assert) { + assert.expect(9); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 20, + }, + }]; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_sidebar + `).length, + 1, + "preview should have a sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + `).length, + 1, + "preview should have some content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + `).length, + 1, + "preview should have header in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + .o_ThreadPreview_name + `).length, + 1, + "preview should have name in header of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_name + `).textContent, + "General", "preview should have name of channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + .o_ThreadPreview_core + `).length, + 1, + "preview should have core in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).length, + 1, + "preview should have inline text in core of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).textContent.trim(), + "Demo: test", + "preview should have message content as inline text of core content" + ); +}); + +QUnit.test('filtered previews', async function (assert) { + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }, { + id: 10, + last_message: { + author_id: [7, "Demo"], + body: "<p>test2</p>", + channel_ids: [10], + id: 101, + message_type: 'comment', + model: 'mail.channel', + res_id: 10, + }, + }]; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 0, + "should not have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="channel"]').click() + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 0, + "should not have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="all"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of channel" + ); +}); + +QUnit.test('open chat window from preview', async function (assert) { + assert.expect(1); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + hasChatWindow: true, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); +}); + +QUnit.test('no code injection in message body preview', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "General", + }], + }, + }); + + this.data['mail.channel'].records = [{ + id: 1, + name: "general", + channel_type: "channel", + channel_message_ids: [1], + }]; + this.data['mail.message'].records = [{ + id: 1, + author_id: [1, 'Georges'], + body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>", + channel_ids: [1], + }]; + + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText') + .textContent.replace(/\s/g, ""), + "Georges:&shoulnotberaisedthrownewError('CodeInjectionError');", + "should display correct uninjected last message inline content" + ); + assert.containsNone( + document.querySelector('.o_ThreadPreview_inlineText'), + 'script', + "last message inline content should not have any code injection" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js new file mode 100644 index 00000000000..923480b3f73 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js @@ -0,0 +1,50 @@ +odoo.define('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js', function (require) { +'use strict'; + +const { Component } = owl; + +class MobileMessagingNavbar extends Component { + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.trigger('o-select-mobile-messaging-navbar-tab', { + tabId: ev.currentTarget.dataset.tabId, + }); + } + +} + +Object.assign(MobileMessagingNavbar, { + defaultProps: { + tabs: [], + }, + props: { + activeTabId: String, + tabs: { + type: Array, + element: { + type: Object, + shape: { + icon: { + type: String, + optional: true, + }, + id: String, + label: String, + }, + }, + }, + }, + template: 'mail.MobileMessagingNavbar', +}); + +return MobileMessagingNavbar; + +}); diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss new file mode 100644 index 00000000000..df0611f9115 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MobileMessagingNavbar { + display: flex; + flex: 0 0 auto; + z-index: 1; +} + +.o_MobileMessagingNavbar_tab { + display: flex; + flex-flow: column; + align-items: center; + flex: 1 1 0; + padding: 8px; +} + +.o_MobileMessagingNavbar_tabIcon { + margin-bottom: 4%; + font-size: 1.3em; +} + +.o_MobileMessagingNavbar_tabLabel { + font-size: 0.8em; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MobileMessagingNavbar { + background-color: white; + box-shadow: 0 0 8px gray('400'); +} + +.o_MobileMessagingNavbar_tab { + box-shadow: 1px 0 0 gray('400'); + + &.o-active { + color: $o-brand-primary; + } +} diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml new file mode 100644 index 00000000000..d60611bf786 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MobileMessagingNavbar" owl="1"> + <div class="o_MobileMessagingNavbar"> + <t t-foreach="props.tabs" t-as="tab" t-key="tab.id"> + <div class="o_MobileMessagingNavbar_tab" t-att-class="{ 'o-active': props.activeTabId === tab.id }" t-on-click="_onClick" t-att-data-tab-id="tab.id"> + <t t-if="tab.icon"> + <span class="o_MobileMessagingNavbar_tabIcon" t-att-class="tab.icon"/> + </t> + <span class="o_MobileMessagingNavbar_tabLabel"><t t-esc="tab.label"/></span> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js new file mode 100644 index 00000000000..ec260829cf4 --- /dev/null +++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js @@ -0,0 +1,79 @@ +odoo.define('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.OwlDialog'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ModerationBanDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => this.env.models['mail.message'].get(localId)); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickBan() { + this._dialogRef.comp._close(); + this.env.models['mail.message'].moderate(this.messages, 'ban'); + } + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + +} + +Object.assign(ModerationBanDialog, { + components: { Dialog }, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationBanDialog', +}); + +return ModerationBanDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml new file mode 100644 index 00000000000..d0bc59c19a6 --- /dev/null +++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationBanDialog" owl="1"> + <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 --> + <t t-set="title">Confirmation</t> + <Dialog contentClass="'o_ModerationBanDialog'" title="title" size="'medium'" t-ref="dialog"> + <t t-if="messages.length === 1"> + <p>You are going to ban the following user:</p> + </t> + <t t-else=""> + <p>You are going to ban the following users:</p> + </t> + <ul class="my-5"> + <t t-foreach="messages" t-as="message" t-key="message.localId"> + <li t-esc="message.email_from"/> + </t> + </ul> + <p>Do you confirm the action?</p> + <t t-set-slot="buttons"> + <button class="o-ban btn btn-primary" t-on-click="_onClickBan">Ban</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js new file mode 100644 index 00000000000..597d2ac3262 --- /dev/null +++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js @@ -0,0 +1,94 @@ +odoo.define('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.OwlDialog'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ModerationDiscardDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + getText() { + if (this.messages.length === 1) { + return this.env._t("You are going to discard 1 message."); + } + return _.str.sprintf( + this.env._t("You are going to discard %s messages."), + this.messages.length + ); + } + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + + /** + * @private + */ + _onClickDiscard() { + this._dialogRef.comp._close(); + this.env.models['mail.message'].moderate(this.messages, 'discard'); + } + +} + +Object.assign(ModerationDiscardDialog, { + components: { Dialog }, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationDiscardDialog', +}); + +return ModerationDiscardDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml new file mode 100644 index 00000000000..13cafb0f047 --- /dev/null +++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationDiscardDialog" owl="1"> + <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 --> + <t t-set="title">Confirmation</t> + <Dialog contentClass="'o_ModerationDiscardDialog'" title="title" size="'medium'" t-ref="dialog"> + <p t-esc="getText()"/> + <p>Do you confirm the action?</p> + <t t-set-slot="buttons"> + <button class="o-discard btn btn-primary" t-on-click="_onClickDiscard">Discard</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js new file mode 100644 index 00000000000..95f90e42c7d --- /dev/null +++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.OwlDialog'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +class ModerationRejectDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + title: this.env._t("Message Rejected"), + comment: this.env._t("Your message was rejected by moderator."), + }); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + + /** + * @private + */ + _onClickReject() { + this._dialogRef.comp._close(); + const kwargs = { + title: this.state.title, + comment: this.state.comment, + }; + this.env.models['mail.message'].moderate(this.messages, 'reject', kwargs); + } + +} + +Object.assign(ModerationRejectDialog, { + components: { Dialog }, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationRejectDialog', +}); + +return ModerationRejectDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml new file mode 100644 index 00000000000..a908265d33b --- /dev/null +++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationRejectDialog" owl="1"> + <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 --> + <t t-set="title">Send explanation to author</t> + <Dialog contentClass="'o_ModerationRejectDialog'" title="title" size="'medium'" t-ref="dialog"> + <input class="o_ModerationRejectDialog_title form-control" type="text" placeholder="Subject" autofocus="autofocus" t-model="state.title"/> + <textarea class="o_ModerationRejectDialog_comment form-control mt16" placeholder="Mail Body" t-model="state.comment"/> + <t t-set-slot="buttons"> + <button class="o-reject btn btn-primary" t-on-click="_onClickReject">Reject</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/notification_group/notification_group.js b/addons/mail/static/src/components/notification_group/notification_group.js new file mode 100644 index 00000000000..4020a610dc6 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.js @@ -0,0 +1,85 @@ +odoo.define('mail/static/src/components/notification_group/notification_group.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class NotificationGroup extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const group = this.env.models['mail.notification_group'].get(props.notificationGroupLocalId); + return { + group: group ? group.__state : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.notification_group} + */ + get group() { + return this.env.models['mail.notification_group'].get(this.props.notificationGroupLocalId); + } + + /** + * @returns {string|undefined} + */ + image() { + if (this.group.notification_type === 'email') { + return '/mail/static/src/img/smiley/mailfailure.jpg'; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.group.openDocuments(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.group.openCancelAction(); + } + +} + +Object.assign(NotificationGroup, { + props: { + notificationGroupLocalId: String, + }, + template: 'mail.NotificationGroup', +}); + +return NotificationGroup; + +}); diff --git a/addons/mail/static/src/components/notification_group/notification_group.scss b/addons/mail/static/src/components/notification_group/notification_group.scss new file mode 100644 index 00000000000..0ba937ec127 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.scss @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_NotificationGroup { + @include o-mail-notification-list-item-layout(); + + &:hover .o_NotificationGroup_markAsRead { + // TODO also mixin this + // task-2258605 + opacity: 1; + } +} + +.o_NotificationGroup_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_NotificationGroup_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_NotificationGroup_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_NotificationGroup_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_NotificationGroup_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_NotificationGroup_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_NotificationGroup_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_NotificationGroup_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_NotificationGroup_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_NotificationGroup_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_NotificationGroup_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_NotificationGroup_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_NotificationGroup { + @include o-mail-notification-list-item-style(); +} + +.o_NotificationGroup_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_NotificationGroup_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_NotificationGroup_date { + @include o-mail-notification-list-item-date-style(); +} + +.o_NotificationGroup_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_NotificationGroup_name { + @include o-mail-notification-list-item-bold-style(); +} diff --git a/addons/mail/static/src/components/notification_group/notification_group.xml b/addons/mail/static/src/components/notification_group/notification_group.xml new file mode 100644 index 00000000000..c2f3dcebda1 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationGroup" owl="1"> + <div class="o_NotificationGroup" t-on-click="_onClick"> + <t t-if="group"> + <div class="o_NotificationGroup_sidebar"> + <div class="o_NotificationGroup_imageContainer o_NotificationGroup_sidebarItem"> + <img class="o_NotificationGroup_image rounded-circle" t-att-src="image()" alt="Message delivery failure image"/> + </div> + </div> + <div class="o_NotificationGroup_content"> + <div class="o_NotificationGroup_header"> + <span class="o_NotificationGroup_name"> + <t t-esc="group.res_model_name"/> + </span> + <span class="o_NotificationGroup_counter"> + (<t t-esc="group.notifications.length"/>) + </span> + <span class="o-autogrow"/> + <span class="o_NotificationGroup_date"> + <t t-esc="group.date.fromNow()"/> + </span> + </div> + <div class="o_NotificationGroup_core"> + <span class="o_NotificationGroup_coreItem o_NotificationGroup_inlineText"> + <t t-if="group.notification_type === 'email'"> + An error occurred when sending an email. + </t> + </span> + <span class="o-autogrow"/> + <span class="o_NotificationGroup_coreItem o_NotificationGroup_markAsRead fa fa-check" title="Discard message delivery failures" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_list/notification_list.js b/addons/mail/static/src/components/notification_list/notification_list.js new file mode 100644 index 00000000000..5f714afc6d6 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.js @@ -0,0 +1,216 @@ +odoo.define('mail/static/src/components/notification_list/notification_list.js', function (require) { +'use strict'; + +const components = { + NotificationGroup: require('mail/static/src/components/notification_group/notification_group.js'), + ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'), + ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class NotificationList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.storeProps = useStore((...args) => this._useStoreSelector(...args), { + compareDepth: { + // list + notification object created in useStore + notifications: 2, + }, + }); + } + + mounted() { + this._loadPreviews(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object[]} + */ + get notifications() { + const { notifications } = this.storeProps; + return notifications; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Load previews of given thread. Basically consists of fetching all missing + * last messages of each thread. + * + * @private + */ + async _loadPreviews() { + const threads = this.notifications + .filter(notification => notification.thread) + .map(notification => this.env.models['mail.thread'].get(notification.thread)); + this.env.models['mail.thread'].loadPreviews(threads); + } + + /** + * @private + * @param {Object} props + */ + _useStoreSelector(props) { + const threads = this._useStoreSelectorThreads(props); + let threadNeedactionNotifications = []; + if (props.filter === 'all') { + // threads with needactions + threadNeedactionNotifications = this.env.models['mail.thread'] + .all(t => t.model !== 'mail.box' && t.needactionMessages.length > 0) + .sort((t1, t2) => { + if (t1.needactionMessages.length > 0 && t2.needactionMessages.length === 0) { + return -1; + } + if (t1.needactionMessages.length === 0 && t2.needactionMessages.length > 0) { + return 1; + } + if (t1.lastNeedactionMessage && t2.lastNeedactionMessage) { + return t1.lastNeedactionMessage.date.isBefore(t2.lastNeedactionMessage.date) ? 1 : -1; + } + if (t1.lastNeedactionMessage) { + return -1; + } + if (t2.lastNeedactionMessage) { + return 1; + } + return t1.id < t2.id ? -1 : 1; + }) + .map(thread => { + return { + thread, + type: 'thread_needaction', + uniqueId: thread.localId + '_needaction', + }; + }); + } + // thread notifications + const threadNotifications = threads + .sort((t1, t2) => { + if (t1.message_unread_counter > 0 && t2.message_unread_counter === 0) { + return -1; + } + if (t1.message_unread_counter === 0 && t2.message_unread_counter > 0) { + return 1; + } + if (t1.lastMessage && t2.lastMessage) { + return t1.lastMessage.date.isBefore(t2.lastMessage.date) ? 1 : -1; + } + if (t1.lastMessage) { + return -1; + } + if (t2.lastMessage) { + return 1; + } + return t1.id < t2.id ? -1 : 1; + }) + .map(thread => { + return { + thread, + type: 'thread', + uniqueId: thread.localId, + }; + }); + let notifications = threadNeedactionNotifications.concat(threadNotifications); + if (props.filter === 'all') { + const notificationGroups = this.env.messaging.notificationGroupManager.groups; + notifications = Object.values(notificationGroups) + .sort((group1, group2) => + group1.date.isAfter(group2.date) ? -1 : 1 + ).map(notificationGroup => { + return { + notificationGroup, + uniqueId: notificationGroup.localId, + }; + }).concat(notifications); + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + notifications, + }; + } + + /** + * @private + * @param {Object} props + * @throws {Error} in case `props.filter` is not supported + * @returns {mail.thread[]} + */ + _useStoreSelectorThreads(props) { + if (props.filter === 'mailbox') { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } else if (props.filter === 'channel') { + return this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else if (props.filter === 'chat') { + return this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'chat' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else if (props.filter === 'all') { + // "All" filter is for channels and chats + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.channel') + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else { + throw new Error(`Unsupported filter ${props.filter}`); + } + } + +} + +Object.assign(NotificationList, { + _allowedFilters: ['all', 'mailbox', 'channel', 'chat'], + components, + defaultProps: { + filter: 'all', + }, + props: { + filter: { + type: String, + validate: prop => NotificationList._allowedFilters.includes(prop), + }, + }, + template: 'mail.NotificationList', +}); + +return NotificationList; + +}); diff --git a/addons/mail/static/src/components/notification_list/notification_list.scss b/addons/mail/static/src/components/notification_list/notification_list.scss new file mode 100644 index 00000000000..18e3114952c --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.scss @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + + .o_NotificationList { + display: flex; + flex-flow: column; + overflow: auto; + + &.o-empty { + justify-content: center; + } +} + +.o_NotificationList_noConversation { + display: flex; + align-items: center; + justify-content: center; + padding: map-get($spacers, 4) map-get($spacers, 2); +} + +.o_NotificationList_separator { + flex: 0 0 auto; + width: map-get($sizes, 100); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_NotificationList_separator { + border-bottom: $border-width solid $border-color; +} + +.o_NotificationList_noConversation { + color: $text-muted; +} diff --git a/addons/mail/static/src/components/notification_list/notification_list.xml b/addons/mail/static/src/components/notification_list/notification_list.xml new file mode 100644 index 00000000000..bcf65da985d --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationList" owl="1"> + <div class="o_NotificationList" t-att-class="{ 'o-empty': notifications.length === 0 }"> + <t t-if="notifications.length === 0"> + <div class="o_NotificationList_noConversation"> + No conversation yet... + </div> + </t> + <t t-else=""> + <t t-foreach="notifications" t-as="notification" t-key="notification.uniqueId"> + <t t-if="notification.type === 'thread' and notification.thread"> + <ThreadPreview + class="o_NotificationList_preview" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + threadLocalId="notification.thread.localId" + /> + </t> + <t t-if="notification.type === 'thread_needaction' and notification.thread"> + <ThreadNeedactionPreview + class="o_NotificationList_preview" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + threadLocalId="notification.thread.localId" + /> + </t> + <t t-if="notification.notificationGroup"> + <NotificationGroup + class="o_NotificationList_group" + notificationGroupLocalId="notification.notificationGroup.localId" + /> + </t> + <t name="separatorCondition" t-if="!notification_last"> + <div class="o_NotificationList_separator"/> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_list/notification_list_item.scss b/addons/mail/static/src/components/notification_list/notification_list_item.scss new file mode 100644 index 00000000000..265e908a894 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_item.scss @@ -0,0 +1,174 @@ +// ----------------------------------------------------------------------------- +// Layout +// ----------------------------------------------------------------------------- + +@mixin o-mail-notification-list-item-layout { + display: flex; + align-items: center; + padding: map-get($spacers, 1); + + &.o-mobile { + padding: map-get($spacers, 2); + } +} + +@mixin o-mail-notification-list-item-content-layout { + display: flex; + flex-flow: column; + flex: 1 1 auto; + align-self: flex-start; + min-width: 0; // needed for flex to work correctly + margin: map-get($spacers, 2); +} + +@mixin o-mail-notification-list-item-core-layout { + display: flex; +} + +@mixin o-mail-notification-list-item-core-item-layout { + margin: map-get($spacers, 0) map-get($spacers, 2); + + &:first-child { + margin-inline-start: map-get($spacers, 0); + } + + &:last-child { + margin-inline-end: map-get($spacers, 0); + } +} + +@mixin o-mail-notification-list-item-counter-layout() { + margin: map-get($spacers, 0) map-get($spacers, 2); +} + +@mixin o-mail-notification-list-item-date-layout() { + flex: 0 0 auto; +} + +@mixin o-mail-notification-list-item-header-layout { + display: flex; + margin-bottom: map-get($spacers, 1); +} + +@mixin o-mail-notification-list-item-image-layout { + width: map-get($sizes, 100); + height: map-get($sizes, 100); +} + +@mixin o-mail-notification-list-item-image-container-layout { + position: relative; + width: 40px; + height: 40px; +} + +@mixin o-mail-notification-list-item-inline-text-layout { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.o-empty::before { + content: '\00a0'; // keep line-height as if it had content + } +} + +@mixin o-mail-notification-list-item-mark-as-read-layout() { + display: flex; + flex: 0 0 auto; +} + +@mixin o-mail-notification-list-item-name-layout { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.o-mobile { + font-size: 1.1em; + } +} + +@mixin o-mail-notification-list-item-partner-im-status-icon-layout { + @include o-position-absolute($bottom: 0, $right: 0); + display: flex; + align-items: center; + justify-content: center; +} + +@mixin o-mail-notification-list-item-sidebar-layout { + margin: map-get($spacers, 1); +} + +// ----------------------------------------------------------------------------- +// Style +// ----------------------------------------------------------------------------- + +$o-mail-notification-list-item-background-color: $white !default; +$o-mail-notification-list-item-hover-background-color: + darken($o-mail-notification-list-item-background-color, 7%) !default; + +$o-mail-notification-list-item-muted-background-color: gray('100') !default; +$o-mail-notification-list-item-muted-hover-background-color: + darken($o-mail-notification-list-item-muted-background-color, 7%) !default; + +@mixin o-mail-notification-list-item-style { + cursor: pointer; + user-select: none; + background-color: $o-mail-notification-list-item-background-color; + + &:hover { + background-color: $o-mail-notification-list-item-hover-background-color; + } + + &.o-muted { + background-color: $o-mail-notification-list-item-muted-background-color; + + &:hover { + background-color: $o-mail-notification-list-item-muted-hover-background-color; + } + } +} + +@mixin o-mail-notification-list-item-bold-style { + font-weight: bold; + + &.o-muted { + font-weight: initial; + } +} + +@mixin o-mail-notification-list-item-core-style { + color: gray('500'); +} + +@mixin o-mail-notification-list-item-date-style() { + @include o-mail-notification-list-item-bold-style(); + font-size: x-small; + color: $o-brand-primary; +} + +@mixin o-mail-notification-list-item-mark-as-read-style() { + opacity: 0; + + &:hover { + color: gray('600'); + } +} + +@mixin o-mail-notification-list-item-hover-partner-im-status-icon-style { + color: $o-mail-notification-list-item-hover-background-color; +} + +@mixin o-mail-notification-list-item-muted-hover-partner-im-status-icon-style { + color: $o-mail-notification-list-item-muted-hover-background-color; +} + +@mixin o-mail-notification-list-item-partner-im-status-icon-style { + color: $o-mail-notification-list-item-background-color; + + &:not(.o-mobile) { + font-size: x-small; + } + + &.o-muted { + color: $o-mail-notification-list-item-muted-background-color; + } +} diff --git a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js new file mode 100644 index 00000000000..f76fd920d82 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js @@ -0,0 +1,563 @@ +odoo.define('mail/static/src/components/notification_list/notification_list_notification_group_tests.js', function (require) { +'use strict'; + +const components = { + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('notification_list', {}, function () { +QUnit.module('notification_list_notification_group_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + /** + * @param {Object} param0 + * @param {string} [param0.filter='all'] + */ + this.createNotificationListComponent = async ({ filter = 'all' } = {}) => { + const NotificationListComponent = components.NotificationList; + NotificationListComponent.env = this.env; + this.component = new NotificationListComponent(null, { filter }); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.NotificationList.env; + }, +}); + +QUnit.test('notification group basic layout', async function (assert) { + assert.expect(10); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Channel", + }]; + await this.start(); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_name', + "should have 1 group name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_name').textContent, + "Channel", + "should have model name as group name" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have only 1 notification in the group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_date', + "should have 1 group date" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_date').textContent, + "a few seconds ago", + "should have the group date corresponding to now" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_inlineText', + "should have 1 group text" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_inlineText').textContent.trim(), + "An error occurred when sending an email.", + "should have the group text corresponding to email" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_markAsRead', + "should have 1 mark as read button" + ); +}); + +QUnit.test('mark as read', async function (assert) { + assert.expect(6); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Channel", + }]; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'mail.mail_resend_cancel_action', + "action should be the one to cancel email" + ); + assert.strictEqual( + payload.options.additional_context.default_model, + 'mail.channel', + "action should have the group model as default_model" + ); + assert.strictEqual( + payload.options.additional_context.unread_counter, + 1, + "action should have the group notification length as unread_counter" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup_markAsRead', + "should have 1 mark as read button" + ); + + document.querySelector('.o_NotificationGroup_markAsRead').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the cancel email dialog" + ); +}); + +QUnit.test('grouped notifications by document', async function (assert) { + // If some failures linked to a document refers to a same document, a single + // notification should group all those failures. + assert.expect(9); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Partner", + }, { + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 31, + res_model_name: "Partner", + }]; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should have the type act_window" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "action should have the group model as res_model" + ); + assert.strictEqual( + JSON.stringify(payload.action.views), + JSON.stringify([[false, 'form']]), + "action should have form view" + ); + assert.strictEqual( + payload.action.res_id, + 31, + "action should have the group res_id as res_id" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(2)", + "should have 2 notifications in the group" + ); + + document.querySelector('.o_NotificationGroup').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the related record" + ); +}); + +QUnit.test('grouped notifications by document model', async function (assert) { + // If all failures linked to a document model refers to different documents, + // a single notification should group all failures that are linked to this + // document model. + assert.expect(12); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, // key element of this test: a different res_id + res_model_name: "Partner", + }, { + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 32, // key element of this test: a different res_id + res_model_name: "Partner", + }]; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.name, + "Mail Failures", + "action should have 'Mail Failures' as name", + ); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should have the type act_window" + ); + assert.strictEqual( + payload.action.view_mode, + 'kanban,list,form', + "action should have 'kanban,list,form' as view_mode" + ); + assert.strictEqual( + JSON.stringify(payload.action.views), + JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]), + "action should have correct views" + ); + assert.strictEqual( + payload.action.target, + 'current', + "action should have 'current' as target" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "action should have the group model as res_model" + ); + assert.strictEqual( + JSON.stringify(payload.action.domain), + JSON.stringify([['message_has_error', '=', true]]), + "action should have 'message_has_error' as domain" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(2)", + "should have 2 notifications in the group" + ); + + document.querySelector('.o_NotificationGroup').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the related records" + ); +}); + +QUnit.test('different mail.channel are not grouped', async function (assert) { + // `mail.channel` is a special case where notifications are not grouped when + // they are linked to different channels, even though the model is the same. + assert.expect(6); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', // key element of this test: `mail.channel` + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, // key element of this test: a different res_id + res_model_name: "Channel", + }, { + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'mail.channel', // key element of this test: `mail.channel` + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 32, // key element of this test: a different res_id + res_model_name: "Channel", + }]; + await this.start({ + hasChatWindow: true, // needed to assert thread.open + }); + await this.createNotificationListComponent(); + + assert.containsN( + document.body, + '.o_NotificationGroup', + 2, + "should have 2 notifications group" + ); + const groups = document.querySelectorAll('.o_NotificationGroup'); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_counter', + "should have 1 group counter in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in first group" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_counter', + "should have 1 group counter in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in second group" + ); + + await afterNextRender(() => groups[0].click()); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the channel related to the first group in a chat window" + ); +}); + +QUnit.test('multiple grouped notifications by document model, sorted by date desc', async function (assert) { + assert.expect(9); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', // key element of this test: different model + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Partner", + }, { + date: moment.utc().add(1, 'days').format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'res.company', // key element of this test: different model + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 32, + res_model_name: "Company", + }]; + await this.start(); + await this.createNotificationListComponent(); + + assert.containsN( + document.body, + '.o_NotificationGroup', + 2, + "should have 2 notifications group" + ); + const groups = document.querySelectorAll('.o_NotificationGroup'); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_name', + "should have 1 group name in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_name').textContent, + "Company", + "should have first model name as group name" + ); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_counter', + "should have 1 group counter in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in first group" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_name', + "should have 1 group name in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_name').textContent, + "Partner", + "should have second model name as group name" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_counter', + "should have 1 group counter in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in second group" + ); +}); + +QUnit.test('non-failure notifications are ignored', async function (assert) { + assert.expect(1); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', + notifications: [{ + failure_type: false, + id: 21, + // key element of this test: status is not bounce or exception + notification_status: 'ready', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Partner", + }]; + await this.start(); + await this.createNotificationListComponent(); + + assert.containsNone( + document.body, + '.o_NotificationGroup', + "should have 0 notification group" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/notification_list/notification_list_tests.js b/addons/mail/static/src/components/notification_list/notification_list_tests.js new file mode 100644 index 00000000000..cc71ee2d0f2 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js @@ -0,0 +1,210 @@ +odoo.define('mail/static/src/components/notification_list/notification_list_tests.js', function (require) { +'use strict'; + +const components = { + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('notification_list', {}, function () { +QUnit.module('notification_list_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + /** + * @param {Object} param0 + * @param {string} [param0.filter='all'] + */ + this.createNotificationListComponent = async ({ filter = 'all' }) => { + const NotificationListComponent = components.NotificationList; + NotificationListComponent.env = this.env; + this.component = new NotificationListComponent(null, { filter }); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 100, + name: "Channel 2019", + message_unread_counter: 0, + }, { + channel_type: 'channel', + id: 200, + name: "Channel 2020", + message_unread_counter: 0, + }], + }, + }); + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.NotificationList.env; + }, +}); + +QUnit.test('marked as read thread notifications are ordered by last message date', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records = [{ + channel_type: 'channel', + id: 100, + message_unread_counter: 0, + name: "Channel 2019", + }, { + channel_type: 'channel', + id: 200, + message_unread_counter: 0, + name: "Channel 2020", + }]; + this.data['mail.message'].records = [{ + author_id: [10, "Author A"], + body: "<p>Message A</p>", + channel_ids: [100], + date: "2019-01-01 00:00:00", + id: 42, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2019', + res_id: 100, + }, { + author_id: [20, "Author B"], + body: "<p>Message B</p>", + channel_ids: [200], + date: "2020-01-01 00:00:00", + id: 43, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2020', + res_id: 200, + }]; + await this.start(); + await this.createNotificationListComponent({ filter: 'all' }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should be two thread previews" + ); + const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview'); + assert.strictEqual( + threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2020', + "First channel in the list should be the channel of 2020 (more recent last message)" + ); + assert.strictEqual( + threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2019', + "Second channel in the list should be the channel of 2019 (least recent last message)" + ); +}); + +QUnit.test('thread notifications are re-ordered on receiving a new message', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records = [{ + channel_type: 'channel', + id: 100, + message_unread_counter: 0, + name: "Channel 2019", + }, { + channel_type: 'channel', + id: 200, + message_unread_counter: 0, + name: "Channel 2020", + }]; + this.data['mail.message'].records = [{ + author_id: [10, "Author A"], + body: "<p>Message A</p>", + channel_ids: [100], + date: "2019-01-01 00:00:00", + id: 42, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2019', + res_id: 100, + }, { + author_id: [20, "Author B"], + body: "<p>Message B</p>", + channel_ids: [200], + date: "2020-01-01 00:00:00", + id: 43, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2020', + res_id: 200, + }]; + await this.start(); + await this.createNotificationListComponent({ filter: 'all' }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should be two thread previews" + ); + + await afterNextRender(() => { + const messageData = { + author_id: [7, "Demo User"], + body: "<p>New message !</p>", + channel_ids: [100], + date: "2020-03-23 10:00:00", + id: 44, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2019', + res_id: 100, + }; + this.widget.call('bus_service', 'trigger', 'notification', [ + [['my-db', 'mail.channel', 100], messageData] + ]); + }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should still be two thread previews" + ); + const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview'); + assert.strictEqual( + threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2019', + "First channel in the list should now be 'Channel 2019'" + ); + assert.strictEqual( + threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2020', + "Second channel in the list should now be 'Channel 2020'" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.js b/addons/mail/static/src/components/notification_popover/notification_popover.js new file mode 100644 index 00000000000..0f3fce77a58 --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/src/components/notification_popover/notification_popover.js', function (require) { +'use strict'; + +const { Component } = owl; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +class NotificationPopover extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const notifications = props.notificationLocalIds.map( + notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId) + ); + return { + notifications: notifications.map(notification => notification ? notification.__state : undefined), + }; + }, { + compareDepth: { + notifications: 1, + }, + }); + } + + /** + * @returns {string} + */ + get iconClass() { + switch (this.notification.notification_status) { + case 'sent': + return 'fa fa-check'; + case 'bounce': + return 'fa fa-exclamation'; + case 'exception': + return 'fa fa-exclamation'; + case 'ready': + return 'fa fa-send-o'; + case 'canceled': + return 'fa fa-trash-o'; + } + return ''; + } + + /** + * @returns {string} + */ + get iconTitle() { + switch (this.notification.notification_status) { + case 'sent': + return this.env._t("Sent"); + case 'bounce': + return this.env._t("Bounced"); + case 'exception': + return this.env._t("Error"); + case 'ready': + return this.env._t("Ready"); + case 'canceled': + return this.env._t("Canceled"); + } + return ''; + } + + /** + * @returns {mail.notification[]} + */ + get notifications() { + return this.props.notificationLocalIds.map( + notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId) + ); + } + +} + +Object.assign(NotificationPopover, { + props: { + notificationLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.NotificationPopover', +}); + +return NotificationPopover; + +}); diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.scss b/addons/mail/static/src/components/notification_popover/notification_popover.scss new file mode 100644 index 00000000000..06b4201ca2c --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.scss @@ -0,0 +1,7 @@ +// ----------------------------------------------------------------------------- +// Layout +// ----------------------------------------------------------------------------- + +.o_NotificationPopover_notificationIcon { + margin-inline-end: map-get($spacers, 2); +} diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.xml b/addons/mail/static/src/components/notification_popover/notification_popover.xml new file mode 100644 index 00000000000..cf5aa027672 --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationPopover" owl="1"> + <div class="o_NotificationPopover"> + <t t-foreach="notifications" t-as="notification" t-key="notification.localId"> + <div class="o_NotificationPopover_notification"> + <i class="o_NotificationPopover_notificationIcon" t-att-class="iconClass" t-att-title="iconTitle" role="img"/> + <t t-if="notification.partner"> + <span class="o_NotificationPopover_notificationPartnerName" t-esc="notification.partner.nameOrDisplayName"/> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js new file mode 100644 index 00000000000..7150c2914a9 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js @@ -0,0 +1,52 @@ +odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class PartnerImStatusIcon extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const partner = this.env.models['mail.partner'].get(props.partnerLocalId); + return { + partner: partner ? partner.__state : undefined, + partnerRoot: this.env.messaging.partnerRoot + ? this.env.messaging.partnerRoot.__state + : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.partner} + */ + get partner() { + return this.env.models['mail.partner'].get(this.props.partnerLocalId); + } + +} + +Object.assign(PartnerImStatusIcon, { + defaultProps: { + hasBackground: true + }, + props: { + partnerLocalId: String, + hasBackground: Boolean, + }, + template: 'mail.PartnerImStatusIcon', +}); + +return PartnerImStatusIcon; + +}); diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss new file mode 100644 index 00000000000..d5791c2f0c6 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_PartnerImStatusIcon { + display: flex; + flex-flow: column; + + width: 1.2em; + height: 1.2em; + line-height: 1.3em; +} + +.o_PartnerImStatusIcon_outerBackground { + transform: scale(1.5); +} + +.o-background { + transform: scale(1); + margin-inline-end: map-get($spacers, 1); + margin-top: 2px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_PartnerImStatusIcon_innerBackground { + color: white; +} + +.o_PartnerImStatusIcon_icon { + + &.o-away { + color: theme-color('warning'); + } + + &.o-bot { + color: $o-enterprise-primary-color; + } + + &.o-offline { + color: gray('700'); + } + + &.o-online { + color: $o-enterprise-primary-color; + } +} + + + + diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml new file mode 100644 index 00000000000..23879cc6b39 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.PartnerImStatusIcon" owl="1"> + <span class="o_PartnerImStatusIcon fa-stack" + t-att-class="{ + 'o-away': partner and partner.im_status === 'away', + 'o-background': !props.hasBackground, + 'o-bot': partner and env.messaging.partnerRoot === partner, + 'o-offline': partner and partner.im_status === 'offline', + 'o-online': partner and partner.im_status === 'online', + }" + t-att-data-partner-local-id="partner ? partner.localId : undefined" + > + <t t-if="partner" name="rootCondition"> + <t t-if="props.hasBackground"> + <i class="o_PartnerImStatusIcon_outerBackground fa fa-circle fa-stack-1x"/> + <i class="o_PartnerImStatusIcon_innerBackground fa fa-circle fa-stack-1x"/> + </t> + <t t-if="partner.im_status === 'online'"> + <i class="o_PartnerImStatusIcon_icon o-online fa fa-circle fa-stack-1x" title="Online" role="img" aria-label="User is online"/> + </t> + <t t-if="partner.im_status === 'away'"> + <i class="o_PartnerImStatusIcon_icon o-away fa fa-circle fa-stack-1x" title="Idle" role="img" aria-label="User is idle"/> + </t> + <t t-if="partner.im_status === 'offline'"> + <i class="o_PartnerImStatusIcon_icon o-offline fa fa-circle-o fa-stack-1x" title="Offline" role="img" aria-label="User is offline"/> + </t> + <t t-if="partner === env.messaging.partnerRoot"> + <i class="o_PartnerImStatusIcon_icon o-bot fa fa-heart fa-stack-1x" title="Bot" role="img" aria-label="User is a bot"/> + </t> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js new file mode 100644 index 00000000000..01246ddcdb3 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js @@ -0,0 +1,152 @@ +odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js', function (require) { +'use strict'; + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('partner_im_status_icon', {}, function () { +QUnit.module('partner_im_status_icon_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createPartnerImStatusIcon = async partner => { + const PartnerImStatusIconComponent = components.PartnerImStatusIcon; + PartnerImStatusIconComponent.env = this.env; + this.component = new PartnerImStatusIconComponent(null, { partnerLocalId: partner.localId }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.PartnerImStatusIcon.env; + }, +}); + +QUnit.test('initially online', async function (assert) { + assert.expect(3); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'online', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon`).length, + 1, + "should have partner IM status icon" + ); + assert.strictEqual( + document.querySelector(`.o_PartnerImStatusIcon`).dataset.partnerLocalId, + partner.localId, + "partner IM status icon should be linked to partner with ID 7" + ); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering" + ); +}); + +QUnit.test('initially offline', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'offline', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length, + 1, + "partner IM status icon should have offline status rendering" + ); +}); + +QUnit.test('initially away', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'away', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length, + 1, + "partner IM status icon should have away status rendering" + ); +}); + +QUnit.test('change icon on change partner im_status', async function (assert) { + assert.expect(4); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'online', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'offline' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length, + 1, + "partner IM status icon should have offline status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'away' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length, + 1, + "partner IM status icon should have away status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'online' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering in the end" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js new file mode 100644 index 00000000000..7c39f148264 --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js @@ -0,0 +1,79 @@ +odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; + +const { Component } = owl; + +class PartnerMentionSuggestion extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const partner = this.env.models['mail.partner'].get(props.partnerLocalId); + return { + partner: partner ? partner.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.partner} + */ + get partner() { + return this.env.models['mail.partner'].get(this.props.partnerLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onClick(ev) { + // avoid following dummy href + ev.preventDefault(); + this.trigger('o-partner-mention-suggestion-clicked', { + partner: this.partner, + }); + } + + /** + * @private + * @param {Event} ev + */ + _onMouseOver(ev) { + this.trigger('o-partner-mention-suggestion-mouse-over', { + partner: this.partner, + }); + } +} + +Object.assign(PartnerMentionSuggestion, { + components, + defaultProps: { + isActive: false, + }, + props: { + isActive: Boolean, + partnerLocalId: String, + }, + template: 'mail.PartnerMentionSuggestion', +}); + +return PartnerMentionSuggestion; + +}); diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss new file mode 100644 index 00000000000..b93d245972c --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_PartnerMentionSuggestion { + display: flex; + width: map-get($sizes, 100); + padding: map-get($spacers, 2) map-get($spacers, 4); +} + +.o_PartnerMentionSuggestion_name { + padding-inline-end: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_PartnerMentionSuggestion_email { + font-style: italic; + color: $gray-600; +} + +.o_PartnerMentionSuggestion_name { + font-weight: $font-weight-bold; +} diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml new file mode 100644 index 00000000000..a45e991ab19 --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.PartnerMentionSuggestion" owl="1"> + <a class="o_PartnerMentionSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" role="menuitem" t-on-click="_onClick" t-on-mouseover="_onMouseOver"> + <t t-if="partner"> + <PartnerImStatusIcon + hasBackground="false" + partnerLocalId="partner.localId" + /> + <span class="o_PartnerMentionSuggestion_name"><t t-esc="partner.nameOrDisplayName"/></span> + <t t-if="partner.email"> + <span class="o_PartnerMentionSuggestion_email">(<t t-esc="partner.email"/>)</span> + </t> + </t> + </a> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js new file mode 100644 index 00000000000..e9b31a264ef --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js @@ -0,0 +1,141 @@ +odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js', function (require) { +'use strict'; + +const components = { + PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('partner_mention_suggestion', {}, function () { +QUnit.module('partner_mention_suggestion_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.createPartnerMentionSuggestion = async partner => { + const PartnerMentionSuggestionComponent = components.PartnerMentionSuggestion; + PartnerMentionSuggestionComponent.env = this.env; + this.component = new PartnerMentionSuggestionComponent( + null, + { + isActive: true, + partnerLocalId: partner.localId, + }); + await this.component.mount(this.widget.el); + }; + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.PartnerMentionSuggestion.env; + }, +}); + +QUnit.test('partner mention suggestion displayed', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createPartnerMentionSuggestion(partner); + + assert.containsOnce( + document.body, + `.o_PartnerMentionSuggestion`, + "Partner mention suggestion should be present" + ); +}); + +QUnit.test('partner mention suggestion correct data', async function (assert) { + assert.expect(6); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + email: "demo_user@odoo.com", + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createPartnerMentionSuggestion(partner); + + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "Partner mention suggestion should be present" + ); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon`).length, + 1, + "Partner's im_status should be displayed" + ); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion_name', + "Partner's name should be present" + ); + assert.strictEqual( + document.querySelector(`.o_PartnerMentionSuggestion_name`).textContent, + "Demo User", + "Partner's name should be displayed" + ); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion_email', + "Partner's email should be present" + ); + assert.strictEqual( + document.querySelector(`.o_PartnerMentionSuggestion_email`).textContent, + "(demo_user@odoo.com)", + "Partner's email should be displayed" + ); +}); + +QUnit.test('partner mention suggestion active', async function (assert) { + assert.expect(2); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + email: "demo_user@odoo.com", + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createPartnerMentionSuggestion(partner); + + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "Partner mention suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_PartnerMentionSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.js b/addons/mail/static/src/components/thread_icon/thread_icon.js new file mode 100644 index 00000000000..0b82d0c9510 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.js @@ -0,0 +1,54 @@ +odoo.define('mail/static/src/components/thread_icon/thread_icon.js', function (require) { +'use strict'; + +const components = { + ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ThreadIcon extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const correspondent = thread ? thread.correspondent : undefined; + return { + correspondent: correspondent ? correspondent.__state : undefined, + partnerRoot: this.env.messaging.partnerRoot + ? this.env.messaging.partnerRoot.__state + : undefined, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(ThreadIcon, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadIcon', +}); + +return ThreadIcon; + +}); diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.scss b/addons/mail/static/src/components/thread_icon/thread_icon.scss new file mode 100644 index 00000000000..f27b48d0a8b --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.scss @@ -0,0 +1,26 @@ + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadIcon { + display: flex; + width: 13px; + justify-content: center; +} + +.o_ThreadIcon_typing { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadIcon_away { + color: theme-color('warning'); +} + +.o_ThreadIcon_online { + color: $o-enterprise-primary-color; +} diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.xml b/addons/mail/static/src/components/thread_icon/thread_icon.xml new file mode 100644 index 00000000000..95c4694a661 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadIcon" owl="1"> + <div class="o_ThreadIcon"> + <t t-if="thread" name="rootCondition"> + <t t-if="thread.channel_type === 'channel'"> + <t t-if="thread.public === 'private'"> + <!-- AKU TODO: channel of type 'groups' should maybe also have lock icon --> + <div class="o_ThreadIcon_channelPrivate fa fa-lock" title="Private channel"/> + </t> + <t t-else=""> + <div class="o_ThreadIcon_channelPublic fa fa-hashtag" title="Public channel"/> + </t> + </t> + <t t-elif="thread.channel_type === 'chat' and thread.correspondent"> + <t t-if="thread.orderedOtherTypingMembers.length > 0"> + <ThreadTypingIcon + class="o_ThreadIcon_typing" + animation="'pulse'" + title="thread.typingStatusText" + /> + </t> + <t t-elif="thread.correspondent.im_status === 'online'"> + <div class="o_ThreadIcon_online fa fa-circle" title="Online"/> + </t> + <t t-elif="thread.correspondent.im_status === 'offline'"> + <div class="o_ThreadIcon_offline fa fa-circle-o" title="Offline"/> + </t> + <t t-elif="thread.correspondent.im_status === 'away'"> + <div class="o_ThreadIcon_away fa fa-circle" title="Away"/> + </t> + <t t-elif="thread.correspondent === env.messaging.partnerRoot"> + <div class="o_ThreadIcon_online fa fa-heart" title="Bot"/> + </t> + <t t-else="" name="noImStatusCondition"> + <div class="o_ThreadIcon_noImStatus fa fa-question-circle" title="No IM status available"/> + </t> + </t> + <t t-elif="thread.model === 'mail.box'"> + <t t-if="thread === env.messaging.inbox"> + <div class="o_ThreadIcon_mailboxInbox fa fa-inbox"/> + </t> + <t t-elif="thread === env.messaging.starred"> + <div class="o_ThreadIcon_mailboxStarred fa fa-star-o"/> + </t> + <t t-elif="thread === env.messaging.history"> + <div class="o_ThreadIcon_mailboxHistory fa fa-history"/> + </t> + <t t-elif="thread === env.messaging.moderation"> + <div class="o_ThreadIcon_mailboxModeration fa fa-envelope"/> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_icon/thread_icon_tests.js b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js new file mode 100644 index 00000000000..c21aef4c393 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js @@ -0,0 +1,146 @@ +odoo.define('mail/static/src/components/thread_icon/thread_icon_tests.js', function (require) { +'use strict'; + +const components = { + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_icon', {}, function () { +QUnit.module('thread_icon_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createThreadIcon = async thread => { + const ThreadIconComponent = components.ThreadIcon; + ThreadIconComponent.env = this.env; + this.component = new ThreadIconComponent(null, { threadLocalId: thread.localId }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.ThreadIcon.env; + }, +}); + +QUnit.test('chat: correspondent is typing', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: 'chat', + direct_partner: [{ + email: 'demo@odoo.com', + id: 7, + im_status: 'online', + name: "Demo", + }], + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + im_status: 'online', + name: 'Demo', + }], + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadIcon(thread); + + assert.containsOnce( + document.body, + '.o_ThreadIcon', + "should have thread icon" + ); + assert.containsOnce( + document.body, + '.o_ThreadIcon_online', + "should have thread icon with partner im status icon 'online'" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.containsOnce( + document.body, + '.o_ThreadIcon_typing', + "should have thread icon with partner currently typing" + ); + assert.strictEqual( + document.querySelector('.o_ThreadIcon_typing').title, + "Demo is typing...", + "title of icon should tell demo is currently typing" + ); + + // simulate receive typing notification from demo "no longer is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: false, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.containsOnce( + document.body, + '.o_ThreadIcon_online', + "should have thread icon with partner im status icon 'online' (no longer typing)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js new file mode 100644 index 00000000000..efcd8f5d86e --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js @@ -0,0 +1,131 @@ +odoo.define('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js', function (require) { +'use strict'; + +const components = { + MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadNeedactionPreview extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const mainThreadCache = thread ? thread.mainCache : undefined; + let lastNeedactionMessageAuthor; + let lastNeedactionMessage; + let threadCorrespondent; + if (thread) { + lastNeedactionMessage = mainThreadCache.lastNeedactionMessage; + threadCorrespondent = thread.correspondent; + } + if (lastNeedactionMessage) { + lastNeedactionMessageAuthor = lastNeedactionMessage.author; + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + lastNeedactionMessage: lastNeedactionMessage ? lastNeedactionMessage.__state : undefined, + lastNeedactionMessageAuthor: lastNeedactionMessageAuthor + ? lastNeedactionMessageAuthor.__state + : undefined, + thread: thread ? thread.__state : undefined, + threadCorrespondent: threadCorrespondent + ? threadCorrespondent.__state + : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the image route of the thread. + * + * @returns {string} + */ + image() { + if (this.thread.moduleIcon) { + return this.thread.moduleIcon; + } + if (this.thread.correspondent) { + return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`; + } + if (this.thread.model === 'mail.channel') { + return `/web/image/mail.channel/${this.thread.id}/image_128`; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + } + + /** + * Get inline content of the last message of this conversation. + * + * @returns {string} + */ + get inlineLastNeedactionMessageBody() { + if (!this.thread.lastNeedactionMessage) { + return ''; + } + return mailUtils.htmlToTextContentInline(this.thread.lastNeedactionMessage.prettyBody); + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.trigger('o-select-thread', { thread: this.thread }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.thread.markNeedactionMessagesAsRead(); + } + +} + +Object.assign(ThreadNeedactionPreview, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadNeedactionPreview', +}); + +return ThreadNeedactionPreview; + +}); diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss new file mode 100644 index 00000000000..08219402ab4 --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadNeedactionPreview { + @include o-mail-notification-list-item-layout(); + + &:hover .o_ThreadNeedactionPreview_markAsRead { + opacity: 1; + } +} + +.o_ThreadNeedactionPreview_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_ThreadNeedactionPreview_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_ThreadNeedactionPreview_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_ThreadNeedactionPreview_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_ThreadNeedactionPreview_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_ThreadNeedactionPreview_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_ThreadNeedactionPreview_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_ThreadNeedactionPreview_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_ThreadNeedactionPreview_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_ThreadNeedactionPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_ThreadNeedactionPreview_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-layout(); +} + +.o_ThreadNeedactionPreview_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadNeedactionPreview { + @include o-mail-notification-list-item-style(); + background-color: rgba($o-brand-primary, 0.1); + + &:hover { + background-color: rgba($o-brand-primary, 0.2); + + .o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-hover-partner-im-status-icon-style(); + } + } +} + +.o_ThreadNeedactionPreview_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_ThreadNeedactionPreview_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadNeedactionPreview_date { + @include o-mail-notification-list-item-date-style(); +} + +.o_ThreadNeedactionPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_ThreadNeedactionPreview_name { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-style(); +} diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml new file mode 100644 index 00000000000..82612ed233c --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadNeedactionPreview" owl="1"> + <!-- + The preview template is used by the discuss in mobile, and by the systray + menu in order to show preview of threads. + --> + <div class="o_ThreadNeedactionPreview" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined"> + <t t-if="thread"> + <div class="o_ThreadNeedactionPreview_sidebar"> + <div class="o_ThreadNeedactionPreview_imageContainer o_ThreadNeedactionPreview_sidebarItem"> + <img class="o_ThreadNeedactionPreview_image" t-att-src="image()" alt="Thread Image"/> + <t t-if="thread.correspondent and thread.correspondent.im_status"> + <PartnerImStatusIcon + class="o_ThreadNeedactionPreview_partnerImStatusIcon" + t-att-class="{ + 'o-mobile': env.messaging.device.isMobile, + }" + partnerLocalId="thread.correspondent.localId" + /> + </t> + </div> + </div> + <div class="o_ThreadNeedactionPreview_content"> + <div class="o_ThreadNeedactionPreview_header"> + <span class="o_ThreadNeedactionPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-esc="thread.displayName"/> + </span> + <span class="o_ThreadNeedactionPreview_counter"> + (<t t-esc="thread.needactionMessages.length"/>) + </span> + <span class="o-autogrow"/> + <t t-if="thread.lastNeedactionMessage"> + <span class="o_ThreadNeedactionPreview_date"> + <t t-esc="thread.lastNeedactionMessage.date.fromNow()"/> + </span> + </t> + </div> + <div class="o_ThreadNeedactionPreview_core"> + <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText" t-att-class="{ 'o-empty': inlineLastNeedactionMessageBody.length === 0 }"> + <t t-if="thread.lastMessage and thread.lastMessage.author"> + <MessageAuthorPrefix + messageLocalId="thread.lastMessage.localId" + threadLocalId="thread.localId" + /> + </t> + <t t-raw="inlineLastNeedactionMessageBody"/> + </span> + <span class="o-autogrow"/> + <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.js b/addons/mail/static/src/components/thread_preview/thread_preview.js new file mode 100644 index 00000000000..f2bbfabe3f9 --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.js @@ -0,0 +1,124 @@ +odoo.define('mail/static/src/components/thread_preview/thread_preview.js', function (require) { +'use strict'; + +const components = { + MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadPreview extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const mainThreadCache = thread ? thread.mainCache : undefined; + let lastMessageAuthor; + let lastMessage; + if (thread) { + const orderedMessages = mainThreadCache.orderedMessages; + lastMessage = orderedMessages[orderedMessages.length - 1]; + } + if (lastMessage) { + lastMessageAuthor = lastMessage.author; + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + lastMessage: lastMessage ? lastMessage.__state : undefined, + lastMessageAuthor: lastMessageAuthor + ? lastMessageAuthor.__state + : undefined, + thread: thread ? thread.__state : undefined, + threadCorrespondent: thread && thread.correspondent + ? thread.correspondent.__state + : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the image route of the thread. + * + * @returns {string} + */ + image() { + if (this.thread.correspondent) { + return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`; + } + return `/web/image/mail.channel/${this.thread.id}/image_128`; + } + + /** + * Get inline content of the last message of this conversation. + * + * @returns {string} + */ + get inlineLastMessageBody() { + if (!this.thread.lastMessage) { + return ''; + } + return mailUtils.htmlToTextContentInline(this.thread.lastMessage.prettyBody); + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.trigger('o-select-thread', { thread: this.thread }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.thread.markAsSeen(); + } + +} + +Object.assign(ThreadPreview, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadPreview', +}); + +return ThreadPreview; + +}); diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.scss b/addons/mail/static/src/components/thread_preview/thread_preview.scss new file mode 100644 index 00000000000..7e7f8abdf1a --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.scss @@ -0,0 +1,113 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadPreview { + @include o-mail-notification-list-item-layout(); + + &:hover .o_ThreadPreview_markAsRead { + opacity: 1; + } +} + +.o_ThreadPreview_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_ThreadPreview_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_ThreadPreview_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_ThreadPreview_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_ThreadPreview_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_ThreadPreview_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_ThreadPreview_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_ThreadPreview_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_ThreadPreview_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_ThreadPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_ThreadPreview_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-layout(); +} + +.o_ThreadPreview_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadPreview { + @include o-mail-notification-list-item-style(); + + &:hover { + .o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-hover-partner-im-status-icon-style(); + } + } + + &.o-muted { + &:hover { + .o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-muted-hover-partner-im-status-icon-style(); + } + } + } +} + +.o_ThreadPreview_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_ThreadPreview_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadPreview_date { + @include o-mail-notification-list-item-date-style(); + + &.o-muted { + color: gray('500'); + } +} + +.o_ThreadPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_ThreadPreview_name { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-style(); +} diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.xml b/addons/mail/static/src/components/thread_preview/thread_preview.xml new file mode 100644 index 00000000000..ded9c6fadac --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadPreview" owl="1"> + <!-- + The preview template is used by the discuss in mobile, and by the systray + menu in order to show preview of threads. + --> + <div class="o_ThreadPreview" t-att-class="{ 'o-muted': thread and thread.message_unread_counter === 0 }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined"> + <t t-if="thread"> + <div class="o_ThreadPreview_sidebar"> + <div class="o_ThreadPreview_imageContainer o_ThreadPreview_sidebarItem"> + <img class="o_ThreadPreview_image rounded-circle" t-att-src="image()" alt="Thread Image"/> + <t t-if="thread.correspondent and thread.correspondent.im_status"> + <PartnerImStatusIcon + class="o_ThreadPreview_partnerImStatusIcon" + t-att-class="{ + 'o-mobile': env.messaging.device.isMobile, + 'o-muted': thread.message_unread_counter === 0, + }" + partnerLocalId="thread.correspondent.localId" + /> + </t> + </div> + </div> + <div class="o_ThreadPreview_content"> + <div class="o_ThreadPreview_header"> + <span class="o_ThreadPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-muted': thread.message_unread_counter === 0 }"> + <t t-esc="thread.displayName"/> + </span> + <t t-if="thread.message_unread_counter > 0"> + <span class="o_ThreadPreview_counter" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }"> + (<t t-esc="thread.message_unread_counter"/>) + </span> + </t> + <span class="o-autogrow"/> + <t t-if="thread.lastMessage"> + <span class="o_ThreadPreview_date" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }"> + <t t-esc="thread.lastMessage.date.fromNow()"/> + </span> + </t> + </div> + <div class="o_ThreadPreview_core"> + <span class="o_ThreadPreview_coreItem o_ThreadPreview_inlineText" t-att-class="{ 'o-empty': inlineLastMessageBody.length === 0 }"> + <t t-if="thread.lastMessage and thread.lastMessage.author"> + <MessageAuthorPrefix + messageLocalId="thread.lastMessage.localId" + threadLocalId="thread.localId" + /> + </t> + <t t-raw="inlineLastMessageBody"/> + </span> + <span class="o-autogrow"/> + <t t-if="thread.message_unread_counter > 0"> + <span class="o_ThreadPreview_coreItem o_ThreadPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/> + </t> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js new file mode 100644 index 00000000000..94f8c2b95bb --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js @@ -0,0 +1,49 @@ +odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js', function (require) { +'use strict'; + +const components = { + ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ThreadTextualTypingStatus extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(ThreadTextualTypingStatus, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadTextualTypingStatus', +}); + +return ThreadTextualTypingStatus; + +}); diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss new file mode 100644 index 00000000000..4cb9e1cfc3f --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss @@ -0,0 +1,12 @@ + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadTextualTypingStatus { + display: flex; +} + +.o_ThreadTextualTypingStatus_separator { + width: map-get($spacers, 1); +} diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml new file mode 100644 index 00000000000..722d07389f8 --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadTextualTypingStatus" owl="1"> + <div class="o_ThreadTextualTypingStatus"> + <t t-if="thread and thread.orderedOtherTypingMembers.length > 0"> + <ThreadTypingIcon animation="'pulse'" size="'medium'"/> + <span class="o_ThreadTextualTypingStatus_separator"/> + <span t-esc="thread.typingStatusText"/> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js new file mode 100644 index 00000000000..2d4665e4920 --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js @@ -0,0 +1,480 @@ +odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', function (require) { +'use strict'; + +const components = { + ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + nextAnimationFrame, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_textual_typing_status', {}, function () { +QUnit.module('thread_textual_typing_status_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createThreadTextualTypingStatusComponent = async thread => { + const ThreadTextualTypingStatusComponent = components.ThreadTextualTypingStatus; + ThreadTextualTypingStatusComponent.env = this.env; + this.component = new ThreadTextualTypingStatusComponent(null, { + threadLocalId: thread.localId, + }); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + async afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.ThreadTextualTypingStatus.env; + }, +}); + +QUnit.test('receive other member typing status "is typing"', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); +}); + +QUnit.test('receive other member typing status "is typing" then "no longer is typing"', async function (assert) { + assert.expect(3); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + // simulate receive typing notification from demo "is no longer typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: false, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing" + ); +}); + +QUnit.test('assume other member typing status becomes "no longer is typing" after 60 seconds without any updated typing status', async function (assert) { + assert.expect(3); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + await afterNextRender(() => this.env.testUtils.advanceTime(60 * 1000)); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing" + ); +}); + +QUnit.test ('other member typing status "is typing" refreshes 60 seconds timer of assuming no longer typing', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + // simulate receive typing notification from demo "is typing" again after 50s. + await this.env.testUtils.advanceTime(50 * 1000); + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + await this.env.testUtils.advanceTime(50 * 1000); + await nextAnimationFrame(); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should still display that demo user is typing after 100 seconds (refreshed is typing status at 50s => (100 - 50) = 50s < 60s after assuming no-longer typing)" + ); + + await afterNextRender(() => this.env.testUtils.advanceTime(11 * 1000)); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing after 111 seconds (refreshed is typing status at 50s => (111 - 50) = 61s > 60s after assuming no-longer typing)" + ); +}); + +QUnit.test('receive several other members typing status "is typing"', async function (assert) { + assert.expect(6); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'other10@odoo.com', + id: 10, + name: 'Other10', + }, { + email: 'other11@odoo.com', + id: 11, + name: 'Other11', + }, { + email: 'other12@odoo.com', + id: 12, + name: 'Other12', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from other10 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 10, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10 is typing...", + "Should display that 'Other10' member is typing" + ); + + // simulate receive typing notification from other11 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 11, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10 and Other11 are typing...", + "Should display that members 'Other10' and 'Other11' are typing (order: longer typer named first)" + ); + + // simulate receive typing notification from other12 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 12, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10, Other11 and more are typing...", + "Should display that members 'Other10', 'Other11' and more (at least 1 extra member) are typing (order: longer typer named first)" + ); + + // simulate receive typing notification from other10 (no longer is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 10, + is_typing: false, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other11 and Other12 are typing...", + "Should display that members 'Other11' and 'Other12' are typing ('Other10' stopped typing)" + ); + + // simulate receive typing notification from other10 (is typing again) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 10, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other11, Other12 and more are typing...", + "Should display that members 'Other11' and 'Other12' and more (at least 1 extra member) are typing (order by longer typer, 'Other10' just recently restarted typing)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js new file mode 100644 index 00000000000..95caee984d4 --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js @@ -0,0 +1,32 @@ +odoo.define('mail/static/src/components/thread_typing_icon/thread_typing_icon.js', function (require) { +'use strict'; + +const { Component } = owl; + +class ThreadTypingIcon extends Component {} + +Object.assign(ThreadTypingIcon, { + defaultProps: { + animation: 'none', + size: 'small', + }, + props: { + animation: { + type: String, + validate: prop => ['bounce', 'none', 'pulse'].includes(prop), + }, + size: { + type: String, + validate: prop => ['small', 'medium'].includes(prop), + }, + title: { + type: String, + optional: true, + } + }, + template: 'mail.ThreadTypingIcon', +}); + +return ThreadTypingIcon; + +}); diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss new file mode 100644 index 00000000000..ac3c5b2fb83 --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------ +// Variables +// ------------------------------------------------------------------ + +$o-thread-typing-icon-size-medium: 5px; +$o-thread-typing-icon-size-small: 3px; + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon { + display: flex; + align-items: center; +} + +.o_ThreadTypingIcon_dot { + display: flex; + flex: 0 0 auto; +} + +.o_ThreadTypingIcon_separator { + min-width: 1px; + flex: 1 0 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon_dot { + border-radius: 50%; + background: gray('500'); + + &.o-sizeMedium { + width: $o-thread-typing-icon-size-medium; + height: $o-thread-typing-icon-size-medium; + } + + &.o-sizeSmall { + width: $o-thread-typing-icon-size-small; + height: $o-thread-typing-icon-size-small; + } +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon_dot.o-animationBounce { + + // Note: duplicated animation because dependent on size, and current SASS version doesn't support var() + &.o-sizeMedium { + animation: o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation 1.5s linear infinite; + } + + &.o-sizeSmall { + animation: o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation 1.5s linear infinite; + } + + &.o_ThreadTypingIcon_dot2 { + animation-delay: -1.35s; + } + + &.o_ThreadTypingIcon_dot3 { + animation-delay: -1.2s; + } +} + +.o_ThreadTypingIcon_dot.o-animationPulse { + animation: o_ThreadTypingIcon_dot_animationPulse_animation 1.5s linear infinite; + + &.o_ThreadTypingIcon_dot2 { + animation-delay: -1.35s; + } + + &.o_ThreadTypingIcon_dot3 { + animation-delay: -1.2s; + } +} + +@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation { + 0%, 40%, 100% { + transform: initial; + } + 20% { + transform: translateY(-$o-thread-typing-icon-size-medium); + } +} + +@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation { + 0%, 40%, 100% { + transform: initial; + } + 20% { + transform: translateY(-$o-thread-typing-icon-size-small); + } +} + + +@keyframes o_ThreadTypingIcon_dot_animationPulse_animation { + 0%, 40%, 100% { + opacity: initial; + } + 20% { + opacity: 25%; + } +} diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml new file mode 100644 index 00000000000..1bdb4adaf92 --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadTypingIcon" owl="1"> + <div class="o_ThreadTypingIcon" t-att-title="props.title"> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot1" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + <span class="o_ThreadTypingIcon_separator"/> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot2" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + <span class="o_ThreadTypingIcon_separator"/> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot3" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.js b/addons/mail/static/src/components/thread_viewer/thread_viewer.js new file mode 100644 index 00000000000..98c3c6edfb6 --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.js @@ -0,0 +1,199 @@ +odoo.define('mail/static/src/components/thread_viewer/thread_viewer.js', function (require) { +'use strict'; + +const components = { + Composer: require('mail/static/src/components/composer/composer.js'), + MessageList: require('mail/static/src/components/message_list/message_list.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadViewer extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + useStore((...args) => this._useStoreSelector(...args)); + /** + * Reference of the composer. Useful to set focus on composer when + * thread has the focus. + */ + this._composerRef = useRef('composer'); + /** + * Reference of the message list. Useful to determine scroll positions. + */ + this._messageListRef = useRef('messageList'); + } + + mounted() { + this._update(); + } + + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Focus the thread. If it has a composer, focus it. + */ + focus() { + if (!this._composerRef.comp) { + return; + } + this._composerRef.comp.focus(); + } + + /** + * Focusout the thread. + */ + focusout() { + if (!this._composerRef.comp) { + return; + } + this._composerRef.comp.focusout(); + } + + /** + * Get the scroll position in the message list. + * + * @returns {integer|undefined} + */ + getScrollTop() { + if (!this._messageListRef.comp) { + return undefined; + } + return this._messageListRef.comp.getScrollTop(); + } + + /** + * @returns {mail.thread_viewer} + */ + get threadViewer() { + return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when thread component is mounted or patched. + * + * @private + */ + async _update() { + const messageList = this._messageListRef.comp; + this.trigger('o-rendered'); + /** + * Control panel may offset scrolling position of message list due to + * height of buttons. To prevent this, control panel re-render is + * triggered before message list. Correct way should be to adjust + * scroll positions after everything has been rendered, but OWL doesn't + * have such an API for the moment. + */ + if (messageList) { + messageList.adjustFromComponentHints(); + } + } + + /** + * Returns data selected from the store. + * + * @private + * @param {Object} props + * @returns {Object} + */ + _useStoreSelector(props) { + const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId); + const thread = threadViewer ? threadViewer.thread : undefined; + const threadCache = threadViewer ? threadViewer.threadCache : undefined; + return { + isDeviceMobile: this.env.messaging.device.isMobile, + thread: thread ? thread.__state : undefined, + threadCache: threadCache ? threadCache.__state : undefined, + threadViewer: threadViewer ? threadViewer.__state : undefined, + }; + } + +} + +Object.assign(ThreadViewer, { + components, + defaultProps: { + composerAttachmentsDetailsMode: 'auto', + hasComposer: false, + hasMessageCheckbox: false, + hasSquashCloseMessages: false, + haveMessagesAuthorRedirect: false, + haveMessagesMarkAsReadIcon: false, + haveMessagesReplyIcon: false, + order: 'asc', + showComposerAttachmentsExtensions: true, + showComposerAttachmentsFilenames: true, + }, + props: { + composerAttachmentsDetailsMode: { + type: String, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + hasComposer: Boolean, + hasComposerCurrentPartnerAvatar: { + type: Boolean, + optional: true, + }, + hasComposerSendButton: { + type: Boolean, + optional: true, + }, + /** + * If set, determines whether the composer should display status of + * members typing on related thread. When this prop is not provided, + * it defaults to composer component default value. + */ + hasComposerThreadTyping: { + type: Boolean, + optional: true, + }, + hasMessageCheckbox: Boolean, + hasSquashCloseMessages: Boolean, + haveMessagesAuthorRedirect: Boolean, + haveMessagesMarkAsReadIcon: Boolean, + haveMessagesReplyIcon: Boolean, + /** + * Set the initial scroll position of message list on mount. Note that + * this prop is not directly passed to message list as props because + * it may compute scroll top without composer, and then composer may alter + * them on mount. To solve this issue, thread handles setting initial scroll + * positions, so that this is always done after composer has been mounted. + * (child `mounted()` are called before parent `mounted()`) + */ + messageListInitialScrollTop: { + type: Number, + optional: true + }, + order: { + type: String, + validate: prop => ['asc', 'desc'].includes(prop), + }, + selectedMessageLocalId: { + type: String, + optional: true, + }, + showComposerAttachmentsExtensions: Boolean, + showComposerAttachmentsFilenames: Boolean, + threadViewerLocalId: String, + }, + template: 'mail.ThreadViewer', +}); + +return ThreadViewer; + +}); diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.scss b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss new file mode 100644 index 00000000000..9b39a493979 --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadViewer { + display: flex; + position: relative; + flex-flow: column; + overflow: auto; +} + +.o_ThreadViewer_composer { + flex: 0 0 auto; +} + +.o_ThreadViewer_loading { + display: flex; + align-self: center; + flex: 1 1 auto; + align-items: center; +} + +.o_ThreadViewer_messageList { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadViewer { + background-color: gray('100'); + +} + +.o_ThreadViewer_loadingIcon { + margin-right: 3px; +} diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.xml b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml new file mode 100644 index 00000000000..e292ac4e4ec --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadViewer" owl="1"> + <div class="o_ThreadViewer" t-att-data-thread-local-id="threadViewer and threadViewer.thread and threadViewer.thread.localId"> + <t t-if="threadViewer"> + <t t-if="threadViewer.isShowingLoading" name="loadingCondition"> + <div class="o_ThreadViewer_loading"> + <span><i class="o_ThreadViewer_loadingIcon fa fa-spinner fa-spin" title="Loading..." role="img"/>Loading...</span> + </div> + </t> + <t t-elif="threadViewer.threadCache.isLoaded"> + <MessageList + class="o_ThreadViewer_messageList" + hasMessageCheckbox="props.hasMessageCheckbox" + hasSquashCloseMessages="props.hasSquashCloseMessages" + haveMessagesAuthorRedirect="props.haveMessagesAuthorRedirect" + haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" + haveMessagesReplyIcon="props.haveMessagesReplyIcon" + order="props.order" + selectedMessageLocalId="props.selectedMessageLocalId" + threadViewerLocalId="threadViewer.localId" + t-ref="messageList" + /> + </t> + <t t-if="props.hasComposer"> + <Composer + class="o_ThreadViewer_composer" + attachmentsDetailsMode="props.composerAttachmentsDetailsMode" + composerLocalId="threadViewer.thread.composer.localId" + hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar" + hasSendButton="props.hasComposerSendButton" + hasThreadTyping="props.hasComposerThreadTyping" + isFocusOnMount="!env.messaging.device.isMobile" + showAttachmentsExtensions="props.showComposerAttachmentsExtensions" + showAttachmentsFilenames="props.showComposerAttachmentsFilenames" + t-ref="composer" + /> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js new file mode 100644 index 00000000000..1c7d24a86e1 --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js @@ -0,0 +1,364 @@ +odoo.define('mail/static/src/components/thread_viewer/thread_viewer_tests.js', function (require) { +'use strict'; + +const components = { + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + dragenterFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_viewer', {}, function () { +QUnit.module('thread_viewer_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + /** + * @param {mail.thread_viewer} threadViewer + * @param {Object} [otherProps={}] + * @param {Object} [param2={}] + * @param {boolean} [param2.isFixedSize=false] + */ + this.createThreadViewerComponent = async (threadViewer, otherProps = {}, { isFixedSize = false } = {}) => { + const ThreadViewerComponent = components.ThreadViewer; + ThreadViewerComponent.env = this.env; + this.component = new ThreadViewerComponent( + null, + Object.assign({ threadViewerLocalId: threadViewer.localId }, otherProps) + ); + let target; + if (isFixedSize) { + // needed to allow scrolling in some tests + const div = document.createElement('div'); + Object.assign(div.style, { + display: 'flex', + 'flex-flow': 'column', + height: '300px', + }); + this.widget.el.append(div); + target = div; + } else { + target = this.widget.el; + } + await afterNextRender(() => this.component.mount(target)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.ThreadViewer.env; + }, +}); + +QUnit.test('dragover files on thread with composer', async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer, { hasComposer: true }); + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_ThreadViewer')) + ); + assert.ok( + document.querySelector('.o_Composer_dropZone'), + "should have dropzone when dragging file over the thread" + ); +}); + +QUnit.test('message list desc order', async function (assert) { + assert.expect(8); + + let lastId = 10000; + let amountOfCalls = 0; + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + amountOfCalls ++; + assert.step(`message_fetch_${amountOfCalls}`); + // Just return 30 different messages + const messagesData = [...Array(30).keys()].reduce(function (acc, i) { + acc.push({ + author_id: [i + 1, `Author #${i}`], + body: `<p>The message</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: lastId - i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + return acc; + }, []); + lastId -= 30; + return messagesData; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer, { order: 'desc' }, { isFixedSize: true }); + const messageItems = document.querySelectorAll(`.o_MessageList_item`); + assert.notOk( + messageItems[0].classList.contains("o_MessageList_loadMore"), + "load more link should NOT be before messages" + ); + assert.ok( + messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"), + "load more link should be after messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "should have 30 messages at the beginning" + ); + + // scroll to bottom + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = + document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "should have 60 messages after scrolled to bottom" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "scrolling to top should not trigger any message fetching" + ); + assert.verifySteps(['message_fetch_1', 'message_fetch_2']); +}); + +QUnit.test('message list asc order', async function (assert) { + assert.expect(8); + + let lastId = 10000; + let amountOfCalls = 0; + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + amountOfCalls ++; + assert.step(`message_fetch_${amountOfCalls}`); + // Just return 30 different messages + const messagesData = [...Array(30).keys()].reduce(function (acc, i) { + acc.push({ + author_id: [i + 1, `Author #${i}`], + body: `<p>The message</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: lastId - i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + return acc; + }, []); + lastId -= 30; + return messagesData; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer, { order: 'asc' }, { isFixedSize: true }); + const messageItems = document.querySelectorAll(`.o_MessageList_item`); + assert.notOk( + messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"), + "load more link should be before messages" + ); + assert.ok( + messageItems[0].classList.contains("o_MessageList_loadMore"), + "load more link should NOT be after messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "should have 30 messages at the beginning" + ); + + // scroll to top + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "should have 60 messages after scrolled to top" + ); + + // scroll to bottom + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = + document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "scrolling to bottom should not trigger any message fetching" + ); + assert.verifySteps(['message_fetch_1', 'message_fetch_2']); +}); + +QUnit.test('mark channel as fetched when a new message is loaded and as seen when message is visible', async function (assert) { + assert.expect(7); + + await this.start({ + mockRPC(route, args) { + if (args.method === 'channel_fetched') { + assert.strictEqual( + args.args[0][0], + 100, + 'channel_fetched is called on the right channel id' + ); + assert.strictEqual( + args.model, + 'mail.channel', + 'channel_fetched is called on the right channel model' + ); + assert.step('rpc_channel_fetched:called'); + } else if (args.method === 'channel_seen') { + assert.strictEqual( + args.args[0][0], + 100, + 'channel_seen is called on the right channel id' + ); + assert.strictEqual( + args.model, + 'mail.channel', + 'channel_seeb is called on the right channel model' + ); + assert.step('rpc_channel_seen:called'); + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + isPinned: true, // just to avoid joinChannel to be called + members: [['insert', [ + { + email: "john@example.com", + id: this.env.session.partner_id, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer); + const notifications = [ + [['myDB', 'mail.channel', 100], { + channelId: 100, + id: 1, + body: "<p>fdsfsd</p>", + author_id: [10, "Fred"], + model: "mail.channel", + channel_ids: [100], + }] + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps( + ['rpc_channel_fetched:called', 'rpc_channel_seen:called'], + "Channel should have been fetched before having been seen" + ); +}); + +}); +}); +}); + +}); |
