diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/js')
24 files changed, 3280 insertions, 0 deletions
diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js new file mode 100644 index 00000000..ae9d914f --- /dev/null +++ b/addons/mail/static/src/js/activity.js @@ -0,0 +1,868 @@ +odoo.define('mail.Activity', function (require) { +"use strict"; + +var mailUtils = require('mail.utils'); + +var AbstractField = require('web.AbstractField'); +var BasicModel = require('web.BasicModel'); +var config = require('web.config'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); +var session = require('web.session'); +var framework = require('web.framework'); +var time = require('web.time'); + +var QWeb = core.qweb; +var _t = core._t; +const _lt = core._lt; + +/** + * 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 {Promise<Array>} resolved with the activities + */ +function _readActivities(self, ids) { + if (!ids.length) { + return Promise.resolve([]); + } + var context = self.getSession().user_context; + if (self.record && !_.isEmpty(self.record.getContext())) { + context = self.record.getContext(); + } + return self._rpc({ + model: 'mail.activity', + method: 'activity_format', + args: [ids], + context: context, + }).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 {Promise<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 + * @return {Array} : list of modified activity Object + */ +var setDelayLabel = function (activities) { + var today = moment().startOf('day'); + _.each(activities, function (activity) { + var toDisplay = ''; + var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding + if (diff === 0) { + toDisplay = _t("Today"); + } else { + if (diff < 0) { // overdue + if (diff === -1) { + toDisplay = _t("Yesterday"); + } else { + toDisplay = _.str.sprintf(_t("%d days overdue"), Math.abs(diff)); + } + } else { // due + if (diff === 1) { + toDisplay = _t("Tomorrow"); + } else { + toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff)); + } + } + } + activity.label_delay = toDisplay; + }); + return activities; +}; + +/** + * Set the file upload identifier for 'upload_file' type activities + * + * @param {Array} activities list of activity Object + * @return {Array} : list of modified activity Object + */ +var setFileUploadID = function (activities) { + _.each(activities, function (activity) { + if (activity.activity_category === 'upload_file') { + activity.fileuploadID = _.uniqueId('o_fileupload'); + } + }); + return activities; +}; + +var BasicActivity = AbstractField.extend({ + events: { + 'click .o_edit_activity': '_onEditActivity', + 'change input.o_input_file': '_onFileChanged', + 'click .o_mark_as_done': '_onMarkActivityDone', + 'click .o_mark_as_done_upload_file': '_onMarkActivityDoneUploadFile', + 'click .o_activity_template_preview': '_onPreviewMailTemplate', + 'click .o_schedule_activity': '_onScheduleActivity', + 'click .o_activity_template_send': '_onSendMailTemplate', + 'click .o_unlink_activity': '_onUnlinkActivity', + }, + init: function () { + this._super.apply(this, arguments); + this._draftFeedback = {}; + }, + + //------------------------------------------------------------ + // Public + //------------------------------------------------------------ + + /** + * @param {integer} previousActivityTypeID + * @return {Promise} + */ + scheduleActivity: function () { + var callback = this._reload.bind(this, { activity: true, thread: true }); + return this._openActivityForm(false, callback); + }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * Send a feedback and reload page in order to mark activity as done + * + * @private + * @param {Object} params + * @param {integer} params.activityID + * @param {integer[]} params.attachmentIds + * @param {string} params.feedback + * + * @return {$.Promise} + */ + _markActivityDone: function (params) { + var activityID = params.activityID; + var feedback = params.feedback || false; + var attachmentIds = params.attachmentIds || []; + + return this._sendActivityFeedback(activityID, feedback, attachmentIds) + .then(this._reload.bind(this, { activity: true, thread: true })); + }, + /** + * Send a feedback and proposes to schedule next activity + * previousActivityTypeID will be given to new activity to propose activity + * type based on recommended next activity + * + * @private + * @param {Object} params + * @param {integer} params.activityID + * @param {string} params.feedback + */ + _markActivityDoneAndScheduleNext: function (params) { + var activityID = params.activityID; + var feedback = params.feedback; + var self = this; + this._rpc({ + model: 'mail.activity', + method: 'action_feedback_schedule_next', + args: [[activityID]], + kwargs: {feedback: feedback}, + context: this.record.getContext(), + }).then( + function (rslt_action) { + if (rslt_action) { + self.do_action(rslt_action, { + on_close: function () { + self.trigger_up('reload', { keepChanges: true }); + }, + }); + } else { + self.trigger_up('reload', { keepChanges: true }); + } + } + ); + }, + /** + * @private + * @param {integer} id + * @param {function} callback + * @return {Promise} + */ + _openActivityForm: function (id, callback) { + var action = { + type: 'ir.actions.act_window', + name: _t("Schedule Activity"), + res_model: 'mail.activity', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.res_id, + default_res_model: this.model, + }, + res_id: id || false, + }; + return this.do_action(action, { on_close: callback }); + }, + /** + * @private + * @param {integer} activityID + * @param {string} feedback + * @param {integer[]} attachmentIds + * @return {Promise} + */ + _sendActivityFeedback: function (activityID, feedback, attachmentIds) { + return this._rpc({ + model: 'mail.activity', + method: 'action_feedback', + args: [[activityID]], + kwargs: { + feedback: feedback, + attachment_ids: attachmentIds || [], + }, + context: this.record.getContext(), + }); + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + * @param {Object[]} activities + */ + _bindOnUploadAction: function (activities) { + var self = this; + _.each(activities, function (activity) { + if (activity.fileuploadID) { + $(window).on(activity.fileuploadID, function () { + framework.unblockUI(); + // find the button clicked and display the feedback popup on it + var files = Array.prototype.slice.call(arguments, 1); + self._markActivityDone({ + activityID: activity.id, + attachmentIds: _.pluck(files, 'id') + }).then(function () { + self.trigger_up('reload', { keepChanges: true }); + }); + }); + } + }); + }, + /** Binds a focusout handler on a bootstrap popover + * Useful to do some operations on the popover's HTML, + * like keeping the user's input for the feedback + * @param {JQuery} $popover_el: the element on which + * the popover() method has been called + */ + _bindPopoverFocusout: function ($popover_el) { + var self = this; + // Retrieve the actual popover's HTML + var $popover = $($popover_el.data("bs.popover").tip); + var activityID = $popover_el.data('activity-id'); + $popover.off('focusout'); + $popover.focusout(function (e) { + // outside click of popover hide the popover + // e.relatedTarget is the element receiving the focus + if (!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) { + self._draftFeedback[activityID] = $popover.find('#activity_feedback').val(); + $popover.popover('hide'); + } + }); + }, + + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onEditActivity: function (ev) { + ev.preventDefault(); + var activityID = $(ev.currentTarget).data('activity-id'); + return this._openActivityForm(activityID, this._reload.bind(this, { activity: true, thread: true })); + }, + /** + * @private + * @param {FormEvent} ev + */ + _onFileChanged: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var $form = $(ev.currentTarget).closest('form'); + $form.submit(); + framework.blockUI(); + }, + /** + * Called when marking an activity as done + * + * It lets the current user write a feedback in a popup menu. + * After writing the feedback and confirm mark as done + * is sent, it marks this activity as done for good with the feedback linked + * to it. + * + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDone: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var self = this; + var $markDoneBtn = $(ev.currentTarget); + var activityID = $markDoneBtn.data('activity-id'); + var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id') || false; + var forceNextActivity = $markDoneBtn.data('force-next-activity'); + + if ($markDoneBtn.data('toggle') === 'collapse') { + var $actLi = $markDoneBtn.parents('.o_log_activity'); + var $panel = self.$('#o_activity_form_' + activityID); + + if (!$panel.data('bs.collapse')) { + var $form = $(QWeb.render('mail.activity_feedback_form', { + previous_activity_type_id: previousActivityTypeID, + force_next: forceNextActivity + })); + $panel.append($form); + self._onMarkActivityDoneActions($markDoneBtn, $form, activityID); + + // Close and reset any other open panels + _.each($panel.siblings('.o_activity_form'), function (el) { + if ($(el).data('bs.collapse')) { + $(el).empty().collapse('dispose').removeClass('show'); + } + }); + + // Scroll to selected activity + $markDoneBtn.parents('.o_activity_log_container').scrollTo($actLi.position().top, 100); + } + + // Empty and reset panel on close + $panel.on('hidden.bs.collapse', function () { + if ($panel.data('bs.collapse')) { + $actLi.removeClass('o_activity_selected'); + $panel.collapse('dispose'); + $panel.empty(); + } + }); + + this.$('.o_activity_selected').removeClass('o_activity_selected'); + $actLi.toggleClass('o_activity_selected'); + $panel.collapse('toggle'); + + } else if (!$markDoneBtn.data('bs.popover')) { + $markDoneBtn.popover({ + template: $(Popover.Default.template).addClass('o_mail_activity_feedback')[0].outerHTML, // Ugly but cannot find another way + container: $markDoneBtn, + title: _t("Feedback"), + html: true, + trigger: 'manual', + placement: 'right', // FIXME: this should work, maybe a bug in the popper lib + content: function () { + var $popover = $(QWeb.render('mail.activity_feedback_form', { + previous_activity_type_id: previousActivityTypeID, + force_next: forceNextActivity + })); + self._onMarkActivityDoneActions($markDoneBtn, $popover, activityID); + return $popover; + }, + }).on('shown.bs.popover', function () { + var $popover = $($(this).data("bs.popover").tip); + $(".o_mail_activity_feedback.popover").not($popover).popover("hide"); + $popover.addClass('o_mail_activity_feedback').attr('tabindex', 0); + $popover.find('#activity_feedback').focus(); + self._bindPopoverFocusout($(this)); + }).popover('show'); + } else { + var popover = $markDoneBtn.data('bs.popover'); + if ($('#' + popover.tip.id).length === 0) { + popover.show(); + } + } + }, + /** + * Bind all necessary actions to the 'mark as done' form + * + * @private + * @param {Object} $form + * @param {integer} activityID + */ + _onMarkActivityDoneActions: function ($btn, $form, activityID) { + var self = this; + $form.find('#activity_feedback').val(self._draftFeedback[activityID]); + $form.on('click', '.o_activity_popover_done', function (ev) { + ev.stopPropagation(); + self._markActivityDone({ + activityID: activityID, + feedback: $form.find('#activity_feedback').val(), + }); + }); + $form.on('click', '.o_activity_popover_done_next', function (ev) { + ev.stopPropagation(); + self._markActivityDoneAndScheduleNext({ + activityID: activityID, + feedback: $form.find('#activity_feedback').val(), + }); + }); + $form.on('click', '.o_activity_popover_discard', function (ev) { + ev.stopPropagation(); + if ($btn.data('bs.popover')) { + $btn.popover('hide'); + } else if ($btn.data('toggle') === 'collapse') { + self.$('#o_activity_form_' + activityID).collapse('hide'); + } + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDoneUploadFile: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + var fileuploadID = $(ev.currentTarget).data('fileupload-id'); + var $input = this.$("[target='" + fileuploadID + "'] > input.o_input_file"); + $input.click(); + }, + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onPreviewMailTemplate: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var self = this; + var templateID = $(ev.currentTarget).data('template-id'); + var action = { + name: _t('Compose Email'), + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.res_id, + default_model: this.model, + default_use_template: true, + default_template_id: templateID, + force_email: true, + }, + }; + return this.do_action(action, { on_close: function () { + self.trigger_up('reload', { keepChanges: true }); + } }); + }, + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onSendMailTemplate: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var templateID = $(ev.currentTarget).data('template-id'); + return this._rpc({ + model: this.model, + method: 'activity_send_mail', + args: [[this.res_id], templateID], + }) + .then(this._reload.bind(this, {activity: true, thread: true, followers: true})); + }, + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onScheduleActivity: function (ev) { + ev.preventDefault(); + return this._openActivityForm(false, this._reload.bind(this)); + }, + + /** + * @private + * @param {MouseEvent} ev + * @param {Object} options + * @returns {Promise} + */ + _onUnlinkActivity: function (ev, options) { + ev.preventDefault(); + var activityID = $(ev.currentTarget).data('activity-id'); + options = _.defaults(options || {}, { + model: 'mail.activity', + args: [[activityID]], + }); + return this._rpc({ + model: options.model, + method: 'unlink', + args: options.args, + }) + .then(this._reload.bind(this, {activity: true})); + }, + /** + * Unbind event triggered when a file is uploaded. + * + * @private + * @param {Array} activities: list of activity to unbind + */ + _unbindOnUploadAction: function (activities) { + _.each(activities, function (activity) { + if (activity.fileuploadID) { + $(window).off(activity.fileuploadID); + } + }); + }, +}); + +// ----------------------------------------------------------------------------- +// Activities Widget for Form views ('mail_activity' widget) +// ----------------------------------------------------------------------------- +// FIXME seems to still be needed in some cases like systray +var Activity = BasicActivity.extend({ + className: 'o_mail_activity', + events: _.extend({}, BasicActivity.prototype.events, { + 'click a': '_onClickRedirect', + }), + specialData: '_fetchSpecialActivity', + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this._activities = this.record.specialData[this.name]; + }, + /** + * @override + */ + destroy: function () { + this._unbindOnUploadAction(); + return this._super.apply(this, arguments); + }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + /** + * @private + * @param {Object} fieldsToReload + */ + _reload: function (fieldsToReload) { + this.trigger_up('reload_mail_fields', fieldsToReload); + }, + /** + * @override + * @private + */ + _render: function () { + _.each(this._activities, function (activity) { + var note = mailUtils.parseAndTransform(activity.note || '', mailUtils.inline); + var is_blank = (/^\s*$/).test(note); + if (!is_blank) { + activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink); + } else { + activity.note = ''; + } + }); + var activities = setFileUploadID(setDelayLabel(this._activities)); + if (activities.length) { + var nbActivities = _.countBy(activities, 'state'); + this.$el.html(QWeb.render('mail.activity_items', { + uid: session.uid, + activities: activities, + nbPlannedActivities: nbActivities.planned, + nbTodayActivities: nbActivities.today, + nbOverdueActivities: nbActivities.overdue, + dateFormat: time.getLangDateFormat(), + datetimeFormat: time.getLangDatetimeFormat(), + session: session, + widget: this, + })); + this._bindOnUploadAction(this._activities); + } else { + this._unbindOnUploadAction(this._activities); + this.$el.empty(); + } + }, + /** + * @override + * @private + * @param {Object} record + */ + _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; + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRedirect: function (ev) { + var id = $(ev.currentTarget).data('oe-id'); + if (id) { + ev.preventDefault(); + var model = $(ev.currentTarget).data('oe-model'); + this.trigger_up('redirect', { + res_id: id, + res_model: model, + }); + } + }, + +}); + +// ----------------------------------------------------------------------------- +// Activities Widget for Kanban views ('kanban_activity' widget) +// ----------------------------------------------------------------------------- +var KanbanActivity = BasicActivity.extend({ + 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_state: {type: 'selection'}, + }), + + /** + * @override + */ + init: function (parent, name, record) { + this._super.apply(this, arguments); + var selection = {}; + _.each(record.fields.activity_state.selection, function (value) { + selection[value[0]] = value[1]; + }); + this.selection = selection; + this._setState(record); + }, + /** + * @override + */ + destroy: function () { + this._unbindOnUploadAction(); + return this._super.apply(this, arguments); + }, + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + */ + _reload: function () { + this.trigger_up('reload', { db_id: this.record_id, keepChanges: true }); + }, + /** + * @override + * @private + */ + _render: function () { + // span classes need to be updated manually because the template cannot + // be re-rendered eaasily (because of the dropdown state) + const spanClasses = ['fa', 'fa-lg', 'fa-fw']; + spanClasses.push('o_activity_color_' + (this.activityState || 'default')); + if (this.recordData.activity_exception_decoration) { + spanClasses.push('text-' + this.recordData.activity_exception_decoration); + spanClasses.push(this.recordData.activity_exception_icon); + } else { + spanClasses.push('fa-clock-o'); + } + this.$('.o_activity_btn > span').removeClass().addClass(spanClasses.join(' ')); + + if (this.$el.hasClass('show')) { + // note: this part of the rendering might be asynchronous + this._renderDropdown(); + } + }, + /** + * @private + */ + _renderDropdown: function () { + var self = this; + this.$('.o_activity') + .toggleClass('dropdown-menu-right', config.device.isMobile) + .html(QWeb.render('mail.KanbanActivityLoading')); + return _readActivities(this, this.value.res_ids).then(function (activities) { + activities = setFileUploadID(activities); + self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', { + selection: self.selection, + records: _.groupBy(setDelayLabel(activities), 'state'), + session: session, + widget: self, + })); + self._bindOnUploadAction(activities); + }); + }, + /** + * @override + * @private + * @param {Object} record + */ + _reset: function (record) { + this._super.apply(this, arguments); + this._setState(record); + }, + /** + * @private + * @param {Object} record + */ + _setState: function (record) { + this.record_id = record.id; + this.activityState = this.recordData.activity_state; + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + */ + _onDropdownShow: function () { + this._renderDropdown(); + }, +}); + +// ----------------------------------------------------------------------------- +// 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'}, + activity_type_icon: {type: 'char'}, + }), + 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); + } + if (this.recordData.activity_type_icon) { + this.el.querySelector('.o_activity_btn > span').classList.replace('fa-clock-o', this.recordData.activity_type_icon); + } + }, + + //-------------------------------------------------------------------------- + // 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) +// ----------------------------------------------------------------------------- + +var ActivityException = AbstractField.extend({ + noLabel: true, + fieldDependencies: _.extend({}, AbstractField.prototype.fieldDependencies, { + activity_exception_icon: {type: 'char'} + }), + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * There is no edit mode for this widget, the icon is always readonly. + * + * @override + * @private + */ + _renderEdit: function () { + return this._renderReadonly(); + }, + + /** + * Displays the exception icon if there is one. + * + * @override + * @private + */ + _renderReadonly: function () { + this.$el.empty(); + if (this.value) { + this.$el.attr({ + title: _t('This record has an exception activity.'), + class: "pull-right mt-1 text-" + this.value + " fa " + this.recordData.activity_exception_icon + }); + } + } +}); + +field_registry + .add('kanban_activity', KanbanActivity) + .add('list_activity', ListActivity) + .add('activity_exception', ActivityException); + +return Activity; + +}); diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js new file mode 100644 index 00000000..4c5c9a12 --- /dev/null +++ b/addons/mail/static/src/js/basic_view.js @@ -0,0 +1,68 @@ +odoo.define('mail.BasicView', function (require) { +"use strict"; + +const BasicView = require('web.BasicView'); + +const mailWidgets = ['kanban_activity']; + +BasicView.include({ + init: function () { + this._super.apply(this, arguments); + const post_refresh = this._getFieldOption('message_ids', 'post_refresh', false); + const followers_post_refresh = this._getFieldOption('message_follower_ids', 'post_refresh', false); + this.chatterFields = { + hasActivityIds: this._hasField('activity_ids'), + hasMessageFollowerIds: this._hasField('message_follower_ids'), + hasMessageIds: this._hasField('message_ids'), + hasRecordReloadOnAttachmentsChanged: post_refresh === 'always', + hasRecordReloadOnMessagePosted: !!post_refresh, + hasRecordReloadOnFollowersUpdate: !!followers_post_refresh, + isAttachmentBoxVisibleInitially: ( + this._getFieldOption('message_ids', 'open_attachments', false) || + this._getFieldOption('message_follower_ids', 'open_attachments', false) + ), + }; + const fieldsInfo = this.fieldsInfo[this.viewType]; + this.rendererParams.chatterFields = this.chatterFields; + + // LEGACY for widget kanban_activity + this.mailFields = {}; + for (const fieldName in fieldsInfo) { + const fieldInfo = fieldsInfo[fieldName]; + if (_.contains(mailWidgets, fieldInfo.widget)) { + this.mailFields[fieldInfo.widget] = fieldName; + fieldInfo.__no_fetch = true; + } + } + this.rendererParams.activeActions = this.controllerParams.activeActions; + this.rendererParams.mailFields = this.mailFields; + }, + /** + * Gets the option value of a field if present. + * + * @private + * @param {string} fieldName the desired field name + * @param {string} optionName the desired option name + * @param {*} defaultValue the default value if option or field is not found. + * @returns {*} + */ + _getFieldOption(fieldName, optionName, defaultValue) { + const field = this.fieldsInfo[this.viewType][fieldName]; + if (field && field.options && field.options[optionName] !== undefined) { + return field.options[optionName]; + } + return defaultValue; + }, + /** + * Checks whether the view has a given field. + * + * @private + * @param {string} fieldName the desired field name + * @returns {boolean} + */ + _hasField(fieldName) { + return !!this.fieldsInfo[this.viewType][fieldName]; + }, +}); + +}); diff --git a/addons/mail/static/src/js/core/translation.js b/addons/mail/static/src/js/core/translation.js new file mode 100644 index 00000000..faecafaf --- /dev/null +++ b/addons/mail/static/src/js/core/translation.js @@ -0,0 +1,28 @@ +odoo.define('mail/static/src/js/core/translation.js', function (require) { +'use strict'; + +const { TranslationDataBase } = require('web.translation'); + +const { Component } = owl; + +TranslationDataBase.include({ + /** + * @override + */ + set_bundle() { + const res = this._super(...arguments); + if (Component.env.messaging) { + // Update messaging locale whenever the translation bundle changes. + // In particular if messaging is created before the end of the + // `load_translations` RPC, the default values have to be + // updated by the received ones. + Component.env.messaging.locale.update({ + language: this.parameters.code, + textDirection: this.parameters.direction, + }); + } + return res; + }, +}); + +}); diff --git a/addons/mail/static/src/js/custom_filter_item.js b/addons/mail/static/src/js/custom_filter_item.js new file mode 100644 index 00000000..abe15eda --- /dev/null +++ b/addons/mail/static/src/js/custom_filter_item.js @@ -0,0 +1,21 @@ +odoo.define('mail.CustomFilterItem', function (require) { + "use strict"; + + const CustomFilterItem = require('web.CustomFilterItem'); + + CustomFilterItem.patch('mail.CustomFilterItem', T => class extends T { + + /** + * With the `mail` module installed, we want to filter out some of the + * available fields in 'Add custom filter' menu (@see CustomFilterItem). + * @override + */ + _validateField(field) { + return super._validateField(...arguments) && + field.relation !== 'mail.message' && + field.name !== 'message_ids'; + } + }); + + return CustomFilterItem; +}); diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js new file mode 100644 index 00000000..b46aea30 --- /dev/null +++ b/addons/mail/static/src/js/document_viewer.js @@ -0,0 +1,396 @@ +odoo.define('mail.DocumentViewer', function (require) { +"use strict"; + +var core = require('web.core'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +var SCROLL_ZOOM_STEP = 0.1; +var ZOOM_STEP = 0.5; + +/** + * This widget is deprecated, and should instead use AttachmentViewer component. + * @see `mail/static/src/components/attachment_viewer/attachment_viewer.js` + * TODO: remove this widget when it's not longer used + * + * @deprecated + */ +var DocumentViewer = Widget.extend({ + template: "DocumentViewer", + events: { + 'click .o_download_btn': '_onDownload', + 'click .o_viewer_img': '_onImageClicked', + 'click .o_viewer_video': '_onVideoClicked', + 'click .move_next': '_onNext', + 'click .move_previous': '_onPrevious', + 'click .o_rotate': '_onRotate', + 'click .o_zoom_in': '_onZoomIn', + 'click .o_zoom_out': '_onZoomOut', + 'click .o_zoom_reset': '_onZoomReset', + 'click .o_close_btn, .o_viewer_img_wrapper': '_onClose', + 'click .o_print_btn': '_onPrint', + 'DOMMouseScroll .o_viewer_content': '_onScroll', // Firefox + 'mousewheel .o_viewer_content': '_onScroll', // Chrome, Safari, IE + 'keydown': '_onKeydown', + 'keyup': '_onKeyUp', + 'mousedown .o_viewer_img': '_onStartDrag', + 'mousemove .o_viewer_content': '_onDrag', + 'mouseup .o_viewer_content': '_onEndDrag' + }, + /** + * The documentViewer takes an array of objects describing attachments in + * argument, and the ID of an active attachment (the one to display first). + * Documents that are not of type image or video are filtered out. + * + * @override + * @param {Array<Object>} attachments list of attachments + * @param {integer} activeAttachmentID + */ + init: function (parent, attachments, activeAttachmentID) { + this._super.apply(this, arguments); + this.attachment = _.filter(attachments, function (attachment) { + var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)"); + if (match) { + attachment.fileType = match[1]; + if (match[1].match("(.png|.jpg|.gif)")) { + attachment.fileType = 'image'; + } + if (match[1] === 'youtu') { + var youtube_array = attachment.url.split('/'); + var youtube_token = youtube_array[youtube_array.length-1]; + if (youtube_token.indexOf('watch') !== -1) { + youtube_token = youtube_token.split('v=')[1]; + var amp = youtube_token.indexOf('&') + if (amp !== -1){ + youtube_token = youtube_token.substring(0, amp); + } + } + attachment.youtube = youtube_token; + } + return true; + } + }); + this.activeAttachment = _.findWhere(attachments, {id: activeAttachmentID}); + this.modelName = 'ir.attachment'; + this._reset(); + }, + /** + * Open a modal displaying the active attachment + * @override + */ + start: function () { + this.$el.modal('show'); + this.$el.on('hidden.bs.modal', _.bind(this._onDestroy, this)); + this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this)); + this.$('[data-toggle="tooltip"]').tooltip({delay: 0}); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + if (this.isDestroyed()) { + return; + } + this.$el.modal('hide'); + this.$el.remove(); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------------- + + /** + * @private + */ + _next: function () { + var index = _.findIndex(this.attachment, this.activeAttachment); + index = (index + 1) % this.attachment.length; + this.activeAttachment = this.attachment[index]; + this._updateContent(); + }, + /** + * @private + */ + _previous: function () { + var index = _.findIndex(this.attachment, this.activeAttachment); + index = index === 0 ? this.attachment.length - 1 : index - 1; + this.activeAttachment = this.attachment[index]; + this._updateContent(); + }, + /** + * @private + */ + _reset: function () { + this.scale = 1; + this.dragStartX = this.dragstopX = 0; + this.dragStartY = this.dragstopY = 0; + }, + /** + * Render the active attachment + * + * @private + */ + _updateContent: function () { + this.$('.o_viewer_content').html(QWeb.render('DocumentViewer.Content', { + widget: this + })); + this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this)); + this.$('[data-toggle="tooltip"]').tooltip({delay: 0}); + this._reset(); + }, + /** + * Get CSS transform property based on scale and angle + * + * @private + * @param {float} scale + * @param {float} angle + */ + _getTransform: function(scale, angle) { + return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)'; + }, + /** + * Rotate image clockwise by provided angle + * + * @private + * @param {float} angle + */ + _rotate: function (angle) { + this._reset(); + var new_angle = (this.angle || 0) + angle; + this.$('.o_viewer_img').css('transform', this._getTransform(this.scale, new_angle)); + this.$('.o_viewer_img').css('max-width', new_angle % 180 !== 0 ? $(document).height() : '100%'); + this.$('.o_viewer_img').css('max-height', new_angle % 180 !== 0 ? $(document).width() : '100%'); + this.angle = new_angle; + }, + /** + * Zoom in/out image by provided scale + * + * @private + * @param {integer} scale + */ + _zoom: function (scale) { + if (scale > 0.5) { + this.$('.o_viewer_img').css('transform', this._getTransform(scale, this.angle || 0)); + this.scale = scale; + } + this.$('.o_zoom_reset').add('.o_zoom_out').toggleClass('disabled', scale === 1); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} e + */ + _onClose: function (e) { + e.preventDefault(); + this.destroy(); + }, + /** + * When popup close complete destroyed modal even DOM footprint too + * + * @private + */ + _onDestroy: function () { + this.destroy(); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onDownload: function (e) { + e.preventDefault(); + window.location = '/web/content/' + this.modelName + '/' + this.activeAttachment.id + '/' + 'datas' + '?download=true'; + }, + /** + * @private + * @param {MouseEvent} e + */ + _onDrag: function (e) { + e.preventDefault(); + if (this.enableDrag) { + var $image = this.$('.o_viewer_img'); + var $zoomer = this.$('.o_viewer_zoomer'); + var top = $image.prop('offsetHeight') * this.scale > $zoomer.height() ? e.clientY - this.dragStartY : 0; + var left = $image.prop('offsetWidth') * this.scale > $zoomer.width() ? e.clientX - this.dragStartX : 0; + $zoomer.css("transform", "translate3d("+ left +"px, " + top + "px, 0)"); + $image.css('cursor', 'move'); + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onEndDrag: function (e) { + e.preventDefault(); + if (this.enableDrag) { + this.enableDrag = false; + this.dragstopX = e.clientX - this.dragStartX; + this.dragstopY = e.clientY - this.dragStartY; + this.$('.o_viewer_img').css('cursor', ''); + } + }, + /** + * On click of image do not close modal so stop event propagation + * + * @private + * @param {MouseEvent} e + */ + _onImageClicked: function (e) { + e.stopPropagation(); + }, + /** + * Remove loading indicator when image loaded + * @private + */ + _onImageLoaded: function () { + this.$('.o_loading_img').hide(); + }, + /** + * Move next previous attachment on keyboard right left key + * + * @private + * @param {KeyEvent} e + */ + _onKeydown: function (e){ + switch (e.which) { + case $.ui.keyCode.RIGHT: + e.preventDefault(); + this._next(); + break; + case $.ui.keyCode.LEFT: + e.preventDefault(); + this._previous(); + break; + } + }, + /** + * Close popup on ESCAPE keyup + * + * @private + * @param {KeyEvent} e + */ + _onKeyUp: function (e) { + switch (e.which) { + case $.ui.keyCode.ESCAPE: + e.preventDefault(); + this._onClose(e); + break; + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onNext: function (e) { + e.preventDefault(); + this._next(); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onPrevious: function (e) { + e.preventDefault(); + this._previous(); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onPrint: function (e) { + e.preventDefault(); + var src = this.$('.o_viewer_img').prop('src'); + var script = QWeb.render('PrintImage', { + src: src + }); + var printWindow = window.open('about:blank', "_new"); + printWindow.document.open(); + printWindow.document.write(script); + printWindow.document.close(); + }, + /** + * Zoom image on scroll + * + * @private + * @param {MouseEvent} e + */ + _onScroll: function (e) { + var scale; + if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) { + scale = this.scale + SCROLL_ZOOM_STEP; + this._zoom(scale); + } else { + scale = this.scale - SCROLL_ZOOM_STEP; + this._zoom(scale); + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onStartDrag: function (e) { + e.preventDefault(); + this.enableDrag = true; + this.dragStartX = e.clientX - (this.dragstopX || 0); + this.dragStartY = e.clientY - (this.dragstopY || 0); + }, + /** + * On click of video do not close modal so stop event propagation + * and provide play/pause the video instead of quitting it + * + * @private + * @param {MouseEvent} e + */ + _onVideoClicked: function (e) { + e.stopPropagation(); + var videoElement = e.target; + if (videoElement.paused) { + videoElement.play(); + } else { + videoElement.pause(); + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onRotate: function (e) { + e.preventDefault(); + this._rotate(90); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onZoomIn: function (e) { + e.preventDefault(); + var scale = this.scale + ZOOM_STEP; + this._zoom(scale); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onZoomOut: function (e) { + e.preventDefault(); + var scale = this.scale - ZOOM_STEP; + this._zoom(scale); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onZoomReset: function (e) { + e.preventDefault(); + this.$('.o_viewer_zoomer').css("transform", ""); + this._zoom(1); + }, +}); +return DocumentViewer; +}); diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js new file mode 100644 index 00000000..139bcdae --- /dev/null +++ b/addons/mail/static/src/js/emojis.js @@ -0,0 +1,155 @@ +odoo.define('mail.emojis', function (require) { +"use strict"; + +/** + * This module exports the list of all available emojis on the client side. + * An emoji object has the following properties: + * + * - {string[]} sources: the character representations of the emoji + * - {string} unicode: the unicode representation of the emoji + * - {string} description: the description of the emoji + */ + +/** + * This data represent all the available emojis that are supported on the web + * client: + * + * - key: this is the source representation of an emoji, i.e. its "character" + * representation. This is a string that can be easily typed by the + * user and then translated to its unicode representation (see value) + * - value: this is the unicode representation of an emoji, i.e. its "true" + * representation in the system. + */ +var data = { + ":)": "😊", + ":-)": "😊", // alternative (alt.) + "=)": "😊", // alt. + ":]": "😊", // alt. + ":D": "😃", + ":-D": "😃", // alt. + "=D": "😃", // alt. + "xD": "😆", + "XD": "😆", // alt. + "x'D": "😂", + ";)": "😉", + ";-)": "😉", // alt. + "B)": "😎", + "8)": "😎", // alt. + "B-)": "😎", // alt. + "8-)": "😎", // alt. + ";p": "😜", + ";P": "😜", // alt. + ":p": "😋", + ":P": "😋", // alt. + ":-p": "😋", // alt. + ":-P": "😋", // alt. + "=P": "😋", // alt. + "xp": "😝", + "xP": "😝", // alt. + "o_o": "😳", + ":|": "😐", + ":-|": "😐", // alt. + ":/": "😕", // alt. + ":-/": "😕", // alt. + ":(": "😞", + ":@": "😱", + ":O": "😲", + ":-O": "😲", // alt. + ":o": "😲", // alt. + ":-o": "😲", // alt. + ":'o": "😨", + "3:(": "😠", + ">:(": "😠", // alt. + "3:": "😠", // alt. + "3:)": "😈", + ">:)": "😈", // alt. + ":*": "😘", + ":-*": "😘", // alt. + "o:)": "😇", + ":'(": "😢", + ":'-(": "😭", + ":\"(": "😭", // alt. + "<3": "❤️", + "<3": "❤️", + ":heart": "❤️", // alt. + "</3": "💔", + "</3": "💔", + ":heart_eyes": "😍", + ":turban": "👳", + ":+1": "👍", + ":-1": "👎", + ":ok": "👌", + ":poop": "💩", + ":no_see": "🙈", + ":no_hear": "🙉", + ":no_speak": "🙊", + ":bug": "🐞", + ":kitten": "😺", + ":bear": "🐻", + ":snail": "🐌", + ":boar": "🐗", + ":clover": "🍀", + ":sunflower": "🌹", + ":fire": "🔥", + ":sun": "☀️", + ":partly_sunny:": "⛅️", + ":rainbow": "🌈", + ":cloud": "☁️", + ":zap": "⚡️", + ":star": "⭐️", + ":cookie": "🍪", + ":pizza": "🍕", + ":hamburger": "🍔", + ":fries": "🍟", + ":cake": "🎂", + ":cake_part": "🍰", + ":coffee": "☕️", + ":banana": "🍌", + ":sushi": "🍣", + ":rice_ball": "🍙", + ":beer": "🍺", + ":wine": "🍷", + ":cocktail": "🍸", + ":tropical": "🍹", + ":beers": "🍻", + ":ghost": "👻", + ":skull": "💀", + ":et": "👽", + ":alien": "👽", // alt. + ":party": "🎉", + ":trophy": "🏆", + ":key": "🔑", + ":pin": "📌", + ":postal_horn": "📯", + ":music": "🎵", + ":trumpet": "🎺", + ":guitar": "🎸", + ":run": "🏃", + ":bike": "🚲", + ":soccer": "⚽️", + ":football": "🏈", + ":8ball": "🎱", + ":clapper": "🎬", + ":microphone": "🎤", + ":cheese": "🧀", +}; + +// list of emojis in a dictionary, indexed by emoji unicode +var emojiDict = {}; +_.each(data, function (unicode, source) { + if (!emojiDict[unicode]) { + emojiDict[unicode] = { + sources: [source], + unicode: unicode, + description: source, + }; + } else { + emojiDict[unicode].sources.push(source); + } +}); + +var emojis = _.values(emojiDict); + +return emojis; + +}); diff --git a/addons/mail/static/src/js/emojis_mixin.js b/addons/mail/static/src/js/emojis_mixin.js new file mode 100644 index 00000000..aa535d1d --- /dev/null +++ b/addons/mail/static/src/js/emojis_mixin.js @@ -0,0 +1,91 @@ +odoo.define('mail.emoji_mixin', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); + +/** + * This mixin gathers a few methods that are used to handle emojis. + * + * It's used to: + * + * - handle the click on an emoji from a dropdown panel and add it to the related textarea/input + * - format text and wrap the emojis around <span class="o_mail_emoji"> to make them look nicer + * + * Methods are based on the collections of emojis available in mail.emojis + * + */ +return { + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method should be bound to a click event on an emoji. + * (used in text element's emojis dropdown list) + * + * It assumes that a ``_getTargetTextElement`` method is defined that will return the related + * textarea/input element in which the emoji will be inserted. + * + * @param {MouseEvent} ev + */ + _onEmojiClick: function (ev) { + var unicode = ev.currentTarget.textContent.trim(); + var textInput = this._getTargetTextElement($(ev.currentTarget))[0]; + var selectionStart = textInput.selectionStart; + + textInput.value = textInput.value.slice(0, selectionStart) + unicode + textInput.value.slice(selectionStart); + textInput.focus(); + textInput.setSelectionRange(selectionStart + unicode.length, selectionStart + unicode.length); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method is used to wrap emojis in a text message with <span class="o_mail_emoji"> + * As this returns html to be used in a 't-raw' argument, it first makes sure that the + * passed text message is html escaped for safety reasons. + * + * @param {String} message a text message to format + */ + _formatText: function (message) { + message = this._htmlEscape(message); + message = this._wrapEmojis(message); + message = message.replace(/(?:\r\n|\r|\n)/g, '<br>'); + + return message; + }, + + /** + * Adapted from qweb2.js#html_escape to avoid formatting '&' + * + * @param {String} s + * @private + */ + _htmlEscape: function (s) { + if (s == null) { + return ''; + } + return String(s).replace(/</g, '<').replace(/>/g, '>'); + }, + + /** + * Will use the mail.emojis library to wrap emojis unicode around a span with a special font + * that will make them look nicer (colored, ...). + * + * @param {String} message + */ + _wrapEmojis: function (message) { + emojis.forEach(function (emoji) { + message = message.replace( + new RegExp(emoji.unicode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + '<span class="o_mail_emoji">' + emoji.unicode + '</span>' + ); + }); + + return message; + } +}; + +}); diff --git a/addons/mail/static/src/js/field_char.js b/addons/mail/static/src/js/field_char.js new file mode 100644 index 00000000..1a1b90ec --- /dev/null +++ b/addons/mail/static/src/js/field_char.js @@ -0,0 +1,56 @@ +odoo.define('sms.onchange_in_keyup', function (require) { +"use strict"; + +var FieldChar = require('web.basic_fields').FieldChar; +FieldChar.include({ + + //-------------------------------------------------------------------------- + // Public + //------------------------------------------------------------------------- + + /** + * Support a key-based onchange in text field. In order to avoid too much + * rpc to the server _triggerOnchange is throttled (once every second max) + * + */ + init: function () { + this._super.apply(this, arguments); + this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false}); + }, + + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Trigger the 'change' event at key down. It allows to trigger an onchange + * while typing which may be interesting in some cases. Otherwise onchange + * is triggered only on blur. + * + * @override + * @private + */ + _onKeydown: function () { + this._super.apply(this, arguments); + if (this.nodeOptions.onchange_on_keydown) { + this._triggerOnchange(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Triggers the 'change' event to refresh the value. Throttled at init to + * avoid spaming server. + * + * @private + */ + _triggerOnchange: function () { + this.$input.trigger('change'); + }, +}); + +}); diff --git a/addons/mail/static/src/js/field_char_emojis.js b/addons/mail/static/src/js/field_char_emojis.js new file mode 100644 index 00000000..012a68c2 --- /dev/null +++ b/addons/mail/static/src/js/field_char_emojis.js @@ -0,0 +1,18 @@ +odoo.define('mail.field_char_emojis', function (require) { +"use strict"; + +var basicFields = require('web.basic_fields'); +var registry = require('web.field_registry'); +var FieldEmojiCommon = require('mail.field_emojis_common'); +var MailEmojisMixin = require('mail.emoji_mixin'); + +/** + * Extension of the FieldChar that will add emojis support + */ +var FieldCharEmojis = basicFields.FieldChar.extend(MailEmojisMixin, FieldEmojiCommon); + +registry.add('char_emojis', FieldCharEmojis); + +return FieldCharEmojis; + +}); diff --git a/addons/mail/static/src/js/field_emojis_common.js b/addons/mail/static/src/js/field_emojis_common.js new file mode 100644 index 00000000..79215ac7 --- /dev/null +++ b/addons/mail/static/src/js/field_emojis_common.js @@ -0,0 +1,136 @@ +odoo.define('mail.field_emojis_common', function (require) { +"use strict"; + +var basicFields = require('web.basic_fields'); +var core = require('web.core'); +var emojis = require('mail.emojis'); +var MailEmojisMixin = require('mail.emoji_mixin'); +var _onEmojiClickMixin = MailEmojisMixin._onEmojiClick; +var QWeb = core.qweb; + +/* + * Common code for FieldTextEmojis and FieldCharEmojis + */ +var FieldEmojiCommon = { + /** + * @override + * @private + */ + init: function () { + this._super.apply(this, arguments); + this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false}); + this.emojis = emojis; + }, + + /** + * @override + */ + on_attach_callback: function () { + this._attachEmojisDropdown(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this._super.apply(this, arguments); + + if (this.mode !== 'edit') { + this.$el.html(this._formatText(this.$el.text())); + } + }, + + /** + * Overridden because we need to add the Emoji to the input AND trigger + * the 'change' event to refresh the value. + * + * @override + * @private + */ + _onEmojiClick: function () { + _onEmojiClickMixin.apply(this, arguments); + this._isDirty = true; + this.$input.trigger('change'); + }, + + /** + * + * By default, the 'change' event is only triggered when the text element is blurred. + * + * We override this method because we want to update the value while + * the user is typing his message (and not only on blur). + * + * @override + * @private + */ + _onKeydown: function () { + this._super.apply(this, arguments); + if (this.nodeOptions.onchange_on_keydown) { + this._triggerOnchange(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Used by MailEmojisMixin, check its document for more info. + * + * @private + */ + _getTargetTextElement() { + return this.$el; + }, + + /** + * Triggers the 'change' event to refresh the value. + * This method is throttled to run at most once every second. + * (to avoid spamming the server while the user is typing his message) + * + * @private + */ + _triggerOnchange: function () { + this.$input.trigger('change'); + }, + + /** + * This will add an emoji button that shows the emojis selection dropdown. + * + * Should be used inside 'on_attach_callback' because we need the element to be attached to the form first. + * That's because the $emojisIcon element needs to be rendered outside of this $el + * (which is an text element, that can't 'contain' any other elements). + * + * @private + */ + _attachEmojisDropdown: function () { + if (!this.$emojisIcon) { + this.$emojisIcon = $(QWeb.render('mail.EmojisDropdown', {widget: this})); + this.$emojisIcon.find('.o_mail_emoji').on('click', this._onEmojiClick.bind(this)); + + if (this.$el.filter('span.o_field_translate').length) { + // multi-languages activated, place the button on the left of the translation button + this.$emojisIcon.addClass('o_mail_emojis_dropdown_translation'); + } + if (this.$el.filter('textarea').length) { + this.$emojisIcon.addClass('o_mail_emojis_dropdown_textarea'); + } + this.$el.last().after(this.$emojisIcon); + } + + if (this.mode === 'edit') { + this.$emojisIcon.show(); + } else { + this.$emojisIcon.hide(); + } + } +}; + +return FieldEmojiCommon; + +}); diff --git a/addons/mail/static/src/js/field_text_emojis.js b/addons/mail/static/src/js/field_text_emojis.js new file mode 100644 index 00000000..8ed14ecf --- /dev/null +++ b/addons/mail/static/src/js/field_text_emojis.js @@ -0,0 +1,18 @@ +odoo.define('mail.field_text_emojis', function (require) { +"use strict"; + +var basicFields = require('web.basic_fields'); +var registry = require('web.field_registry'); +var FieldEmojiCommon = require('mail.field_emojis_common'); +var MailEmojisMixin = require('mail.emoji_mixin'); + +/** + * Extension of the FieldText that will add emojis support + */ +var FieldTextEmojis = basicFields.FieldText.extend(MailEmojisMixin, FieldEmojiCommon); + +registry.add('text_emojis', FieldTextEmojis); + +return FieldTextEmojis; + +}); diff --git a/addons/mail/static/src/js/main.js b/addons/mail/static/src/js/main.js new file mode 100644 index 00000000..d19a8edb --- /dev/null +++ b/addons/mail/static/src/js/main.js @@ -0,0 +1,126 @@ +odoo.define('mail/static/src/js/main.js', function (require) { +'use strict'; + +const ModelManager = require('mail/static/src/model/model_manager.js'); + +const env = require('web.commonEnv'); + +const { Store } = owl; +const { EventBus } = owl.core; + +async function createMessaging() { + await new Promise(resolve => { + /** + * Called when all JS resources are loaded. This is useful in order + * to do some processing after other JS files have been parsed, for + * example new models or patched models that are coming from + * other modules, because some of those patches might need to be + * applied before messaging initialization. + */ + window.addEventListener('load', resolve); + }); + /** + * All JS resources are loaded, but not necessarily processed. + * We assume no messaging-related modules return any Promise, + * therefore they should be processed *at most* asynchronously at + * "Promise time". + */ + await new Promise(resolve => setTimeout(resolve)); + /** + * Some models require session data, like locale text direction (depends on + * fully loaded translation). + */ + await env.session.is_bound; + + env.modelManager.start(); + /** + * Create the messaging singleton record. + */ + env.messaging = env.models['mail.messaging'].create(); +} + +/** + * Messaging store + */ +const store = new Store({ + env, + state: { + messagingRevNumber: 0, + }, +}); + +/** + * Registry of models. + */ +env.models = {}; +/** + * Environment keys used in messaging. + */ +Object.assign(env, { + autofetchPartnerImStatus: true, + destroyMessaging() { + if (env.modelManager) { + env.modelManager.deleteAll(); + env.messaging = undefined; + } + }, + disableAnimation: false, + isMessagingInitialized() { + if (!this.messaging) { + return false; + } + return this.messaging.isInitialized; + }, + /** + * States whether the environment is in QUnit test or not. + * + * Useful to prevent some behaviour in QUnit tests, like applying + * style of attachment that uses url. + */ + isQUnitTest: false, + loadingBaseDelayDuration: 400, + messaging: undefined, + messagingBus: new EventBus(), + /** + * Promise which becomes resolved when messaging is created. + * + * Useful for discuss widget to know when messaging is created, because this + * is an essential condition to make it work. + */ + messagingCreatedPromise: createMessaging(), + modelManager: new ModelManager(env), + store, +}); + +/** + * Components cannot use web.bus, because they cannot use + * EventDispatcherMixin, and webclient cannot easily access env. + * Communication between webclient and components by core.bus + * (usable by webclient) and messagingBus (usable by components), which + * the messaging service acts as mediator since it can easily use both + * kinds of buses. + */ +env.bus.on( + 'hide_home_menu', + null, + () => env.messagingBus.trigger('hide_home_menu') +); +env.bus.on( + 'show_home_menu', + null, + () => env.messagingBus.trigger('show_home_menu') +); +env.bus.on( + 'will_hide_home_menu', + null, + () => env.messagingBus.trigger('will_hide_home_menu') +); +env.bus.on( + 'will_show_home_menu', + null, + () => env.messagingBus.trigger('will_show_home_menu') +); + +env.messagingCreatedPromise.then(() => env.messaging.start()); + +}); diff --git a/addons/mail/static/src/js/many2many_tags_email.js b/addons/mail/static/src/js/many2many_tags_email.js new file mode 100644 index 00000000..6648ef50 --- /dev/null +++ b/addons/mail/static/src/js/many2many_tags_email.js @@ -0,0 +1,135 @@ +odoo.define('mail.many2manytags', function (require) { +"use strict"; + +var BasicModel = require('web.BasicModel'); +var core = require('web.core'); +var form_common = require('web.view_dialogs'); +var field_registry = require('web.field_registry'); +var relational_fields = require('web.relational_fields'); + +var M2MTags = relational_fields.FieldMany2ManyTags; +var _t = core._t; + +BasicModel.include({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} record - an element from the localData + * @param {string} fieldName + * @return {Promise<Object>} the promise is resolved with the + * invalidPartnerIds + */ + _setInvalidMany2ManyTagsEmail: function (record, fieldName) { + var self = this; + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; + var list = this._applyX2ManyOperations(this.localData[localID]); + var invalidPartnerIds = []; + _.each(list.data, function (id) { + var record = self.localData[id]; + if (!record.data.email) { + invalidPartnerIds.push(record); + } + }); + var def; + if (invalidPartnerIds.length) { + // remove invalid partners + var changes = {operation: 'DELETE', ids: _.pluck(invalidPartnerIds, 'id')}; + def = this._applyX2ManyChange(record, fieldName, changes); + } + return Promise.resolve(def).then(function () { + return { + invalidPartnerIds: _.pluck(invalidPartnerIds, 'res_id'), + }; + }); + }, +}); + +var FieldMany2ManyTagsEmail = M2MTags.extend({ + tag_template: "FieldMany2ManyTagsEmail", + fieldsToFetch: _.extend({}, M2MTags.prototype.fieldsToFetch, { + email: {type: 'char'}, + }), + specialData: "_setInvalidMany2ManyTagsEmail", + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Open a popup for each invalid partners (without email) to fill the email. + * + * @private + * @returns {Promise} + */ + _checkEmailPopup: function () { + var self = this; + + var popupDefs = []; + var validPartners = []; + + // propose the user to correct invalid partners + _.each(this.record.specialData[this.name].invalidPartnerIds, function (resID) { + var def = new Promise(function (resolve, reject) { + var pop = new form_common.FormViewDialog(self, { + res_model: self.field.relation, + res_id: resID, + context: self.record.context, + title: "", + on_saved: function (record) { + if (record.data.email) { + validPartners.push(record.res_id); + } + }, + }).open(); + pop.on('closed', self, function () { + resolve(); + }); + }); + popupDefs.push(def); + }); + return Promise.all(popupDefs).then(function() { + // All popups have been processed for the given ids + // It is now time to set the final value with valid partners ids. + validPartners = _.uniq(validPartners); + if (validPartners.length) { + var values = _.map(validPartners, function (id) { + return {id: id}; + }); + self._setValue({ + operation: 'ADD_M2M', + ids: values, + }); + } + }); + }, + /** + * Override to check if all many2many values have an email set before + * rendering the widget. + * + * @override + * @private + */ + _render: function () { + var self = this; + var _super = this._super.bind(this); + return new Promise(function (resolve, reject) { + if (self.record.specialData[self.name].invalidPartnerIds.length) { + resolve(self._checkEmailPopup()); + } else { + resolve(); + } + }).then(function () { + return _super.apply(self, arguments); + }); + }, +}); + +field_registry.add('many2many_tags_email', FieldMany2ManyTagsEmail); + +}); diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js new file mode 100644 index 00000000..6a5b5270 --- /dev/null +++ b/addons/mail/static/src/js/many2one_avatar_user.js @@ -0,0 +1,68 @@ +odoo.define('mail.Many2OneAvatarUser', function (require) { + "use strict"; + + // This module defines an extension of the Many2OneAvatar widget, which is + // integrated with the messaging system. The Many2OneAvatarUser is designed + // to display people, and when the avatar of those people is clicked, it + // opens a DM chat window with the corresponding user. + // + // This widget is supported on many2one fields pointing to 'res.users'. + // + // Usage: + // <field name="user_id" widget="many2one_avatar_user"/> + // + // The widget is designed to be extended, to support many2one fields pointing + // to other models than 'res.users'. + + const fieldRegistry = require('web.field_registry'); + const { Many2OneAvatar } = require('web.relational_fields'); + + const { Component } = owl; + + const Many2OneAvatarUser = Many2OneAvatar.extend({ + events: Object.assign({}, Many2OneAvatar.prototype.events, { + 'click .o_m2o_avatar': '_onAvatarClicked', + }), + // This widget is only supported on many2ones pointing to 'res.users' + supportedModels: ['res.users'], + + init() { + this._super(...arguments); + if (!this.supportedModels.includes(this.field.relation)) { + throw new Error(`This widget is only supported on many2one fields pointing to ${JSON.stringify(this.supportedModels)}`); + } + if (this.mode === 'readonly') { + this.className += ' o_clickable_m2o_avatar'; + } + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * When the avatar is clicked, open a DM chat window with the + * corresponding user. + * + * @private + * @param {MouseEvent} ev + */ + async _onAvatarClicked(ev) { + ev.stopPropagation(); // in list view, prevent from opening the record + const env = Component.env; + await env.messaging.openChat({ userId: this.value.res_id }); + } + }); + + const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({ + _template: 'mail.KanbanMany2OneAvatarUser', + }); + + fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser); + fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser); + + return { + Many2OneAvatarUser, + KanbanMany2OneAvatarUser, + }; +}); diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js new file mode 100644 index 00000000..a7299599 --- /dev/null +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -0,0 +1,202 @@ +odoo.define('mail.systray.ActivityMenu', function (require) { +"use strict"; + +var core = require('web.core'); +var session = require('web.session'); +var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); +var Time = require('web.time'); +var QWeb = core.qweb; + +const { Component } = owl; + +/** + * Menu item appended in the systray part of the navbar, redirects to the next + * activities of all app + */ +var ActivityMenu = Widget.extend({ + name: 'activity_menu', + template:'mail.systray.ActivityMenu', + events: { + 'click .o_mail_activity_action': '_onActivityActionClick', + 'click .o_mail_preview': '_onActivityFilterClick', + 'show.bs.dropdown': '_onActivityMenuShow', + 'hide.bs.dropdown': '_onActivityMenuHide', + }, + start: function () { + this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items'); + Component.env.bus.on('activity_updated', this, this._updateCounter); + this._updateCounter(); + 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: 'systray_get_activities', + args: [], + kwargs: {context: session.user_context}, + }).then(function (data) { + self._activities = data; + self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count || 0; }, 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' + }); + }, + /** + * Return views to display when coming from systray depending on the model. + * + * @private + * @param {string} model + * @returns {Array[]} output the list of views to display. + */ + _getViewsList(model) { + return [[false, 'kanban'], [false, 'list'], [false, 'form']]; + }, + /** + * Update(render) activity system tray view on activity updation. + * @private + */ + _updateActivityPreview: function () { + var self = this; + self._getActivityData().then(function (){ + self._$activitiesPreview.html(QWeb.render('mail.systray.ActivityMenu.Previews', { + widget: self, + Time: Time + })); + }); + }, + /** + * 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.$('.o_notification_counter').text(this.activityCounter); + this.$el.toggleClass('o_no_notification', !this.activityCounter); + } + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * Redirect to specific action given its xml id or to the activity + * view of the current model if no xml id is provided + * + * @private + * @param {MouseEvent} ev + */ + _onActivityActionClick: function (ev) { + ev.stopPropagation(); + this.$('.dropdown-toggle').dropdown('toggle'); + var targetAction = $(ev.currentTarget); + var actionXmlid = targetAction.data('action_xmlid'); + if (actionXmlid) { + this.do_action(actionXmlid); + } else { + var domain = [['activity_ids.user_id', '=', session.uid]] + if (targetAction.data('domain')) { + domain = domain.concat(targetAction.data('domain')) + } + + this.do_action({ + type: 'ir.actions.act_window', + name: targetAction.data('model_name'), + views: [[false, 'activity'], [false, 'kanban'], [false, 'list'], [false, 'form']], + view_mode: 'activity', + res_model: targetAction.data('res_model'), + domain: domain, + }, { + clear_breadcrumbs: true, + }); + } + }, + + /** + * Redirect to particular model view + * @private + * @param {MouseEvent} event + */ + _onActivityFilterClick: function (event) { + // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_preview). + var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data()); + var context = {}; + if (data.filter === 'my') { + context['search_default_activities_overdue'] = 1; + context['search_default_activities_today'] = 1; + } else { + context['search_default_activities_' + data.filter] = 1; + } + // Necessary because activity_ids of mail.activity.mixin has auto_join + // So, duplicates are faking the count and "Load more" doesn't show up + context['force_search_count'] = 1; + + var domain = [['activity_ids.user_id', '=', session.uid]] + if (data.domain) { + domain = domain.concat(data.domain) + } + + this.do_action({ + type: 'ir.actions.act_window', + name: data.model_name, + res_model: data.res_model, + views: this._getViewsList(data.res_model), + search_view_id: [false], + domain: domain, + context:context, + }, { + clear_breadcrumbs: true, + }); + }, + /** + * @private + */ + _onActivityMenuShow: function () { + document.body.classList.add('modal-open'); + this._updateActivityPreview(); + }, + /** + * @private + */ + _onActivityMenuHide: function () { + document.body.classList.remove('modal-open'); + }, +}); + +SystrayMenu.Items.push(ActivityMenu); + +return ActivityMenu; + +}); diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js new file mode 100644 index 00000000..798765eb --- /dev/null +++ b/addons/mail/static/src/js/tools/debug_manager.js @@ -0,0 +1,33 @@ +odoo.define('mail.DebugManager.Backend', function (require) { +"use strict"; + +var core = require('web.core'); +var DebugManager = require('web.DebugManager.Backend'); + +var _t = core._t; +/** + * adds a new method available for the debug manager, called by the "Manage Messages" button. + * + */ +DebugManager.include({ + getMailMessages: function () { + var selectedIDs = this._controller.getSelectedIds(); + if (!selectedIDs.length) { + console.warn(_t("No message available")); + return; + } + this.do_action({ + res_model: 'mail.message', + name: _t('Manage Messages'), + views: [[false, 'list'], [false, 'form']], + type: 'ir.actions.act_window', + domain: [['res_id', '=', selectedIDs[0]], ['model', '=', this._controller.modelName]], + context: { + default_res_model: this._controller.modelName, + default_res_id: selectedIDs[0], + }, + }); + }, +}); + +}); diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js new file mode 100644 index 00000000..8870abc6 --- /dev/null +++ b/addons/mail/static/src/js/tours/mail.js @@ -0,0 +1,59 @@ +odoo.define('mail.tour', function (require) { +"use strict"; + +var core = require('web.core'); +var tour = require('web_tour.tour'); + +var _t = core._t; + +tour.register('mail_tour', { + url: "/web#action=mail.widgets.discuss", + sequence: 80, +}, [{ + trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd', + content: _t("<p>Channels make it easy to organize information across different topics and groups.</p> <p>Try to <b>create your first channel</b> (e.g. sales, marketing, product XYZ, after work party, etc).</p>"), + position: 'bottom', +}, { + trigger: '.o_DiscussSidebar_itemNewInput', + content: _t("<p>Create a channel here.</p>"), + position: 'bottom', + auto: true, + run: function (actions) { + var t = new Date().getTime(); + actions.text("SomeChannel_" + t, this.$anchor); + }, +}, { + trigger: ".o_DiscussSidebar_newChannelAutocompleteSuggestions", + content: _t("<p>Create a public or private channel.</p>"), + position: 'right', + run() { + this.$consumeEventAnchors.find('li:first').click(); + }, +}, { + trigger: '.o_Discuss_thread .o_ComposerTextInput_textarea', + content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>'@'</i> or link another channel with <i>'#'</i>. Start your message with <i>'/'</i> to get the list of possible commands.</p>"), + position: "top", + width: 350, + run: function (actions) { + var t = new Date().getTime(); + actions.text("SomeText_" + t, this.$anchor); + }, +}, { + trigger: '.o_Discuss_thread .o_Composer_buttonSend', + content: _t("Post your message on the thread"), + position: "top", +}, { + trigger: '.o_Discuss_thread .o_Message_commandStar', + content: _t("Messages can be <b>starred</b> to remind you to check back later."), + position: "bottom", +}, { + trigger: '.o_DiscussSidebarItem.o-starred-box', + content: _t("Once a message has been starred, you can come back and review it at any time here."), + position: "bottom", +}, { + trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd', + content: _t("<p><b>Chat with coworkers</b> in real-time using direct messages.</p><p><i>You might need to invite users from the Settings app first.</i></p>"), + position: 'bottom', +}]); + +}); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js new file mode 100644 index 00000000..d267623d --- /dev/null +++ b/addons/mail/static/src/js/utils.js @@ -0,0 +1,187 @@ +odoo.define('mail.utils', function (require) { +"use strict"; + +var core = require('web.core'); + +var _t = core._t; + +/** + * WARNING: this is not enough to unescape potential XSS contained in htmlString, transformFunction + * should handle it or it should be handled after/before calling parseAndTransform. So if the result + * of this function is used in a t-raw, be very careful. + * + * @param {string} htmlString + * @param {function} transformFunction + * @returns {string} + */ +function parseAndTransform(htmlString, transformFunction) { + var openToken = "OPEN" + Date.now(); + var string = htmlString.replace(/</g, openToken); + var children; + try { + children = $('<div>').html(string).contents(); + } catch (e) { + children = $('<div>').html('<pre>' + string + '</pre>').contents(); + } + return _parseAndTransform(children, transformFunction) + .replace(new RegExp(openToken, "g"), "<"); +} + +/** + * @param {Node[]} nodes + * @param {function} transformFunction with: + * param node + * param function + * return string + * @return {string} + */ +function _parseAndTransform(nodes, transformFunction) { + return _.map(nodes, function (node) { + return transformFunction(node, function () { + return _parseAndTransform(node.childNodes, transformFunction); + }); + }).join(""); +} + +// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url +// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match. +// And further extended to include Latin-1 Supplement, Latin Extended-A, Latin Extended-B and Latin Extended Additional. +var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=\u00C0-\u024F\u1E00-\u1EFF]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=;\u00C0-\u024F\u1E00-\u1EFF]*)/gi; +/** + * @param {string} text + * @param {Object} [attrs={}] + * @return {string} linkified text + */ +function linkify(text, attrs) { + attrs = attrs || {}; + if (attrs.target === undefined) { + attrs.target = '_blank'; + } + if (attrs.target === '_blank') { + attrs.rel = 'noreferrer noopener'; + } + attrs = _.map(attrs, function (value, key) { + return key + '="' + _.escape(value) + '"'; + }).join(' '); + return text.replace(urlRegexp, function (url) { + var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url; + return '<a ' + attrs + ' href="' + href + '">' + url + '</a>'; + }); +} + +function addLink(node, transformChildren) { + if (node.nodeType === 3) { // text node + const linkified = linkify(node.data); + if (linkified !== node.data) { + const div = document.createElement('div'); + div.innerHTML = linkified; + for (const childNode of [...div.childNodes]) { + node.parentNode.insertBefore(childNode, node); + } + node.parentNode.removeChild(node); + return linkified; + } + return node.textContent; + } + if (node.tagName === "A") return node.outerHTML; + transformChildren(); + return node.outerHTML; +} + +/** + * @param {string} htmlString + * @return {string} + */ +function htmlToTextContentInline(htmlString) { + const fragment = document.createDocumentFragment(); + const div = document.createElement('div'); + fragment.appendChild(div); + htmlString = htmlString.replace(/<br\s*\/?>/gi,' '); + try { + div.innerHTML = htmlString; + } catch (e) { + div.innerHTML = `<pre>${htmlString}</pre>`; + } + return div + .textContent + .trim() + .replace(/[\n\r]/g, '') + .replace(/\s\s+/g, ' '); +} + +function stripHTML(node, transformChildren) { + if (node.nodeType === 3) return node.data; // text node + if (node.tagName === "BR") return "\n"; + return transformChildren(); +} + +function inline(node, transform_children) { + if (node.nodeType === 3) return node.data; + if (node.nodeType === 8) return ""; + if (node.tagName === "BR") return " "; + if (node.tagName.match(/^(A|P|DIV|PRE|BLOCKQUOTE)$/)) return transform_children(); + node.innerHTML = transform_children(); + return node.outerHTML; +} + +// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False +function parseEmail(text) { + if (text){ + var result = text.match(/(.*)<(.*@.*)>/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[2])]; + } + result = text.match(/(.*@.*)/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[1])]; + } + return [text, false]; + } +} + +/** + * Returns an escaped conversion of a content. + * + * @param {string} content + * @returns {string} + */ +function escapeAndCompactTextContent(content) { + //Removing unwanted extra spaces from message + let value = owl.utils.escape(content).trim(); + value = value.replace(/(\r|\n){2,}/g, '<br/><br/>'); + value = value.replace(/(\r|\n)/g, '<br/>'); + + // prevent html space collapsing + value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); + return value; +} + +// Replaces textarea text into html text (add <p>, <a>) +// TDE note : should be done server-side, in Python -> use mail.compose.message ? +function getTextToHTML(text) { + return text + .replace(/((?:https?|ftp):\/\/[\S]+)/g,'<a href="$1">$1</a> ') + .replace(/[\n\r]/g,'<br/>'); +} + +function timeFromNow(date) { + if (moment().diff(date, 'seconds') < 45) { + return _t("now"); + } + return date.fromNow(); +} + +return { + addLink: addLink, + getTextToHTML: getTextToHTML, + htmlToTextContentInline, + inline: inline, + linkify: linkify, + parseAndTransform: parseAndTransform, + parseEmail: parseEmail, + stripHTML: stripHTML, + timeFromNow: timeFromNow, + escapeAndCompactTextContent, +}; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_cell.js b/addons/mail/static/src/js/views/activity/activity_cell.js new file mode 100644 index 00000000..d1cdca94 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_cell.js @@ -0,0 +1,42 @@ +odoo.define("mail.ActivityCell", function (require) { + "use strict"; + + require("mail.Activity"); + const field_registry = require('web.field_registry'); + + const KanbanActivity = field_registry.get('kanban_activity'); + + const ActivityCell = KanbanActivity.extend({ + /** + * @override + * @private + */ + _render() { + // replace clock by closest deadline + const $date = $('<div class="o_closest_deadline">'); + const date = new Date(this.record.data.closest_deadline); + // To remove year only if current year + if (moment().year() === moment(date).year()) { + $date.text(date.toLocaleDateString(moment().locale(), { + day: 'numeric', month: 'short' + })); + } else { + $date.text(moment(date).format('ll')); + } + this.$('a').html($date); + if (this.record.data.activity_ids.res_ids.length > 1) { + this.$('a').append($('<span>', { + class: 'badge badge-light badge-pill border-0 ' + this.record.data.activity_state, + text: this.record.data.activity_ids.res_ids.length, + })); + } + if (this.$el.hasClass('show')) { + // note: this part of the rendering might be asynchronous + this._renderDropdown(); + } + } + }); + + return ActivityCell; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_controller.js b/addons/mail/static/src/js/views/activity/activity_controller.js new file mode 100644 index 00000000..106c5ee9 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_controller.js @@ -0,0 +1,124 @@ +odoo.define('mail.ActivityController', function (require) { +"use strict"; + +require('mail.Activity'); +var BasicController = require('web.BasicController'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); +var ViewDialogs = require('web.view_dialogs'); + +var KanbanActivity = field_registry.get('kanban_activity'); +var _t = core._t; + +var ActivityController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + empty_cell_clicked: '_onEmptyCell', + send_mail_template: '_onSendMailTemplate', + schedule_activity: '_onScheduleActivity', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @param parent + * @param model + * @param renderer + * @param {Object} params + * @param {String} params.title The title used in schedule activity dialog + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.title = params.title; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overridden to remove the pager as it makes no sense in this view. + * + * @override + */ + _getPagingInfo: function () { + return null; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onScheduleActivity: function () { + var self = this; + var state = this.model.get(this.handle); + new ViewDialogs.SelectCreateDialog(this, { + res_model: state.model, + domain: this.model.originalDomain, + title: _.str.sprintf(_t("Search: %s"), this.title), + no_create: !this.activeActions.create, + disable_multiple_selection: true, + context: state.context, + on_selected: function (record) { + var fakeRecord = state.getKanbanActivityData({}, record[0]); + var widget = new KanbanActivity(self, 'activity_ids', fakeRecord, {}); + widget.scheduleActivity(); + }, + }).open(); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onEmptyCell: function (ev) { + var state = this.model.get(this.handle); + this.do_action({ + type: 'ir.actions.act_window', + res_model: 'mail.activity', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: ev.data.resId, + default_res_model: state.model, + default_activity_type_id: ev.data.activityTypeId, + }, + res_id: false, + }, { + on_close: this.reload.bind(this), + }); + }, + /** + * @private + * @param {CustomEvent} ev + */ + _onSendMailTemplate: function (ev) { + var templateID = ev.data.templateID; + var activityTypeID = ev.data.activityTypeID; + var state = this.model.get(this.handle); + var groupedActivities = state.grouped_activities; + var resIDS = []; + Object.keys(groupedActivities).forEach(function (resID) { + var activityByType = groupedActivities[resID]; + var activity = activityByType[activityTypeID]; + if (activity) { + resIDS.push(parseInt(resID)); + } + }); + this._rpc({ + model: this.model.modelName, + method: 'activity_send_mail', + args: [resIDS, templateID], + }); + }, +}); + +return ActivityController; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_model.js b/addons/mail/static/src/js/views/activity/activity_model.js new file mode 100644 index 00000000..cfb9e36a --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_model.js @@ -0,0 +1,124 @@ +odoo.define('mail.ActivityModel', function (require) { +'use strict'; + +const BasicModel = require('web.BasicModel'); +const session = require('web.session'); + +const ActivityModel = BasicModel.extend({ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Add the following (activity specific) keys when performing a `get` on the + * main list datapoint: + * - activity_types + * - activity_res_ids + * - grouped_activities + * + * @override + */ + __get: function () { + var result = this._super.apply(this, arguments); + if (result && result.model === this.modelName && result.type === 'list') { + _.extend(result, this.additionalData, {getKanbanActivityData: this.getKanbanActivityData}); + } + return result; + }, + /** + * @param {Object} activityGroup + * @param {integer} resId + * @returns {Object} + */ + getKanbanActivityData(activityGroup, resId) { + return { + data: { + activity_ids: { + model: 'mail.activity', + res_ids: activityGroup.ids, + }, + activity_state: activityGroup.state, + closest_deadline: activityGroup.o_closest_deadline, + }, + fields: { + activity_ids: {}, + activity_state: { + selection: [ + ['overdue', "Overdue"], + ['today', "Today"], + ['planned', "Planned"], + ], + }, + }, + fieldsInfo: {}, + model: this.model, + type: 'record', + res_id: resId, + getContext: function () { + return {}; + }, + }; + }, + /** + * @override + * @param {Array[]} params.domain + */ + __load: function (params) { + this.originalDomain = _.extend([], params.domain); + params.domain.push(['activity_ids', '!=', false]); + this.domain = params.domain; + this.modelName = params.modelName; + params.groupedBy = []; + var def = this._super.apply(this, arguments); + return Promise.all([def, this._fetchData()]).then(function (result) { + return result[0]; + }); + }, + /** + * @override + * @param {Array[]} [params.domain] + */ + __reload: function (handle, params) { + if (params && 'domain' in params) { + this.originalDomain = _.extend([], params.domain); + params.domain.push(['activity_ids', '!=', false]); + this.domain = params.domain; + } + if (params && 'groupBy' in params) { + params.groupBy = []; + } + var def = this._super.apply(this, arguments); + return Promise.all([def, this._fetchData()]).then(function (result) { + return result[0]; + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetch activity data. + * + * @private + * @returns {Promise} + */ + _fetchData: function () { + var self = this; + return this._rpc({ + model: "mail.activity", + method: 'get_activity_data', + kwargs: { + res_model: this.modelName, + domain: this.domain, + context: session.user_context, + } + }).then(function (result) { + self.additionalData = result; + }); + }, +}); + +return ActivityModel; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_record.js b/addons/mail/static/src/js/views/activity/activity_record.js new file mode 100644 index 00000000..98da9dca --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_record.js @@ -0,0 +1,62 @@ +odoo.define('mail.ActivityRecord', function (require) { +"use strict"; + +var KanbanRecord = require('web.KanbanRecord'); + +var ActivityRecord = KanbanRecord.extend({ + /** + * @override + */ + init: function (parent, state) { + this._super.apply(this,arguments); + + this.fieldsInfo = state.fieldsInfo.activity; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this.defs = []; + this._replaceElement(this.qweb.render('activity-box', this.qweb_context)); + this.$el.on('click', this._onGlobalClick.bind(this)); + this.$el.addClass('o_activity_record'); + this._processFields(); + this._setupColor(); + return Promise.all(this.defs); + }, + /** + * @override + * @private + */ + _setFieldDisplay: function ($el, fieldName) { + this._super.apply(this, arguments); + + // attribute muted + if (this.fieldsInfo[fieldName].muted) { + $el.addClass('text-muted'); + } + }, + /** + * @override + * @private + */ + _setState: function () { + this._super.apply(this, arguments); + + // activity has a different qweb context + this.qweb_context = { + activity_image: this._getImageURL.bind(this), + record: this.record, + user_context: this.getSession().user_context, + widget: this, + }; + }, +}); +return ActivityRecord; +}); diff --git a/addons/mail/static/src/js/views/activity/activity_renderer.js b/addons/mail/static/src/js/views/activity/activity_renderer.js new file mode 100644 index 00000000..e62d416e --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_renderer.js @@ -0,0 +1,210 @@ +odoo.define('mail.ActivityRenderer', function (require) { +"use strict"; + +const AbstractRendererOwl = require('web.AbstractRendererOwl'); +const ActivityCell = require('mail.ActivityCell'); +const ActivityRecord = require('mail.ActivityRecord'); +const { ComponentAdapter } = require('web.OwlCompatibility'); +const core = require('web.core'); +const KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); +const patchMixin = require('web.patchMixin'); +const QWeb = require('web.QWeb'); +const session = require('web.session'); +const utils = require('web.utils'); + +const _t = core._t; + +const { useState } = owl.hooks; + +/** + * Owl Component Adapter for ActivityRecord which is KanbanRecord (Odoo Widget) + * TODO: Remove this adapter when ActivityRecord is a Component + */ +class ActivityRecordAdapter extends ComponentAdapter { + renderWidget() { + _.invoke(_.pluck(this.widget.subWidgets, '$el'), 'detach'); + this.widget._render(); + } + + updateWidget(nextProps) { + const state = nextProps.widgetArgs[0]; + this.widget._setState(state); + } +} + +/** + * Owl Component Adapter for ActivityCell which is BasicActivity (AbstractField) + * TODO: Remove this adapter when ActivityCell is a Component + */ +class ActivityCellAdapter extends ComponentAdapter { + renderWidget() { + this.widget._render(); + } + + updateWidget(nextProps) { + const record = nextProps.widgetArgs[1]; + this.widget._reset(record); + } +} + +/** + * Owl Component Adapter for KanbanColumnProgressBar (Odoo Widget) + * TODO: Remove this adapter when KanbanColumnProgressBar is a Component + */ +class KanbanColumnProgressBarAdapter extends ComponentAdapter { + renderWidget() { + this.widget._render(); + } + + updateWidget(nextProps) { + const options = nextProps.widgetArgs[0]; + const columnState = nextProps.widgetArgs[1]; + + const columnId = options.columnID; + const nextActiveFilter = options.progressBarStates[columnId].activeFilter; + this.widget.activeFilter = nextActiveFilter ? this.widget.activeFilter : false; + this.widget.columnState = columnState; + this.widget.computeCounters(); + } + + _trigger_up(ev) { + // KanbanColumnProgressBar triggers 3 events before being mounted + // but we don't need to listen to them in our case. + if (this.el) { + super._trigger_up(ev); + } + } +} + +class ActivityRenderer extends AbstractRendererOwl { + constructor(parent, props) { + super(...arguments); + this.qweb = new QWeb(this.env.isDebug(), {_s: session.origin}); + this.qweb.add_template(utils.json_node_to_xml(props.templates)); + this.activeFilter = useState({ + state: null, + activityTypeId: null, + resIds: [] + }); + this.widgetComponents = { + ActivityRecord, + ActivityCell, + KanbanColumnProgressBar, + }; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Gets all activity resIds in the view. + * + * @returns filtered resIds first then the rest. + */ + get activityResIds() { + const copiedActivityResIds = Array.from(this.props.activity_res_ids) + return copiedActivityResIds.sort((a, b) => this.activeFilter.resIds.includes(a) ? -1 : 0); + } + + /** + * Gets all existing activity type ids. + */ + get activityTypeIds() { + const activities = Object.values(this.props.grouped_activities); + const activityIds = activities.flatMap(Object.keys); + const uniqueIds = Array.from(new Set(activityIds)); + return uniqueIds.map(Number); + } + + getProgressBarOptions(typeId) { + return { + columnID: typeId, + progressBarStates: { + [typeId]: { + activeFilter: this.activeFilter.activityTypeId === typeId, + }, + }, + }; + } + + getProgressBarColumnState(typeId) { + const counts = { planned: 0, today: 0, overdue: 0 }; + for (let activities of Object.values(this.props.grouped_activities)) { + if (typeId in activities) { + counts[activities[typeId].state] += 1; + } + } + return { + count: Object.values(counts).reduce((x, y) => x + y), + fields: { + activity_state: { + type: 'selection', + selection: [ + ['planned', _t('Planned')], + ['today', _t('Today')], + ['overdue', _t('Overdue')], + ], + }, + }, + progressBarValues: { + field: 'activity_state', + colors: { planned: 'success', today: 'warning', overdue: 'danger' }, + counts: counts, + }, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * @private + * @param {MouseEvent} ev + */ + _onEmptyCellClicked(ev) { + this.trigger('empty_cell_clicked', { + resId: parseInt(ev.currentTarget.dataset.resId, 10), + activityTypeId: parseInt(ev.currentTarget.dataset.activityTypeId, 10), + }); + } + /** + * @private + * @param {MouseEvent} ev + */ + _onSendMailTemplateClicked(ev) { + this.trigger('send_mail_template', { + activityTypeID: parseInt(ev.currentTarget.dataset.activityTypeId, 10), + templateID: parseInt(ev.currentTarget.dataset.templateId, 10), + }); + } + /** + * @private + * @param {CustomEvent} ev + */ + _onSetProgressBarState(ev) { + if (ev.detail.values.activeFilter) { + this.activeFilter.state = ev.detail.values.activeFilter; + this.activeFilter.activityTypeId = ev.detail.columnID; + this.activeFilter.resIds = Object.entries(this.props.grouped_activities) + .filter(([, resIds]) => ev.detail.columnID in resIds && + resIds[ev.detail.columnID].state === ev.detail.values.activeFilter) + .map(([key]) => parseInt(key)); + } else { + this.activeFilter.state = null; + this.activeFilter.activityTypeId = null; + this.activeFilter.resIds = []; + } + } +} + +ActivityRenderer.components = { + ActivityRecordAdapter, + ActivityCellAdapter, + KanbanColumnProgressBarAdapter, +}; +ActivityRenderer.template = 'mail.ActivityRenderer'; + +return patchMixin(ActivityRenderer); + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_view.js b/addons/mail/static/src/js/views/activity/activity_view.js new file mode 100644 index 00000000..e2e3eded --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_view.js @@ -0,0 +1,53 @@ +odoo.define('mail.ActivityView', function (require) { +"use strict"; + +const ActivityController = require('mail.ActivityController'); +const ActivityModel = require('mail.ActivityModel'); +const ActivityRenderer = require('mail.ActivityRenderer'); +const BasicView = require('web.BasicView'); +const core = require('web.core'); +const RendererWrapper = require('web.RendererWrapper'); +const view_registry = require('web.view_registry'); + +const _lt = core._lt; + +const ActivityView = BasicView.extend({ + accesskey: "a", + display_name: _lt('Activity'), + icon: 'fa-clock-o', + config: _.extend({}, BasicView.prototype.config, { + Controller: ActivityController, + Model: ActivityModel, + Renderer: ActivityRenderer, + }), + viewType: 'activity', + searchMenuTypes: ['filter', 'favorite'], + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + + this.loadParams.type = 'list'; + // limit makes no sense in this view as we display all records having activities + this.loadParams.limit = false; + + this.rendererParams.templates = _.findWhere(this.arch.children, { 'tag': 'templates' }); + this.controllerParams.title = this.arch.attrs.string; + }, + /** + * + * @override + */ + getRenderer(parent, state) { + state = Object.assign({}, state, this.rendererParams); + return new RendererWrapper(null, this.config.Renderer, state); + }, +}); + +view_registry.add('activity', ActivityView); + +return ActivityView; + +}); |
