summaryrefslogtreecommitdiff
path: root/addons/calendar/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/calendar/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/calendar/static/src')
-rw-r--r--addons/calendar/static/src/js/base_calendar.js164
-rw-r--r--addons/calendar/static/src/js/calendar_controller.js65
-rw-r--r--addons/calendar/static/src/js/calendar_model.js21
-rw-r--r--addons/calendar/static/src/js/calendar_renderer.js118
-rw-r--r--addons/calendar/static/src/js/calendar_view.js22
-rw-r--r--addons/calendar/static/src/js/mail_activity.js60
-rw-r--r--addons/calendar/static/src/js/systray_activity_menu.js56
-rw-r--r--addons/calendar/static/src/scss/calendar.scss69
-rw-r--r--addons/calendar/static/src/xml/base_calendar.xml58
-rw-r--r--addons/calendar/static/src/xml/notification_calendar.xml19
10 files changed, 652 insertions, 0 deletions
diff --git a/addons/calendar/static/src/js/base_calendar.js b/addons/calendar/static/src/js/base_calendar.js
new file mode 100644
index 00000000..f2c1e56b
--- /dev/null
+++ b/addons/calendar/static/src/js/base_calendar.js
@@ -0,0 +1,164 @@
+odoo.define('base_calendar.base_calendar', function (require) {
+"use strict";
+
+var BasicModel = require('web.BasicModel');
+var fieldRegistry = require('web.field_registry');
+var Notification = require('web.Notification');
+var relationalFields = require('web.relational_fields');
+var session = require('web.session');
+var WebClient = require('web.WebClient');
+
+var FieldMany2ManyTags = relationalFields.FieldMany2ManyTags;
+
+var CalendarNotification = Notification.extend({
+ template: "CalendarNotification",
+ xmlDependencies: (Notification.prototype.xmlDependencies || [])
+ .concat(['/calendar/static/src/xml/notification_calendar.xml']),
+
+ init: function(parent, params) {
+ this._super(parent, params);
+ this.eid = params.eventID;
+ this.sticky = true;
+
+ this.events = _.extend(this.events || {}, {
+ 'click .link2event': function() {
+ var self = this;
+
+ this._rpc({
+ route: '/web/action/load',
+ params: {
+ action_id: 'calendar.action_calendar_event_notify',
+ },
+ })
+ .then(function(r) {
+ r.res_id = self.eid;
+ return self.do_action(r);
+ });
+ },
+
+ 'click .link2recall': function() {
+ this.close();
+ },
+
+ 'click .link2showed': function() {
+ this._rpc({route: '/calendar/notify_ack'})
+ .then(this.close.bind(this, false), this.close.bind(this, false));
+ },
+ });
+ },
+});
+
+WebClient.include({
+ display_calendar_notif: function(notifications) {
+ var self = this;
+ var last_notif_timer = 0;
+
+ // Clear previously set timeouts and destroy currently displayed calendar notifications
+ clearTimeout(this.get_next_calendar_notif_timeout);
+ _.each(this.calendar_notif_timeouts, clearTimeout);
+ this.calendar_notif_timeouts = {};
+
+ // For each notification, set a timeout to display it
+ _.each(notifications, function(notif) {
+ var key = notif.event_id + ',' + notif.alarm_id;
+ if (key in self.calendar_notif) {
+ return;
+ }
+ self.calendar_notif_timeouts[key] = setTimeout(function () {
+ var notificationID = self.call('notification', 'notify', {
+ Notification: CalendarNotification,
+ title: notif.title,
+ message: notif.message,
+ eventID: notif.event_id,
+ onClose: function () {
+ delete self.calendar_notif[key];
+ },
+ });
+ self.calendar_notif[key] = notificationID;
+ }, notif.timer * 1000);
+ last_notif_timer = Math.max(last_notif_timer, notif.timer);
+ });
+
+ // Set a timeout to get the next notifications when the last one has been displayed
+ if (last_notif_timer > 0) {
+ this.get_next_calendar_notif_timeout = setTimeout(this.get_next_calendar_notif.bind(this), last_notif_timer * 1000);
+ }
+ },
+ get_next_calendar_notif: function() {
+ session.rpc("/calendar/notify", {}, {shadow: true})
+ .then(this.display_calendar_notif.bind(this))
+ .guardedCatch(function(reason) { //
+ var err = reason.message;
+ var ev = reason.event;
+ if(err.code === -32098) {
+ // Prevent the CrashManager to display an error
+ // in case of an xhr error not due to a server error
+ ev.preventDefault();
+ }
+ });
+ },
+ show_application: function() {
+ // An event is triggered on the bus each time a calendar event with alarm
+ // in which the current user is involved is created, edited or deleted
+ this.calendar_notif_timeouts = {};
+ this.calendar_notif = {};
+ this.call('bus_service', 'onNotification', this, function (notifications) {
+ _.each(notifications, (function (notification) {
+ if (notification[0][1] === 'calendar.alarm') {
+ this.display_calendar_notif(notification[1]);
+ }
+ }).bind(this));
+ });
+ return this._super.apply(this, arguments).then(this.get_next_calendar_notif.bind(this));
+ },
+});
+
+BasicModel.include({
+ /**
+ * @private
+ * @param {Object} record
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchSpecialAttendeeStatus: function (record, fieldName) {
+ var context = record.getContext({fieldName: fieldName});
+ var attendeeIDs = record.data[fieldName] ? this.localData[record.data[fieldName]].res_ids : [];
+ var meetingID = _.isNumber(record.res_id) ? record.res_id : false;
+ return this._rpc({
+ model: 'res.partner',
+ method: 'get_attendee_detail',
+ args: [attendeeIDs, meetingID],
+ context: context,
+ }).then(function (result) {
+ return _.map(result, function (d) {
+ return _.object(['id', 'display_name', 'status', 'color'], d);
+ });
+ });
+ },
+});
+
+var Many2ManyAttendee = FieldMany2ManyTags.extend({
+ // as this widget is model dependant (rpc on res.partner), use it in another
+ // context probably won't work
+ // supportedFieldTypes: ['many2many'],
+ tag_template: "Many2ManyAttendeeTag",
+ specialData: "_fetchSpecialAttendeeStatus",
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _getRenderTagsContext: function () {
+ var result = this._super.apply(this, arguments);
+ result.attendeesData = this.record.specialData.partner_ids;
+ return result;
+ },
+});
+
+fieldRegistry.add('many2manyattendee', Many2ManyAttendee);
+
+});
diff --git a/addons/calendar/static/src/js/calendar_controller.js b/addons/calendar/static/src/js/calendar_controller.js
new file mode 100644
index 00000000..190125ac
--- /dev/null
+++ b/addons/calendar/static/src/js/calendar_controller.js
@@ -0,0 +1,65 @@
+odoo.define('calendar.CalendarController', function (require) {
+ "use strict";
+
+ const Controller = require('web.CalendarController');
+ const Dialog = require('web.Dialog');
+ const { qweb, _t } = require('web.core');
+
+ const CalendarController = Controller.extend({
+
+ _askRecurrenceUpdatePolicy() {
+ return new Promise((resolve, reject) => {
+ new Dialog(this, {
+ title: _t('Edit Recurrent event'),
+ size: 'small',
+ $content: $(qweb.render('calendar.RecurrentEventUpdate')),
+ buttons: [{
+ text: _t('Confirm'),
+ classes: 'btn-primary',
+ close: true,
+ click: function () {
+ resolve(this.$('input:checked').val());
+ },
+ }],
+ }).open();
+ });
+ },
+
+ // TODO factorize duplicated code
+ /**
+ * @override
+ * @private
+ * @param {OdooEvent} event
+ */
+ async _onDropRecord(event) {
+ const _super = this._super; // reference to this._super is lost after async call
+ if (event.data.record.recurrency) {
+ const recurrenceUpdate = await this._askRecurrenceUpdatePolicy();
+ event.data = _.extend({}, event.data, {
+ 'recurrenceUpdate': recurrenceUpdate,
+ });
+ }
+ _super.apply(this, arguments);
+ },
+
+ /**
+ * @override
+ * @private
+ * @param {OdooEvent} event
+ */
+ async _onUpdateRecord(event) {
+ const _super = this._super; // reference to this._super is lost after async call
+ if (event.data.record.recurrency) {
+ const recurrenceUpdate = await this._askRecurrenceUpdatePolicy();
+ event.data = _.extend({}, event.data, {
+ 'recurrenceUpdate': recurrenceUpdate,
+ });
+ }
+ _super.apply(this, arguments);
+ },
+
+ });
+
+ return CalendarController;
+
+});
diff --git a/addons/calendar/static/src/js/calendar_model.js b/addons/calendar/static/src/js/calendar_model.js
new file mode 100644
index 00000000..8d90b543
--- /dev/null
+++ b/addons/calendar/static/src/js/calendar_model.js
@@ -0,0 +1,21 @@
+odoo.define('calendar.CalendarModel', function (require) {
+ "use strict";
+
+ const Model = require('web.CalendarModel');
+
+ const CalendarModel = Model.extend({
+
+ /**
+ * @override
+ * Transform fullcalendar event object to odoo Data object
+ */
+ calendarEventToRecord(event) {
+ const data = this._super(event);
+ return _.extend({}, data, {
+ 'recurrence_update': event.recurrenceUpdate,
+ });
+ }
+ });
+
+ return CalendarModel;
+});
diff --git a/addons/calendar/static/src/js/calendar_renderer.js b/addons/calendar/static/src/js/calendar_renderer.js
new file mode 100644
index 00000000..6aa97a40
--- /dev/null
+++ b/addons/calendar/static/src/js/calendar_renderer.js
@@ -0,0 +1,118 @@
+odoo.define('calendar.CalendarRenderer', function (require) {
+"use strict";
+
+const CalendarRenderer = require('web.CalendarRenderer');
+const CalendarPopover = require('web.CalendarPopover');
+const session = require('web.session');
+
+
+const AttendeeCalendarPopover = CalendarPopover.extend({
+ template: 'Calendar.attendee.status.popover',
+ events: _.extend({}, CalendarPopover.prototype.events, {
+ 'click .o-calendar-attendee-status .dropdown-item': '_onClickAttendeeStatus'
+ }),
+ /**
+ * @constructor
+ */
+ init: function () {
+ var self = this;
+ this._super.apply(this, arguments);
+ // Show status dropdown if user is in attendees list
+ if (this.isCurrentPartnerAttendee()) {
+ this.statusColors = {accepted: 'text-success', declined: 'text-danger', tentative: 'text-muted', needsAction: 'text-dark'};
+ this.statusInfo = {};
+ _.each(this.fields.attendee_status.selection, function (selection) {
+ self.statusInfo[selection[0]] = {text: selection[1], color: self.statusColors[selection[0]]};
+ });
+ this.selectedStatusInfo = this.statusInfo[this.event.extendedProps.record.attendee_status];
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @return {boolean}
+ */
+ isCurrentPartnerAttendee() {
+ return this.event.extendedProps.record.partner_ids.includes(session.partner_id);
+ },
+ /**
+ * @override
+ * @return {boolean}
+ */
+ isEventDeletable() {
+ return this._super() && (this._isEventPrivate() ? this.isCurrentPartnerAttendee() : true);
+ },
+ /**
+ * @override
+ * @return {boolean}
+ */
+ isEventDetailsVisible() {
+ return this._isEventPrivate() ? this.isCurrentPartnerAttendee() : this._super();
+ },
+ /**
+ * @override
+ * @return {boolean}
+ */
+ isEventEditable() {
+ return this._isEventPrivate() ? this.isCurrentPartnerAttendee() : this._super();
+ },
+ /**
+ * @return {boolean}
+ */
+ displayAttendeeAnswerChoice() {
+ // check if we are a partner and if we are the only attendee.
+ // This avoid to display attendee anwser dropdown for single user attendees
+ const isCurrentpartner = (currentValue) => currentValue === session.partner_id;
+ const onlyAttendee = this.event.extendedProps.record.partner_ids.every(isCurrentpartner);
+ return this.isCurrentPartnerAttendee() && ! onlyAttendee;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @return {boolean}
+ */
+ _isEventPrivate() {
+ return this.event.extendedProps.record.privacy === 'private';
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAttendeeStatus: function (ev) {
+ ev.preventDefault();
+ var self = this;
+ var selectedStatus = $(ev.currentTarget).attr('data-action');
+ this._rpc({
+ model: 'calendar.event',
+ method: 'change_attendee_status',
+ args: [parseInt(this.event.id), selectedStatus],
+ }).then(function () {
+ self.event.extendedProps.record.attendee_status = selectedStatus; // FIXEME: Maybe we have to reload view
+ self.$('.o-calendar-attendee-status-text').text(self.statusInfo[selectedStatus].text);
+ self.$('.o-calendar-attendee-status-icon').removeClass(_.values(self.statusColors).join(' ')).addClass(self.statusInfo[selectedStatus].color);
+ });
+ },
+});
+
+
+const AttendeeCalendarRenderer = CalendarRenderer.extend({
+ config: _.extend({}, CalendarRenderer.prototype.config, {
+ CalendarPopover: AttendeeCalendarPopover,
+ }),
+});
+
+return AttendeeCalendarRenderer
+
+});
diff --git a/addons/calendar/static/src/js/calendar_view.js b/addons/calendar/static/src/js/calendar_view.js
new file mode 100644
index 00000000..4454dc0e
--- /dev/null
+++ b/addons/calendar/static/src/js/calendar_view.js
@@ -0,0 +1,22 @@
+odoo.define('calendar.CalendarView', function (require) {
+"use strict";
+
+var CalendarController = require('calendar.CalendarController');
+var CalendarModel = require('calendar.CalendarModel');
+const CalendarRenderer = require('calendar.CalendarRenderer');
+var CalendarView = require('web.CalendarView');
+var viewRegistry = require('web.view_registry');
+
+var AttendeeCalendarView = CalendarView.extend({
+ config: _.extend({}, CalendarView.prototype.config, {
+ Renderer: CalendarRenderer,
+ Controller: CalendarController,
+ Model: CalendarModel,
+ }),
+});
+
+viewRegistry.add('attendee_calendar', AttendeeCalendarView);
+
+return AttendeeCalendarView
+
+});
diff --git a/addons/calendar/static/src/js/mail_activity.js b/addons/calendar/static/src/js/mail_activity.js
new file mode 100644
index 00000000..3c478921
--- /dev/null
+++ b/addons/calendar/static/src/js/mail_activity.js
@@ -0,0 +1,60 @@
+odoo.define('calendar.Activity', function (require) {
+"use strict";
+
+var Activity = require('mail.Activity');
+var Dialog = require('web.Dialog');
+var core = require('web.core');
+var _t = core._t;
+
+Activity.include({
+
+ /**
+ * Override behavior to redirect to calendar event instead of activity
+ *
+ * @override
+ */
+ _onEditActivity: function (event, options) {
+ var self = this;
+ var activity_id = $(event.currentTarget).data('activity-id');
+ var activity = _.find(this.activities, function (act) { return act.id === activity_id; });
+ if (activity && activity.activity_category === 'meeting' && activity.calendar_event_id) {
+ return self._super(event, _.extend({
+ res_model: 'calendar.event',
+ res_id: activity.calendar_event_id[0],
+ }));
+ }
+ return self._super(event, options);
+ },
+
+ /**
+ * Override behavior to warn that the calendar event is about to be removed as well
+ *
+ * @override
+ */
+ _onUnlinkActivity: function (event, options) {
+ event.preventDefault();
+ var self = this;
+ var activity_id = $(event.currentTarget).data('activity-id');
+ var activity = _.find(this.activities, function (act) { return act.id === activity_id; });
+ if (activity && activity.activity_category === 'meeting' && activity.calendar_event_id) {
+ Dialog.confirm(
+ self,
+ _t("The activity is linked to a meeting. Deleting it will remove the meeting as well. Do you want to proceed ?"), {
+ confirm_callback: function () {
+ return self._rpc({
+ model: 'mail.activity',
+ method: 'unlink_w_meeting',
+ args: [[activity_id]],
+ })
+ .then(self._reload.bind(self, {activity: true}));
+ },
+ }
+ );
+ }
+ else {
+ return self._super(event, options);
+ }
+ },
+});
+
+});
diff --git a/addons/calendar/static/src/js/systray_activity_menu.js b/addons/calendar/static/src/js/systray_activity_menu.js
new file mode 100644
index 00000000..d6f98b69
--- /dev/null
+++ b/addons/calendar/static/src/js/systray_activity_menu.js
@@ -0,0 +1,56 @@
+odoo.define('calendar.systray.ActivityMenu', function (require) {
+"use strict";
+
+var ActivityMenu = require('mail.systray.ActivityMenu');
+var fieldUtils = require('web.field_utils');
+
+ActivityMenu.include({
+
+ //-----------------------------------------
+ // Private
+ //-----------------------------------------
+
+ /**
+ * parse date to server value
+ *
+ * @private
+ * @override
+ */
+ _getActivityData: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ var meeting = _.find(self._activities, {type: 'meeting'});
+ if (meeting && meeting.meetings) {
+ _.each(meeting.meetings, function (res) {
+ res.start = fieldUtils.parse.datetime(res.start, false, {isUTC: true});
+ });
+ }
+ });
+ },
+
+ //-----------------------------------------
+ // Handlers
+ //-----------------------------------------
+
+ /**
+ * @private
+ * @override
+ */
+ _onActivityFilterClick: function (ev) {
+ var $el = $(ev.currentTarget);
+ var data = _.extend({}, $el.data());
+ if (data.res_model === "calendar.event" && data.filter === "my") {
+ this.do_action('calendar.action_calendar_event', {
+ additional_context: {
+ default_mode: 'day',
+ search_default_mymeetings: 1,
+ },
+ clear_breadcrumbs: true,
+ });
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+});
+
+});
diff --git a/addons/calendar/static/src/scss/calendar.scss b/addons/calendar/static/src/scss/calendar.scss
new file mode 100644
index 00000000..7e98c95d
--- /dev/null
+++ b/addons/calendar/static/src/scss/calendar.scss
@@ -0,0 +1,69 @@
+.o_calendar_invitation {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+
+ &.accepted {
+ background-color: theme-color('success');
+ }
+
+ &.needsAction {
+ background-color: $o-brand-secondary;
+ }
+
+ &.declined {
+ background-color: theme-color('danger');
+ }
+}
+
+.o_add_favorite_calendar {
+ margin-top: 10px;
+ position: relative;
+}
+
+.o_calendar_invitation_page {
+ flex: 0 0 auto;
+ width: 50%;
+ margin: 30px auto 0;
+ @include o-webclient-padding($top: 10px, $bottom: 10px);
+ background-color: $o-view-background-color;
+
+ .o_logo {
+ width: 15%;
+ }
+ .o_event_title {
+ margin-left: 20%;
+
+ h2 {
+ margin-top: 0;
+ }
+ }
+ .o_event_table {
+ clear: both;
+ margin: 15px 0 0;
+
+ th {
+ padding-right: 15px;
+ }
+ ul {
+ padding-left: 0;
+ }
+ }
+
+ .o_accepted {
+ color: theme-color('success');
+ }
+ .o_declined {
+ color: theme-color('danger');
+ }
+}
+.o_meeting_filter {
+ @include o-text-overflow();
+ width: 64%;
+ color: grey;
+ vertical-align: top;
+ &.o_meeting_bold {
+ font-weight: bold;
+ }
+}
diff --git a/addons/calendar/static/src/xml/base_calendar.xml b/addons/calendar/static/src/xml/base_calendar.xml
new file mode 100644
index 00000000..b136ee28
--- /dev/null
+++ b/addons/calendar/static/src/xml/base_calendar.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<template>
+ <t t-name="Many2ManyAttendeeTag" t-extend="FieldMany2ManyTag">
+ <t t-jquery="span:first" t-operation="prepend">
+ <span t-if="attendeesData[el_index]" t-attf-class="o_calendar_invitation #{attendeesData[el_index].status}"/>
+ </t>
+ </t>
+
+ <div t-name="calendar.RecurrentEventUpdate">
+ <div class="form-check o_radio_item">
+ <input name="recurrence-update" type="radio" class="form-check-input o_radio_input" checked="true" value="self_only" id="self_only"/>
+ <label class="form-check-label o_form_label" for="self_only">This event</label>
+ </div>
+
+ <div class="form-check o_radio_item">
+ <input name="recurrence-update" type="radio" class="form-check-input o_radio_input" value="future_events" id="future_events"/>
+ <label class="form-check-label o_form_label" for="future_events">This and following events</label>
+ </div>
+ </div>
+
+ <t t-extend="mail.systray.ActivityMenu.Previews">
+ <t t-jquery="div.o_preview_title" t-operation="after">
+ <div t-if="activity and activity.type == 'meeting'">
+ <t t-set="is_next_meeting" t-value="true"/>
+ <t t-foreach="activity.meetings" t-as="meeting">
+ <div>
+ <span t-att-class="!meeting.allday and is_next_meeting ? 'o_meeting_filter o_meeting_bold' : 'o_meeting_filter'" t-att-data-res_model="activity.model" t-att-data-res_id="meeting.id" t-att-data-model_name="activity.name" t-att-title="meeting.name">
+ <span><t t-esc="meeting.name"/></span>
+ </span>
+ <span t-if="meeting.start" class="float-right">
+ <t t-if="meeting.allday">All Day</t>
+ <t t-else=''>
+ <t t-set="is_next_meeting" t-value="false"/>
+ <t t-esc="moment(meeting.start).local().format(Time.strftime_to_moment_format(_t.database.parameters.time_format))"/>
+ </t>
+ </span>
+ </div>
+ </t>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="Calendar.attendee.status.popover" t-extend="CalendarView.event.popover">
+ <t t-jquery=".o_cw_popover_delete" t-operation="after">
+ <div t-if="widget.displayAttendeeAnswerChoice()" class="btn-group o-calendar-attendee-status ml-2">
+ <a href="#" class="btn btn-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ <i t-attf-class="fa fa-circle o-calendar-attendee-status-icon #{widget.selectedStatusInfo.color}"/> <span class="o-calendar-attendee-status-text" t-esc="widget.selectedStatusInfo.text"></span>
+ </a>
+ <div class="dropdown-menu overflow-hidden">
+ <a class="dropdown-item" href="#" data-action="accepted"><i class="fa fa-circle text-success"/> Accept</a>
+ <a class="dropdown-item" href="#" data-action="declined"><i class="fa fa-circle text-danger"/> Decline</a>
+ <a class="dropdown-item" href="#" data-action="tentative"><i class="fa fa-circle text-muted"/> Uncertain</a>
+ </div>
+ </div>
+ </t>
+ </t>
+
+</template>
diff --git a/addons/calendar/static/src/xml/notification_calendar.xml b/addons/calendar/static/src/xml/notification_calendar.xml
new file mode 100644
index 00000000..3c51293f
--- /dev/null
+++ b/addons/calendar/static/src/xml/notification_calendar.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<template>
+
+<t t-name="CalendarNotification" t-extend="Notification">
+ <t t-jquery=".o_notification_title > t" t-operation="replace">
+ <span t-attf-class="link2event eid_{{widget.eid}}">
+ <t t-esc="widget.title"/>
+ </span>
+ </t>
+ <t t-jquery=".o_notification_content" t-operation="after">
+ <div class="mt-2">
+ <button type="button" class="btn btn-primary link2showed oe_highlight oe_form oe_button"><span>OK</span></button>
+ <button type="button" class="btn btn-link link2event">Details</button>
+ <button type="button" class="btn btn-link link2recall">Snooze</button>
+ </div>
+ </t>
+</t>
+
+</template>