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/activity.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/js/activity.js')
| -rw-r--r-- | addons/mail/static/src/js/activity.js | 868 |
1 files changed, 868 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; + +}); |
