From 841721ead9f05fe73bd8c2f6bc0ce30f25213307 Mon Sep 17 00:00:00 2001
From: Aaron Bohy <aab@odoo.com>
Date: Wed, 29 Apr 2020 07:21:48 +0000
Subject: [PATCH] [IMP] mail: add ListActivity widget

This commit adds a tweaked version of the kanban_activity widget
for the list view. This widget displays the summary of the next
activity (and fallbacks on the activity type if there is no
summary). It can be set by defining widget='list_activity' on
field activity_ids.

Part of task 2195254
---
 addons/mail/static/src/js/activity.js         |  62 +++++-
 .../mail/static/src/scss/mail_activity.scss   |  19 ++
 .../static/src/xml/web_kanban_activity.xml    |   8 +-
 addons/mail/static/tests/chatter_tests.js     | 179 +++++++++++++++++-
 4 files changed, 264 insertions(+), 4 deletions(-)

diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js
index 9735ace6f175..ed060fe1e8d3 100644
--- a/addons/mail/static/src/js/activity.js
+++ b/addons/mail/static/src/js/activity.js
@@ -14,6 +14,7 @@ var time = require('web.time');
 
 var QWeb = core.qweb;
 var _t = core._t;
+const _lt = core._lt;
 
 /**
  * Fetches activities and postprocesses them.
@@ -646,14 +647,14 @@ var Activity = BasicActivity.extend({
 // Activities Widget for Kanban views ('kanban_activity' widget)
 // -----------------------------------------------------------------------------
 var KanbanActivity = BasicActivity.extend({
-    className: 'o_mail_activity_kanban',
     template: 'mail.KanbanActivity',
     events: _.extend({}, BasicActivity.prototype.events, {
         'show.bs.dropdown': '_onDropdownShow',
     }),
     fieldDependencies: _.extend({}, BasicActivity.prototype.fieldDependencies, {
         activity_exception_decoration: {type: 'selection'},
-        activity_exception_icon: {type: 'char'}
+        activity_exception_icon: {type: 'char'},
+        activity_state: {type: 'selection'},
     }),
 
     /**
@@ -756,6 +757,62 @@ var KanbanActivity = BasicActivity.extend({
     },
 });
 
+// -----------------------------------------------------------------------------
+// Activities Widget for List views ('list_activity' widget)
+// -----------------------------------------------------------------------------
+const ListActivity = KanbanActivity.extend({
+    template: 'mail.ListActivity',
+    events: Object.assign({}, KanbanActivity.prototype.events, {
+        'click .dropdown-menu.o_activity': '_onDropdownClicked',
+    }),
+    fieldDependencies: _.extend({}, KanbanActivity.prototype.fieldDependencies, {
+        activity_summary: {type: 'char'},
+        activity_type_id: {type: 'many2one', relation: 'mail.activity.type'},
+    }),
+    label: _lt('Next Activity'),
+
+    //--------------------------------------------------------------------------
+    // Private
+    //--------------------------------------------------------------------------
+
+    /**
+     * @override
+     * @private
+     */
+    _render: async function () {
+        await this._super(...arguments);
+        // set the 'special_click' prop on the activity icon to prevent from
+        // opening the record when the user clicks on it (as it opens the
+        // activity dropdown instead)
+        this.$('.o_activity_btn > span').prop('special_click', true);
+        if (this.value.count) {
+            let text;
+            if (this.recordData.activity_exception_decoration) {
+                text = _t('Warning');
+            } else {
+                text = this.recordData.activity_summary ||
+                          this.recordData.activity_type_id.data.display_name;
+            }
+            this.$('.o_activity_summary').text(text);
+        }
+    },
+
+    //--------------------------------------------------------------------------
+    // Handlers
+    //--------------------------------------------------------------------------
+
+    /**
+     * As we are in a list view, we don't want clicks inside the activity
+     * dropdown to open the record in a form view.
+     *
+     * @private
+     * @param {MouseEvent} ev
+     */
+    _onDropdownClicked: function (ev) {
+        ev.stopPropagation();
+    },
+});
+
 // -----------------------------------------------------------------------------
 // Activity Exception Widget to display Exception icon ('activity_exception' widget)
 // -----------------------------------------------------------------------------
@@ -800,6 +857,7 @@ var ActivityException = AbstractField.extend({
 field_registry
     .add('mail_activity', Activity)
     .add('kanban_activity', KanbanActivity)
+    .add('list_activity', ListActivity)
     .add('activity_exception', ActivityException);
 
 return Activity;
diff --git a/addons/mail/static/src/scss/mail_activity.scss b/addons/mail/static/src/scss/mail_activity.scss
index 9f2a9f7002bb..6b487685ef3a 100644
--- a/addons/mail/static/src/scss/mail_activity.scss
+++ b/addons/mail/static/src/scss/mail_activity.scss
@@ -145,6 +145,25 @@
     }
 }
 
+/* list_activity widget */
+.o_list_view {
+    .o_list_table tbody > tr {
+        > td.o_data_cell.o_list_activity_cell {
+            overflow: visible; // allow the activity dropdown to overflow
+            .o_mail_activity {
+                display: flex;
+                max-width: 275px;
+                .o_activity_btn {
+                    margin-right: 3px;
+                }
+                .o_activity_summary {
+                    @include o-text-overflow;
+                }
+            }
+        }
+    }
+}
+
 /* Kanban View */
 .o_kanban_record{
     .o_kanban_inline_block {
diff --git a/addons/mail/static/src/xml/web_kanban_activity.xml b/addons/mail/static/src/xml/web_kanban_activity.xml
index 2c5574c826cf..ac79682e3554 100644
--- a/addons/mail/static/src/xml/web_kanban_activity.xml
+++ b/addons/mail/static/src/xml/web_kanban_activity.xml
@@ -2,7 +2,7 @@
 <templates xml:space="preserve">
 
 <t t-name="mail.KanbanActivity">
-    <div class="o_kanban_inline_block dropdown o_kanban_selection o_mail_activity">
+    <div class="o_kanban_inline_block dropdown o_mail_activity">
         <a class="dropdown-toggle o-no-caret o_activity_btn" data-toggle="dropdown" role="button">
             <!-- span classes are generated dynamically (see _render) -->
             <span t-att-title="widget.selection[widget.activityState]" role="img" t-att-aria-label="widget.selection[widget.activity_state]"/>
@@ -11,6 +11,12 @@
     </div>
 </t>
 
+<t t-name="mail.ListActivity" t-extend="mail.KanbanActivity">
+    <t t-jquery=".o_mail_activity" t-operation="append">
+        <span class="o_activity_summary"/>
+    </t>
+</t>
+
 <t t-name="mail.KanbanActivityLoading">
     <div class="dropdown-item text-center o_no_activity">
         <span class="fa fa-spinner fa-spin fa-2x" role="img" aria-label="Loading..." title="Loading..."/>
diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js
index 8e7ea9beb9d8..96e8f6f2e74b 100644
--- a/addons/mail/static/tests/chatter_tests.js
+++ b/addons/mail/static/tests/chatter_tests.js
@@ -39,7 +39,7 @@ QUnit.module('Chatter', {
                     im_status: 'online',
                 }]
             },
-            partner: {
+            'partner': {
                 fields: {
                     display_name: { string: "Displayed name", type: "char" },
                     foo: {string: "Foo", type: "char", default: "My little Foo Value"},
@@ -61,6 +61,11 @@ QUnit.module('Chatter', {
                         relation: 'mail.activity',
                         relation_field: 'res_id',
                     },
+                    activity_type_id: {
+                        string: "Activity type",
+                        type: "many2one",
+                        relation: "mail.activity.type",
+                    },
                     activity_exception_decoration: {
                         string: 'Decoration',
                         type: 'selection',
@@ -903,6 +908,178 @@ QUnit.test('kanban activity widget popover test', async function (assert) {
     kanban.destroy();
 });
 
+QUnit.test('list activity widget with no activity', async function (assert) {
+    assert.expect(4);
+
+    const list = await createView({
+        View: ListView,
+        model: 'partner',
+        data: this.data,
+        arch: '<list><field name="activity_ids" widget="list_activity"/></list>',
+        mockRPC: function (route) {
+            assert.step(route);
+            return this._super(...arguments);
+        },
+        session: {uid: 2},
+    });
+
+    assert.containsOnce(list, '.o_mail_activity .o_activity_color_default');
+    assert.strictEqual(list.$('.o_activity_summary').text(), '');
+
+    assert.verifySteps(['/web/dataset/search_read']);
+
+    list.destroy();
+});
+
+QUnit.test('list activity widget with activities', async function (assert) {
+    assert.expect(6);
+
+    this.data.partner.records[0].activity_ids = [1, 4];
+    this.data.partner.records[0].activity_state = 'today';
+    this.data.partner.records[0].activity_summary = 'Call with Al';
+    this.data.partner.records[0].activity_type_id = 3;
+
+    this.data.partner.records.push({
+        id: 44,
+        activity_ids: [2],
+        activity_state: 'planned',
+        activity_summary: false,
+        activity_type_id: 2,
+    });
+
+    const list = await createView({
+        View: ListView,
+        model: 'partner',
+        data: this.data,
+        arch: '<list><field name="activity_ids" widget="list_activity"/></list>',
+        mockRPC: function (route) {
+            assert.step(route);
+            return this._super(...arguments);
+        },
+    });
+
+    const $firstRow = list.$('.o_data_row:first');
+    assert.containsOnce($firstRow, '.o_mail_activity .o_activity_color_today');
+    assert.strictEqual($firstRow.find('.o_activity_summary').text(), 'Call with Al');
+
+    const $secondRow = list.$('.o_data_row:nth(1)');
+    assert.containsOnce($secondRow, '.o_mail_activity .o_activity_color_planned');
+    assert.strictEqual($secondRow.find('.o_activity_summary').text(), 'Type 2');
+
+    assert.verifySteps(['/web/dataset/search_read']);
+
+    list.destroy();
+});
+
+QUnit.test('list activity widget with exception', async function (assert) {
+    assert.expect(4);
+
+    this.data.partner.records[0].activity_ids = [1];
+    this.data.partner.records[0].activity_state = 'today';
+    this.data.partner.records[0].activity_summary = 'Call with Al';
+    this.data.partner.records[0].activity_type_id = 3;
+    this.data.partner.records[0].activity_exception_decoration = 'warning';
+    this.data.partner.records[0].activity_exception_icon = 'fa-warning';
+
+    const list = await createView({
+        View: ListView,
+        model: 'partner',
+        data: this.data,
+        arch: '<list><field name="activity_ids" widget="list_activity"/></list>',
+        mockRPC: function (route) {
+            assert.step(route);
+            return this._super(...arguments);
+        },
+    });
+
+    assert.containsOnce(list, '.o_activity_color_today.text-warning.fa-warning');
+    assert.strictEqual(list.$('.o_activity_summary').text(), 'Warning');
+
+    assert.verifySteps(['/web/dataset/search_read']);
+
+    list.destroy();
+});
+
+QUnit.test('list activity widget: open dropdown', async function (assert) {
+    assert.expect(9);
+
+    this.data.partner.records[0].activity_ids = [1, 4];
+    this.data.partner.records[0].activity_state = 'today';
+    this.data.partner.records[0].activity_summary = 'Call with Al';
+    this.data.partner.records[0].activity_type_id = 3;
+    this.data['mail.activity'].records = [{
+        id: 1,
+        display_name: "Call with Al",
+        date_deadline: moment().format("YYYY-MM-DD"), // now
+        can_write: true,
+        state: "today",
+        user_id: 2,
+        create_uid: 2,
+        activity_type_id: 3,
+    }, {
+        id: 4,
+        display_name: "Meet FP",
+        date_deadline: moment().add(1, 'day').format("YYYY-MM-DD"), // tomorrow
+        can_write: true,
+        state: "planned",
+        user_id: 2,
+        create_uid: 2,
+        activity_type_id: 1,
+    }];
+
+    const list = await createView({
+        View: ListView,
+        model: 'partner',
+        data: this.data,
+        arch: `
+            <list>
+                <field name="foo"/>
+                <field name="activity_ids" widget="list_activity"/>
+            </list>`,
+        mockRPC: function (route, args) {
+            assert.step(args.method || route);
+            if (args.method === 'action_feedback') {
+                this.data.partner.records[0].activity_ids = [4];
+                this.data.partner.records[0].activity_state = 'planned';
+                this.data.partner.records[0].activity_summary = 'Meet FP';
+                this.data.partner.records[0].activity_type_id = 1;
+                return Promise.resolve();
+            }
+            return this._super(route, args);
+        },
+        intercepts: {
+            switch_view: () => assert.step('switch_view'),
+        },
+    });
+
+    assert.strictEqual(list.$('.o_activity_summary').text(), 'Call with Al');
+
+    // click on the first record to open it, to ensure that the 'switch_view'
+    // assertion is relevant (it won't be opened as there is no action manager,
+    // but we'll log the 'switch_view' event)
+    await testUtils.dom.click(list.$('.o_data_cell:first'));
+
+    // from this point, no 'switch_view' event should be triggered, as we
+    // interact with the activity widget
+    assert.step('open dropdown');
+    await testUtils.dom.click(list.$('.o_activity_btn span')); // open the popover
+    await testUtils.dom.click(list.$('.o_mark_as_done:first')); // mark the first activity as done
+    await testUtils.dom.click(list.$('.o_activity_popover_done')); // confirm
+
+    assert.strictEqual(list.$('.o_activity_summary').text(), 'Meet FP');
+
+    assert.verifySteps([
+        '/web/dataset/search_read',
+        'switch_view',
+        'open dropdown',
+        'activity_format',
+        'action_feedback',
+        'read',
+    ]);
+
+    list.destroy();
+});
+
 QUnit.test('list activity exception widget with activity', async function (assert) {
     assert.expect(3);
 
-- 
GitLab