[IMP] mail: ease activities management by adding a shortcut-counter in top menu

This commit improves the use of activities through various applications
by adding a counter of activities displaying next activities grouped by
application. For each application activities are divided into late, today
and future activities in order to achieve a better management.

This commit also merges Inbox and Messaging icons in topbar to keep only
one displaying All / Chat / Channels messages.
This commit is contained in:
pgu-odoo
2017-07-05 16:50:27 +02:00
committed by Thibault Delavallée
parent f5c8bd30a3
commit 07f1e9025d
10 changed files with 416 additions and 61 deletions
+19
View File
@@ -132,16 +132,35 @@ class MailActivity(models.Model):
def create(self, values):
activity = super(MailActivity, self).create(values)
self.env[activity.res_model].browse(activity.res_id).message_subscribe(partner_ids=[activity.user_id.partner_id.id])
self.env['bus.bus'].sendone(
(self._cr.dbname, 'res.partner', activity.user_id.partner_id.id),
{'type': 'activity_updated', 'activity_created': True})
return activity
@api.multi
def write(self, values):
res = super(MailActivity, self).write(values)
if values.get('user_id'):
pre_responsibles = self.mapped('user_id.partner_id')
for activity in self:
self.env[activity.res_model].browse(activity.res_id).message_subscribe(partner_ids=[activity.user_id.partner_id.id])
self.env['bus.bus'].sendone(
(self._cr.dbname, 'res.partner', activity.user_id.partner_id.id),
{'type': 'activity_updated', 'activity_created': True})
for partner in pre_responsibles:
self.env['bus.bus'].sendone(
(self._cr.dbname, 'res.partner', partner.id),
{'type': 'activity_updated', 'activity_deleted': True})
return res
@api.multi
def unlink(self):
for activity in self:
self.env['bus.bus'].sendone(
(self._cr.dbname, 'res.partner', activity.user_id.partner_id.id),
{'type': 'activity_updated', 'activity_deleted': True})
return super(MailActivity, self).unlink()
@api.multi
def action_done(self, feedback=False):
message = self.env['mail.message']
+3 -1
View File
@@ -5,7 +5,7 @@ import logging
from email.utils import formataddr
from odoo import _, api, fields, models, SUPERUSER_ID, tools
from odoo import _, api, fields, models, modules, SUPERUSER_ID, tools
from odoo.exceptions import UserError, AccessError
from odoo.osv import expression
from odoo.tools import pycompat
@@ -420,6 +420,8 @@ class Message(models.Model):
for message in message_values:
message['is_note'] = message['subtype_id'] and subtypes_dict[message['subtype_id'][0]]['internal']
message['subtype_description'] = message['subtype_id'] and subtypes_dict[message['subtype_id'][0]]['description']
if message['model']:
message['module_icon'] = modules.module.get_module_icon(self.env[message['model']]._original_module)
return message_values
#------------------------------------------------------
+31 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, exceptions, fields, models
from odoo import _, api, exceptions, fields, models, modules
from odoo.tools import pycompat
@@ -119,6 +119,36 @@ class Users(models.Model):
def message_get_suggested_recipients(self):
return dict((res_id, list()) for res_id in self._ids)
@api.model
def activity_user_count(self):
query = """SELECT m.name, count(*), act.res_model as model,
CASE
WHEN now()::date - act.date_deadline::date = 0 Then 'today'
WHEN now()::date - act.date_deadline::date > 0 Then 'overdue'
WHEN now()::date - act.date_deadline::date < 0 Then 'planned'
END AS states
FROM mail_activity AS act
JOIN ir_model AS m ON act.res_model_id = m.id
WHERE user_id = %s
GROUP BY m.name, states, act.res_model;
"""
self.env.cr.execute(query, [self.env.uid])
activity_data = self.env.cr.dictfetchall()
user_activities = {}
for activity in activity_data:
if not user_activities.get(activity['model']):
user_activities[activity['model']] = {
'name': activity['name'],
'model': activity['model'],
'icon': modules.module.get_module_icon(self.env[activity['model']]._original_module),
'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0,
}
user_activities[activity['model']]['%s_count' % activity['states']] += activity['count']
user_activities[activity['model']]['total_count'] += activity['count']
return user_activities.values()
class res_groups_mail_channel(models.Model):
""" Update of res.groups class
+20
View File
@@ -137,6 +137,7 @@ function make_message (data) {
model: data.model,
res_id: data.res_id,
url: session.url("/mail/view?message_id=" + data.id),
module_icon:data.module_icon,
};
_.each(_.keys(emoji_substitutions), function (key) {
@@ -476,6 +477,8 @@ function on_partner_notification (data) {
on_channel_seen_notification(data);
} else if (data.info === 'transient_message') {
on_transient_message_notification(data);
} else if (data.type === 'activity_updated') {
onActivityUpdateNodification(data);
} else {
on_chat_session_notification(data);
}
@@ -599,6 +602,9 @@ function on_transient_message_notification (data) {
add_message(data);
}
function onActivityUpdateNodification (data) {
chat_manager.bus.trigger('activity_updated', data);
}
// Public interface
//----------------------------------------------------------------------------------
var ChatManager = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
@@ -1128,6 +1134,20 @@ var ChatManager = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
get_channels_preview: function (channels) {
var channels_preview = _.map(channels, function (channel) {
if (channel.channel_ids && _.contains(channel.channel_ids,"channel_inbox")) {
// map inbox(mail_message) data with existing channel/chat template
var info = _.pick(channel, 'id', 'body', 'avatar_src', 'res_id', 'model', 'module_icon', 'subject','date', 'record_name', 'status', 'displayed_author', 'email_from', 'unread_counter');
info.last_message = {
body: info.body,
date: info.date,
displayed_author: info.displayed_author || info.email_from,
};
info.name = info.record_name || info.subject || info.displayed_author;
info.image_src = info.module_icon || info.avatar_src;
info.message_id = info.id;
info.id = 'channel_inbox';
return info;
}
var info = _.pick(channel, 'id', 'is_chat', 'name', 'status', 'unread_counter');
info.last_message = _.last(channel.cache['[]'].messages);
if (!info.is_chat) {
@@ -268,6 +268,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
chat_manager.bus.on('update_starred', self, self.throttled_renderSidebar);
chat_manager.bus.on('update_channel_unread_counter', self, self.throttled_renderSidebar);
chat_manager.bus.on('update_dm_presence', self, self.throttled_renderSidebar);
chat_manager.bus.on('activity_updated', self, self.throttled_renderSidebar);
self.thread.$el.on("scroll", null, _.debounce(function () {
if (self.thread.is_at_bottom()) {
chat_manager.mark_channel_as_seen(self.channel);
+185 -45
View File
@@ -2,6 +2,8 @@ odoo.define('mail.systray', function (require) {
"use strict";
var core = require('web.core');
var framework = require('web.framework');
var session = require('web.session');
var SystrayMenu = require('web.SystrayMenu');
var Widget = require('web.Widget');
@@ -9,39 +11,6 @@ var chat_manager = require('mail.chat_manager');
var QWeb = core.qweb;
/**
* Menu item appended in the systray part of the navbar, redirects to the Inbox in Discuss
* Also displays the needaction counter (= Inbox counter)
*/
var InboxItem = Widget.extend({
template:'mail.chat.InboxItem',
events: {
"click": "on_click",
},
start: function () {
this.$needaction_counter = this.$('.o_notification_counter');
chat_manager.bus.on("update_needaction", this, this.update_counter);
chat_manager.is_ready.then(this.update_counter.bind(this));
return this._super();
},
update_counter: function () {
var counter = chat_manager.get_needaction_counter();
this.$needaction_counter.text(counter);
this.$el.toggleClass('o_no_notification', !counter);
},
on_click: function (event) {
event.preventDefault();
chat_manager.is_ready.then(this.discuss_redirect.bind(this));
},
discuss_redirect: _.debounce(function () {
var self = this;
this.do_action('mail.mail_channel_action_client_chat', {clear_breadcrumbs: true}).then(function () {
self.trigger_up('hide_app_switcher');
core.bus.trigger('change_menu_section', chat_manager.get_discuss_menu_id());
});
}, 1000, true),
});
/**
* Menu item appended in the systray part of the navbar
*
@@ -56,12 +25,13 @@ var MessagingMenu = Widget.extend({
"click": "on_click",
"click .o_filter_button": "on_click_filter_button",
"click .o_new_message": "on_click_new_message",
"click .o_mail_channel_preview": "on_click_channel",
"click .o_mail_channel_preview": "_onClickChannel",
},
start: function () {
this.$filter_buttons = this.$('.o_filter_button');
this.$channels_preview = this.$('.o_mail_navbar_dropdown_channels');
this.filter = false;
chat_manager.bus.on("update_needaction", this, this.update_counter);
chat_manager.bus.on("update_channel_unread_counter", this, this.update_counter);
chat_manager.is_ready.then(this.update_counter.bind(this));
return this._super();
@@ -70,10 +40,9 @@ var MessagingMenu = Widget.extend({
return this.$el.hasClass('open');
},
update_counter: function () {
var counter = chat_manager.get_unread_conversation_counter();
var counter = chat_manager.get_needaction_counter() + chat_manager.get_unread_conversation_counter();
this.$('.o_notification_counter').text(counter);
this.$el.toggleClass('o_no_notification', !counter);
this.$el.toggleClass('o_unread_chat', !!chat_manager.get_chat_unread_counter());
if (this.is_open()) {
this.update_channels_preview();
}
@@ -94,8 +63,23 @@ var MessagingMenu = Widget.extend({
return channel.type !== 'static';
}
});
chat_manager.get_channels_preview(channels).then(self._render_channels_preview.bind(self));
chat_manager.get_messages({channel_id: 'channel_inbox'}).then(function(result) {
var res = [];
_.each(result, function (message) {
message.unread_counter = 1;
var duplicatedMessage = _.findWhere(res, {model: message.model, 'res_id': message.res_id});
if (message.model && message.res_id && duplicatedMessage) {
message.unread_counter = duplicatedMessage.unread_counter + 1;
res[_.findIndex(res, duplicatedMessage)] = message;
} else {
res.push(message);
}
});
if (self.filter === 'channel_inbox' || !self.filter) {
channels = _.union(channels, res);
}
chat_manager.get_channels_preview(channels).then(self._render_channels_preview.bind(self));
});
});
},
_render_channels_preview: function (channels_preview) {
@@ -136,16 +120,172 @@ var MessagingMenu = Widget.extend({
on_click_new_message: function () {
chat_manager.bus.trigger('open_chat');
},
on_click_channel: function (event) {
var channel_id = $(event.currentTarget).data('channel_id');
var channel = chat_manager.get_channel(channel_id);
if (channel) {
chat_manager.open_channel(channel);
// Handlers
/**
* When a channel is clicked on, we want to open chat/channel window
* If channel is inbox then redirect to that record view
* If record not linked redirect to Inbox
* @private
* @param {MouseEvent} event
*/
_onClickChannel: function (event) {
var channelID = $(event.currentTarget).data('channel_id');
if (channelID == 'channel_inbox') {
var resID = $(event.currentTarget).data('res_id');
var resModel = $(event.currentTarget).data('res_model');
if (resModel && resID) {
this.do_action({
type: 'ir.actions.act_window',
res_model: resModel,
views: [[false, 'form'], [false, 'kanban']],
res_id: resID
});
} else {
// if no model linked redirect to inbox
framework.redirect('mail/view?message_id=channel_inbox');
}
} else {
var channel = chat_manager.get_channel(channelID);
if (channel) {
chat_manager.open_channel(channel);
}
}
},
});
SystrayMenu.Items.push(MessagingMenu);
SystrayMenu.Items.push(InboxItem);
/**
* Menu item appended in the systray part of the navbar, redirects to the next activities of all app
*/
var ActivityMenu = Widget.extend({
template:'mail.chat.ActivityMenu',
events: {
"click": "_onActivityMenuClick",
"click .o_activity_filter_button, .o_mail_channel_preview": "_onActivityFilterClick",
},
start: function () {
this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels');
chat_manager.bus.on("activity_updated", this, this._updateCounter);
chat_manager.is_ready.then(this._updateCounter.bind(this));
this._updateActivityPreview();
return this._super();
},
// Private
/**
* Make RPC and get current user's activity details
* @private
*/
_getActivityData: function(){
var self = this;
return self._rpc({
model: 'res.users',
method: 'activity_user_count',
}).then(function (data) {
self.isCounterUpdated = false;
self.activities = data;
self.activityCounter = _.reduce(data, function(total_count, p_data){ return total_count + p_data.total_count; }, 0);
self.$('.o_notification_counter').text(self.activityCounter);
self.$el.toggleClass('o_no_notification', !self.activityCounter);
});
},
/**
* Get particular model view to redirect on click of activity scheduled on that model.
* @private
* @param {string} model
*/
_getActivityModelViewID: function (model) {
return this._rpc({
model: model,
method: 'get_activity_view_id'
});
},
/**
* Check wether activity systray dropdown is open or not
* @private
* @returns {boolean}
*/
_isOpen: function () {
return this.$el.hasClass('open');
},
/**
* Update(render) activity system tray view on activity updation.
* @private
*/
_updateActivityPreview: function () {
var self = this;
self._getActivityData().then(function (){
self.$activities_preview.html(QWeb.render('mail.chat.ActivityMenuPreview', {
activities : self.activities
}));
});
},
/**
* update counter based on activity status(created or Done)
* @private
* @param {Object} [data] key, value to decide activity created or deleted
* @param {String} [data.type] notification type
* @param {Boolean} [data.activity_deleted] when activity deleted
* @param {Boolean} [data.activity_created] when activity created
*/
_updateCounter: function (data) {
if (data) {
if (data.activity_created) {
this.activityCounter ++;
}
if (data.activity_deleted && this.activityCounter > 0) {
this.activityCounter --;
}
this.isCounterUpdated = true;
this.$('.o_notification_counter').text(this.activityCounter);
this.$el.toggleClass('o_no_notification', !this.activityCounter);
}
},
// Handlers
/**
* Redirect to particular model view
* @private
* @param {MouseEvent} event
*/
_onActivityFilterClick: function (event) {
event.stopPropagation();
var $target = $(event.currentTarget);
var context = {};
context['search_default_activities_' + $target.data('filter')] = 1;
this.do_action({
type: 'ir.actions.act_window',
name: $target.data('model_name'),
res_model: $target.data('res_model'),
views: [[false, 'kanban'], [false, 'form']],
search_view_id: [false],
domain: [['activity_user_id', '=', session.uid]],
context:context,
});
},
/**
* When menu clicked update activity preview if counter updated
* @private
* @param {MouseEvent} event
*/
_onActivityMenuClick: function () {
if (!this._isOpen() && this.isCounterUpdated) {
this._updateActivityPreview();
}
},
});
SystrayMenu.Items.push(MessagingMenu);
SystrayMenu.Items.push(ActivityMenu);
// to test activity menu in qunit test cases we need it
return {
ActivityMenu: ActivityMenu,
};
});
+21 -4
View File
@@ -12,14 +12,19 @@
display: none;
}
}
&.o_unread_chat > a > i {
color: @odoo-brand-optional;
}
&.open .o_mail_navbar_dropdown {
.o-flex-display();
.o-flex-flow(column, nowrap);
}
.o_notification_counter {
background: @odoo-brand-optional;
color: white;
padding: 0em 0.3em;
font-size: 0.7em;
top: 7%;
position: absolute;
margin: 0px 0px 0px 8px;
}
.o_mail_navbar_dropdown {
width: 350px;
padding: 0;
@@ -107,12 +112,24 @@
}
.o_last_message_preview {
width: 100%;
max-height: 20px;
color: @odoo-main-color-muted;
.o-text-overflow();
}
}
}
}
.o_activity_filter_button {
.btn-link;
padding: 2px;
}
.o_no_activity {
cursor: initial;
.o-align-items(center);
color: grey;
opacity: 0.5;
padding: 3px;
}
}
}
+45 -10
View File
@@ -1,18 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mail.chat.InboxItem">
<li class="o_mail_navbar_item">
<a href="#" title="Inbox">
<i class="fa fa-at"/> <span class="o_notification_counter"/>
</a>
</li>
</t>
<t t-name="mail.chat.MessagingMenu">
<li class="o_mail_navbar_item">
<a class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" title="Conversations" href="#">
<i class="fa fa-comments"/> <span class="o_notification_counter"/>
<i class="fa fa-comments"/> <span class="o_notification_counter badge"/>
</a>
<ul class="o_mail_navbar_dropdown dropdown-menu" role="menu">
<li class="o_mail_navbar_dropdown_top">
@@ -29,9 +21,14 @@
</t>
<t t-name="mail.chat.ChannelsPreview">
<t t-if="_.isEmpty(channels)">
<li class="text-center o_no_activity mt16">
<span>No discussion yet...</span>
</li>
</t>
<t t-foreach="channels" t-as="channel">
<div t-attf-class="o_mail_channel_preview #{channel.unread_counter ? 'o_channel_unread' : ''}"
t-att-data-channel_id="channel.id">
t-att-data-channel_id="channel.id" t-att-data-res_id="channel.res_id" t-att-data-res_model="channel.model">
<img class="o_channel_image" t-att-src="channel.image_src"/>
<div class="o_channel_info">
<div class="o_channel_title">
@@ -53,5 +50,43 @@
</t>
</t>
<t t-name="mail.chat.ActivityMenuPreview">
<t t-if="_.isEmpty(activities)">
<li class="text-center o_no_activity">
<span>No activities planned.</span>
</li>
</t>
<t t-foreach="activities" t-as="activity">
<div class="o_mail_channel_preview" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='my'>
<img class="o_channel_image" t-att-src="activity.icon"/>
<div class="o_channel_info">
<div class="o_channel_title">
<span class="o_channel_name">
<t t-esc="activity.name"/>
</span>
</div>
<div>
<button t-if="activity.overdue_count" type="button" class="btn btn-link o_activity_filter_button mr16" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='overdue'><t t-esc="activity.overdue_count"/> Late </button>
<span t-if="!activity.overdue_count" class="o_no_activity mr16">0 Late </span>
<button t-if="activity.today_count" type="button" class="btn btn-link o_activity_filter_button mr16" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='today'> <t t-esc="activity.today_count"/> Today </button>
<span t-if="!activity.today_count" class="o_no_activity mr16">0 Today </span>
<button t-if="activity.planned_count" type="button" class="btn btn-link o_activity_filter_button pull-right mr16" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='upcoming_all'> <t t-esc="activity.planned_count"/> Future </button>
<span t-if="!activity.planned_count" class="o_no_activity pull-right mr16">0 Future</span>
</div>
</div>
</div>
</t>
</t>
<t t-name="mail.chat.ActivityMenu">
<li class="o_mail_navbar_item">
<a class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" title="Activities" href="#">
<i class="fa fa-check-square-o"/> <span class="o_notification_counter badge"/>
</a>
<ul class="o_mail_navbar_dropdown dropdown-menu" role="menu">
<li class="o_mail_navbar_dropdown_channels"/>
</ul>
</li>
</t>
</templates>
+90
View File
@@ -0,0 +1,90 @@
odoo.define('mail.systray_tests', function (require) {
"use strict";
var systray = require('mail.systray');
var testUtils = require('web.test_utils');
QUnit.module('mail', {}, function () {
QUnit.module('ActivityMenu', {
beforeEach: function () {
this.data = {
'mail.activity.menu': {
fields: {
name: { type: "char" },
res_model: { type: "char" },
planned_count: { type: "integer"},
today_count: { type: "integer"},
overdue_count: { type: "integer"},
total_count: { type: "integer"},
},
records: [{
name: "Contact",
res_model: "res.partner",
planned_count: 0,
today_count: 1,
overdue_count: 0,
total_count: 1,
},
{
name: "Task",
res_model: "project.task",
planned_count: 1,
today_count: 0,
overdue_count: 0,
total_count: 1,
},
{
name: "Issue",
res_model: "project.issue",
planned_count: 1,
today_count: 1,
overdue_count: 0,
total_count: 2,
}],
},
};
}
});
QUnit.test('activity menu widget: menu with no records', function (assert) {
assert.expect(1);
var activityMenu = new systray.ActivityMenu();
testUtils.addMockEnvironment(activityMenu, {
mockRPC: function (route, args) {
if (args.method === 'activity_user_count') {
return $.when([]);
}
return this._super(route, args);
},
});
activityMenu.appendTo($('#qunit-fixture'));
assert.ok(activityMenu.$('.o_no_activity').hasClass('o_no_activity'), "should not have instance of widget");
activityMenu.destroy();
});
QUnit.test('activity menu widget: activity menu with 3 records', function (assert) {
assert.expect(4);
var self = this;
var activityMenu = new systray.ActivityMenu();
testUtils.addMockEnvironment(activityMenu, {
mockRPC: function (route, args) {
if (args.method === 'activity_user_count') {
return $.when(self.data['mail.activity.menu']['records']);
}
return this._super(route, args);
},
});
activityMenu.appendTo($('#qunit-fixture'));
assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget');
assert.ok(activityMenu.$('.o_mail_channel_preview').hasClass('o_mail_channel_preview'), "should instance of widget");
assert.ok(activityMenu.$('.o_notification_counter').hasClass('o_notification_counter'), "widget should have notification counter");
assert.strictEqual(parseInt(activityMenu.el.innerText), 4, "widget should have 4 notification counter");
activityMenu.destroy();
});
});
});
+1
View File
@@ -40,6 +40,7 @@
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/chat_window_test.js"></script>
</xpath>
</template>