diff options
Diffstat (limited to 'addons/mail/static/src/components/activity')
4 files changed, 1695 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 00000000..1ee7ecf3 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.js @@ -0,0 +1,199 @@ +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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.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); + useShouldUpdateBasedOnProps(); + 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 + */ + _onClick(ev) { + if ( + ev.target.tagName === 'A' && + ev.target.dataset.oeId && + ev.target.dataset.oeModel + ) { + this.env.messaging.openProfile({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + // avoid following dummy href + ev.preventDefault(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickCancel(ev) { + ev.preventDefault(); + await this.activity.deleteServerRecord(); + this.trigger('reload', { keepChanges: true }); + } + + /** + * @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 00000000..64a0ceac --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.scss @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------ +// 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_note p { + margin-bottom: 0; +} + +.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); +} + +// From python template +.o_mail_note_title { + margin-top: map-get($spacers, 2); +} + +.o_mail_note_title + div p { + margin-bottom: 0; +} + +// ------------------------------------------------------------------ +// 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 00000000..e5e9832c --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.xml @@ -0,0 +1,153 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Activity" owl="1"> + <div class="o_Activity" t-on-click="_onClick"> + <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> + + <t t-if="state.areDetailsVisible"> + <div class="o_Activity_details"> + <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> + </t> + + <t t-if="activity.note"> + <div class="o_Activity_note"> + <t t-raw="activity.note"/> + </div> + </t> + + <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.thread.id" + uploadModel="activity.thread.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 00000000..1c260f07 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity_tests.js @@ -0,0 +1,1157 @@ +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, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = 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() { + beforeEach(this); + + this.createActivityComponent = async function (activity) { + await createRootComponent(this, components.Activity, { + props: { activityLocalId: activity.localId }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('activity simplest layout', async function (assert) { + assert.expect(12); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + id: 12, + thread: [['insert', { id: 42, 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_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({ + id: 12, + note: 'There is no good or bad note', + thread: [['insert', { id: 42, 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_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), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, 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_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), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, 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_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), + id: 12, + state: 'today', + thread: [['insert', { id: 42, 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_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), + id: 12, + state: 'overdue', + thread: [['insert', { id: 42, 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_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), + id: 12, + state: 'overdue', + thread: [['insert', { id: 42, 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_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({ + id: 12, + summary: 'test summary', + thread: [['insert', { id: 42, 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_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({ + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + 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, display_name: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, model: 'res.partner' }]], + 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, display_name: "Pauvre pomme" }]], + creator: [['insert', { id: 1, display_name: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, model: 'res.partner' }]], + 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({ + id: 12, + mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]], + thread: [['insert', { id: 42, 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_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({ + id: 12, + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + thread: [['insert', { id: 42, 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({ + id: 12, + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + thread: [['insert', { id: 42, 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_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', + id: 12, + thread: [['insert', { id: 42, 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_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', + id: 12, + thread: [['insert', { id: 42, 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_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 mark as done popover should focus feedback input on open [REQUIRE FOCUS]', 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: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + + assert.containsOnce( + document.body, + '.o_Activity', + "should have activity component" + ); + assert.containsOnce( + document.body, + '.o_Activity_markDoneButton', + "should have activity Mark as Done button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelector('.o_ActivityMarkDonePopover_feedback'), + document.activeElement, + "the popover textarea should have the focus" + ); +}); + +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" }]], + thread: [['insert', { id: 42, 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.push({ + 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'].insert( + 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", + }]], + thread: [['insert', { id: 42, 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> + `, + }); + await createRootComponent(this, ParentComponent, { + props: { activityLocalId: activity.localId }, + target: 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" + ); +}); + +QUnit.test('activity mark done popover close on ESCAPE', async function (assert) { + // This test is not in activity_mark_done_popover_tests.js as it requires the activity mark done + // component to have a parent in order to allow testing interactions the popover. + assert.expect(2); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + + await this.createActivityComponent(activity); + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ActivityMarkDonePopover`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover', + "ESCAPE pressed should have closed the mark done popover" + ); +}); + +QUnit.test('activity mark done popover click on discard', async function (assert) { + // This test is not in activity_mark_done_popover_tests.js as it requires the activity mark done + // component to have a parent in order to allow testing interactions the popover. + assert.expect(3); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + 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" + ); + await afterNextRender(() => + document.querySelector('.o_ActivityMarkDonePopover_discardButton').click() + ); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover', + "Discard button clicked should have closed the mark done popover" + ); +}); + +QUnit.test('data-oe-id & data-oe-model link redirection on click', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should open view" + ); + assert.strictEqual( + payload.action.res_model, + 'some.model', + "action should open view on 'some.model' model" + ); + assert.strictEqual( + payload.action.res_id, + 250, + "action should open view on 250" + ); + assert.step('do-action:openFormView_some.model_250'); + }); + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + note: `<p><a href="#" data-oe-id="250" data-oe-model="some.model">some.model_250</a></p>`, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.containsOnce( + document.body, + '.o_Activity_note', + "activity should have a note" + ); + assert.containsOnce( + document.querySelector('.o_Activity_note'), + 'a', + "activity note should have a link" + ); + + document.querySelector(`.o_Activity_note a`).click(); + assert.verifySteps( + ['do-action:openFormView_some.model_250'], + "should have open form view on related record after click on link" + ); +}); + +}); +}); +}); + +}); |
