[FIX] web,mail: always reload widget mail_activity

Before this rev., the field widget 'mail_activity' kept an internal
cache of fetched activities (like the 'mail_thread' and
'mail_followers'). However, for activities, this cache may cause
issues.

For instance, create a new lead in 'My pipeline', schedule a
'Meeting' activity on that lead (it opens the Calendar view so
that real meetings can be created), create a meeting 2 weeks from
now, go back to the lead form view: the activity still diplays that
the meeting is today (when the view is refreshed, the correct date
is displayed).

This rev. removes the internal cache for this field widget, as the
activities can easily be updated in business flows (unlike messages
or followers).

This rev. also fixes a bug in the BasicModel, where the res_ids of
a list datapoint was altered in place to return a list of x2many
commands instead of the list of ids when being stringified. This
caused a traceback when opening a lead with activities, as soon
as the internal cache had been removed.
This commit is contained in:
Aaron Bohy
2018-03-08 12:39:21 +01:00
parent b4f8ca0a34
commit 3809fc891f
3 changed files with 206 additions and 68 deletions
+83 -64
View File
@@ -2,7 +2,7 @@ odoo.define('mail.Activity', function (require) {
"use strict";
var AbstractField = require('web.AbstractField');
var concurrency = require('web.concurrency');
var BasicModel = require('web.BasicModel');
var core = require('web.core');
var field_registry = require('web.field_registry');
var time = require('web.time');
@@ -11,6 +11,63 @@ var utils = require('mail.utils');
var QWeb = core.qweb;
var _t = core._t;
/**
* Fetches activities and postprocesses them.
*
* This standalone function performs an RPC, but to do so, it needs an instance
* of a widget that implements the _rpc() function.
*
* @todo i'm not very proud of the widget instance given in arguments, we should
* probably try to do it a better way in the future.
*
* @param {Widget} self a widget instance that can perform RPCs
* @param {Array} ids the ids of activities to read
* @return {Deferred<Array>} resolved with the activities
*/
function _readActivities(self, ids) {
if (!ids.length) {
return $.when([]);
}
return self._rpc({
model: 'mail.activity',
method: 'read',
args: [ids],
}).then(function (activities) {
// convert create_date and date_deadline to moments
_.each(activities, function (activity) {
activity.create_date = moment(time.auto_str_to_date(activity.create_date));
activity.date_deadline = moment(time.auto_str_to_date(activity.date_deadline));
});
// sort activities by due date
activities = _.sortBy(activities, 'date_deadline');
return activities;
});
}
BasicModel.include({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Fetches the activities displayed by the activity field widget in form
* views.
*
* @private
* @param {Object} record - an element from the localData
* @param {string} fieldName
* @return {Deferred<Array>} resolved with the activities
*/
_fetchSpecialActivity: function (record, fieldName) {
var localID = (record._changes && fieldName in record._changes) ?
record._changes[fieldName] :
record.data[fieldName];
return _readActivities(this, this.localData[localID].res_ids);
},
});
/**
* Set the 'label_delay' entry in activity data according to the deadline date
* @param {Array} activities list of activity Object
@@ -44,12 +101,6 @@ var setDelayLabel = function(activities){
};
var AbstractActivityField = AbstractField.extend({
// inherited
init: function () {
this._super.apply(this, arguments);
this.activities = [];
},
// private
_markActivityDone: function (id, feedback) {
return this._rpc({
@@ -59,33 +110,6 @@ var AbstractActivityField = AbstractField.extend({
kwargs: {feedback: feedback},
});
},
_readActivities: function () {
var self = this;
var missing_ids = _.difference(this.value.res_ids, _.pluck(this.activities, 'id'));
var fetch_def;
if (missing_ids.length) {
fetch_def = this._rpc({
model: 'mail.activity',
method: 'read',
args: [missing_ids],
});
}
return $.when(fetch_def).then(function (results) {
// convert create_date and date_deadline to moments
_.each(results, function (activity) {
activity.create_date = moment(time.auto_str_to_date(activity.create_date));
activity.date_deadline = moment(time.auto_str_to_date(activity.date_deadline));
});
// filter out activities that are no longer linked to this record
self.activities = _.filter(self.activities.concat(results || []), function (activity) {
return _.contains(self.value.res_ids, activity.id);
});
// sort activities by due date
self.activities = _.sortBy(self.activities, 'date_deadline');
});
},
_scheduleActivity: function (id, previous_activity_type_id, callback) {
var action = {
type: 'ir.actions.act_window',
@@ -115,41 +139,37 @@ var Activity = AbstractActivityField.extend({
'click .o_activity_unlink': '_onUnlinkActivity',
'click .o_activity_done': '_onMarkActivityDone',
},
specialData: '_fetchSpecialActivity',
// inherited
init: function () {
this._super.apply(this, arguments);
this.dp = new concurrency.DropPrevious();
this.activities = this.record.specialData[this.name];
},
_render: function () {
// note: the rendering of this widget is asynchronous as it needs to
// fetch the details of the linked activities
var self = this;
var fetch_def = this.dp.add(this._readActivities());
return fetch_def.then(function () {
_.each(self.activities, function (activity) {
if (activity.note) {
activity.note = utils.parse_and_transform(activity.note, utils.add_link);
}
});
var activities = setDelayLabel(self.activities);
if (activities.length) {
var nbActivities = _.countBy(activities, 'state');
self.$el.html(QWeb.render('mail.activity_items', {
activities: activities,
nbPlannedActivities: nbActivities.planned,
nbTodayActivities: nbActivities.today,
nbOverdueActivities: nbActivities.overdue,
date_format: time.getLangDateFormat(),
datetime_format: time.getLangDatetimeFormat(),
}));
} else {
self.$el.empty();
_.each(this.activities, function (activity) {
if (activity.note) {
activity.note = utils.parse_and_transform(activity.note, utils.add_link);
}
});
var activities = setDelayLabel(this.activities);
if (activities.length) {
var nbActivities = _.countBy(activities, 'state');
this.$el.html(QWeb.render('mail.activity_items', {
activities: activities,
nbPlannedActivities: nbActivities.planned,
nbTodayActivities: nbActivities.today,
nbOverdueActivities: nbActivities.overdue,
date_format: time.getLangDateFormat(),
datetime_format: time.getLangDatetimeFormat(),
}));
} else {
this.$el.empty();
}
},
_reset: function (record) {
this._super.apply(this, arguments);
this.activities = this.record.specialData[this.name];
// the mail widgets being persistent, one need to update the res_id on reset
this.res_id = record.res_id;
},
@@ -184,9 +204,6 @@ var Activity = AbstractActivityField.extend({
});
return this.do_action(action, {
on_close: function () {
// remove the edited activity from the array of fetched activities to
// force a reload of that activity
self.activities = _.reject(self.activities, {id: activity_id});
self._reload({activity: true, thread: true});
},
});
@@ -300,10 +317,10 @@ var KanbanActivity = AbstractActivityField.extend({
_renderDropdown: function () {
var self = this;
this.$('.o_activity').html(QWeb.render("mail.KanbanActivityLoading"));
return this._readActivities().then(function () {
return _readActivities(this, this.value.res_ids).then(function (activities) {
self.$('.o_activity').html(QWeb.render("mail.KanbanActivityDropdown", {
selection: self.selection,
records: _.groupBy(setDelayLabel(self.activities), 'state'),
records: _.groupBy(setDelayLabel(activities), 'state'),
uid: self.getSession().uid,
}));
});
@@ -316,7 +333,9 @@ var KanbanActivity = AbstractActivityField.extend({
// handlers
_onButtonClicked: function (event) {
event.preventDefault();
this._renderDropdown();
if (!this.$el.hasClass('open')) {
this._renderDropdown();
}
},
_onMarkActivityDone: function (event) {
event.stopPropagation();
+122 -3
View File
@@ -368,12 +368,12 @@ QUnit.test('kanban activity widget with an activity', function (assert) {
// click on the activity button to re-open dropdown
$record.find('.o_activity_btn').click();
assert.strictEqual(rpcCount, 2, 'no RPC should be done as the activities are now in cache');
assert.strictEqual(rpcCount, 3, 'should have reloaded the activities');
// mark activity as done
$record.find('.o_mark_as_done').click();
$record = kanban.$('.o_kanban_record').first(); // the record widget has been reset
assert.strictEqual(rpcCount, 4, 'should have done an RPC to mark activity as done, and a read');
assert.strictEqual(rpcCount, 5, 'should have done an RPC to mark activity as done, and a read');
assert.ok($record.find('.o_mail_activity .o_activity_color_default:not(.o_activity_color_today)').length,
"activity widget should have been updated correctly");
assert.strictEqual($record.find('.o_mail_activity.open').length, 1,
@@ -732,6 +732,125 @@ QUnit.test('chatter: Attachment viewer', function (assert) {
form.destroy();
});
QUnit.test('form activity widget: read RPCs', function (assert) {
// Records of model 'mail.activity' may be updated in business flows (e.g.
// the date of a 'Meeting' activity is updated when the associated meeting
// is dragged&dropped in the Calendar view). Because of that, the activities
// must be reloaded when the form is reloaded (the widget can't keep an
// internal cache).
assert.expect(6);
this.data.partner.records[0].activity_ids = [1];
this.data.partner.records[0].activity_state = 'today';
this.data['mail.activity'].records = [{
id: 1,
display_name: "An activity",
date_deadline: moment().format("YYYY-MM-DD"), // now
state: "today",
user_id: 2,
activity_type_id: 2,
}];
var nbReads = 0;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<div class="oe_chatter">' +
'<field name="activity_ids" widget="mail_activity"/>' +
'</div>' +
'</form>',
res_id: 2,
mockRPC: function (route, args) {
if (args.method === 'read' && args.model === 'mail.activity') {
nbReads++;
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(nbReads, 1, "should have read the activities");
assert.strictEqual(form.$('.o_mail_activity .o_thread_message').length, 1,
"should display an activity");
assert.strictEqual(form.$('.o_mail_activity .o_thread_message .o_activity_date').text(),
'Today', "the activity should be today");
form.$buttons.find('.o_form_button_edit').click();
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(nbReads, 1, "should not have re-read the activities");
// simulate a date change, and a reload of the form view
var tomorrow = moment().add(1, 'day').format("YYYY-MM-DD");
this.data['mail.activity'].records[0].date_deadline = tomorrow;
form.reload();
assert.strictEqual(nbReads, 2, "should have re-read the activities");
assert.strictEqual(form.$('.o_mail_activity .o_thread_message .o_activity_date').text(),
'Tomorrow', "the activity should be tomorrow");
form.destroy();
});
QUnit.test('form activity widget on a new record', function (assert) {
assert.expect(0);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<div class="oe_chatter">' +
'<field name="activity_ids" widget="mail_activity"/>' +
'</div>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'read' && args.model === 'mail.activity') {
throw new Error("should not do a read on mail.activity");
}
return this._super.apply(this, arguments);
},
});
form.destroy();
});
QUnit.test('form activity widget with another x2many field in view', function (assert) {
assert.expect(1);
this.data.partner.fields.m2m = {string: "M2M", type: 'many2many', relation: 'partner'};
this.data.partner.records[0].m2m = [2];
this.data.partner.records[0].activity_ids = [1];
this.data.partner.records[0].activity_state = 'today';
this.data['mail.activity'].records = [{
id: 1,
display_name: "An activity",
date_deadline: moment().format("YYYY-MM-DD"), // now
state: "today",
user_id: 2,
activity_type_id: 2,
}];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="m2m" widget="many2many_tags"/>' +
'<div class="oe_chatter">' +
'<field name="activity_ids" widget="mail_activity"/>' +
'</div>' +
'</form>',
res_id: 2,
});
assert.strictEqual(form.$('.o_mail_activity .o_thread_message').length, 1,
"should display an activity");
form.destroy();
});
QUnit.test('form activity widget: schedule next activity', function (assert) {
assert.expect(5);
this.data.partner.records[0].activity_ids = [1];
@@ -767,7 +886,7 @@ QUnit.test('form activity widget: schedule next activity', function (assert) {
"the feedback should be sent correctly");
return $.when();
}
if (args.method === 'read' && checkReadArgs) {
if (args.method === 'read' && args.model === 'partner' && checkReadArgs) {
assert.deepEqual(args.args[1], ['activity_ids', 'message_ids', 'display_name'],
"should only read the mail fields");
}
@@ -3047,7 +3047,7 @@ var BasicModel = AbstractModel.extend({
if (field.type === 'one2many' || field.type === 'many2many') {
var ids;
if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet
ids = context[fieldName] || [];
ids = context[fieldName] ? context[fieldName].slice(0) : [];
} else {
relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]);
ids = relDataPoint.res_ids.slice(0);