summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/calendar/calendar_renderer.js
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/web/static/src/js/views/calendar/calendar_renderer.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/calendar/calendar_renderer.js')
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js1006
1 files changed, 1006 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js
new file mode 100644
index 00000000..4ab750f6
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -0,0 +1,1006 @@
+odoo.define('web.CalendarRenderer', function (require) {
+"use strict";
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var CalendarPopover = require('web.CalendarPopover');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var field_utils = require('web.field_utils');
+var FieldManagerMixin = require('web.FieldManagerMixin');
+var relational_fields = require('web.relational_fields');
+var session = require('web.session');
+var Widget = require('web.Widget');
+const { createYearCalendarView } = require('/web/static/src/js/libs/fullcalendar.js');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var SidebarFilterM2O = relational_fields.FieldMany2One.extend({
+ _getSearchBlacklist: function () {
+ return this._super.apply(this, arguments).concat(this.filter_ids || []);
+ },
+});
+
+var SidebarFilter = Widget.extend(FieldManagerMixin, {
+ template: 'CalendarView.sidebar.filter',
+ custom_events: _.extend({}, FieldManagerMixin.custom_events, {
+ field_changed: '_onFieldChanged',
+ }),
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {string} options.fieldName
+ * @param {Object[]} options.filters A filter is an object with the
+ * following keys: id, value, label, active, avatar_model, color,
+ * can_be_removed
+ * @param {Object} [options.favorite] this is an object with the following
+ * keys: fieldName, model, fieldModel
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ FieldManagerMixin.init.call(this);
+
+ this.title = options.title;
+ this.fields = options.fields;
+ this.fieldName = options.fieldName;
+ this.write_model = options.write_model;
+ this.write_field = options.write_field;
+ this.avatar_field = options.avatar_field;
+ this.avatar_model = options.avatar_model;
+ this.filters = options.filters;
+ this.label = options.label;
+ this.getColor = options.getColor;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+
+ if (this.write_model || this.write_field) {
+ var def = this.model.makeRecord(this.write_model, [{
+ name: this.write_field,
+ relation: this.fields[this.fieldName].relation,
+ type: 'many2one',
+ }]).then(function (recordID) {
+ self.many2one = new SidebarFilterM2O(self,
+ self.write_field,
+ self.model.get(recordID),
+ {
+ mode: 'edit',
+ attrs: {
+ string: _t(self.fields[self.fieldName].string),
+ placeholder: "+ " + _.str.sprintf(_t("Add %s"), self.title),
+ can_create: false
+ },
+ });
+ });
+ defs.push(def);
+ }
+ return Promise.all(defs);
+
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._super();
+ if (this.many2one) {
+ this.many2one.appendTo(this.$el);
+ this.many2one.filter_ids = _.without(_.pluck(this.filters, 'value'), 'all');
+ }
+ this.$el.on('click', '.o_remove', this._onFilterRemove.bind(this));
+ this.$el.on('click', '.o_calendar_filter_items input', this._onFilterActive.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ var self = this;
+ event.stopPropagation();
+ var createValues = {'user_id': session.uid};
+ var value = event.data.changes[this.write_field].id;
+ createValues[this.write_field] = value;
+ this._rpc({
+ model: this.write_model,
+ method: 'create',
+ args: [createValues],
+ })
+ .then(function () {
+ self.trigger_up('changeFilter', {
+ 'fieldName': self.fieldName,
+ 'value': value,
+ 'active': true,
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onFilterActive: function (e) {
+ var $input = $(e.currentTarget);
+ this.trigger_up('changeFilter', {
+ 'fieldName': this.fieldName,
+ 'value': $input.closest('.o_calendar_filter_item').data('value'),
+ 'active': $input.prop('checked'),
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onFilterRemove: function (e) {
+ var self = this;
+ var $filter = $(e.currentTarget).closest('.o_calendar_filter_item');
+ Dialog.confirm(this, _t("Do you really want to delete this filter from favorites ?"), {
+ confirm_callback: function () {
+ self._rpc({
+ model: self.write_model,
+ method: 'unlink',
+ args: [[$filter.data('id')]],
+ })
+ .then(function () {
+ self.trigger_up('changeFilter', {
+ 'fieldName': self.fieldName,
+ 'id': $filter.data('id'),
+ 'active': false,
+ 'value': $filter.data('value'),
+ });
+ });
+ },
+ });
+ },
+});
+
+return AbstractRenderer.extend({
+ template: "CalendarView",
+ config: {
+ CalendarPopover: CalendarPopover,
+ },
+ custom_events: _.extend({}, AbstractRenderer.prototype.custom_events || {}, {
+ edit_event: '_onEditEvent',
+ delete_event: '_onDeleteEvent',
+ }),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} state
+ * @param {Object} params
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.displayFields = params.displayFields;
+ this.model = params.model;
+ this.filters = [];
+ this.color_map = {};
+ this.hideDate = params.hideDate;
+ this.hideTime = params.hideTime;
+ this.canDelete = params.canDelete;
+ this.canCreate = params.canCreate;
+ this.scalesInfo = params.scalesInfo;
+ this._isInDOM = false;
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ start: function () {
+ this._initSidebar();
+ this._initCalendar();
+ return this._super();
+ },
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ this._super(...arguments);
+ this._isInDOM = true;
+ // BUG Test ????
+ // this.$el.height($(window).height() - this.$el.offset().top);
+ this.calendar.render();
+ this._renderCalendar();
+ window.addEventListener('click', this._onWindowClick.bind(this));
+ },
+ /**
+ * Called when the field is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ this._super(...arguments);
+ this._isInDOM = false;
+ window.removeEventListener('click', this._onWindowClick);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.calendar) {
+ this.calendar.destroy();
+ }
+ if (this.$small_calendar) {
+ this.$small_calendar.datepicker('destroy');
+ $('#ui-datepicker-div:empty').remove();
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Note: this is not dead code, it is called by the calendar-box template
+ *
+ * @param {any} record
+ * @param {any} fieldName
+ * @param {any} imageField
+ * @returns {string[]}
+ */
+ getAvatars: function (record, fieldName, imageField) {
+ var field = this.state.fields[fieldName];
+
+ if (!record[fieldName]) {
+ return [];
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ return _.map(record[fieldName], function (id) {
+ return '<img src="/web/image/'+field.relation+'/'+id+'/'+imageField+'" />';
+ });
+ } else if (field.type === 'many2one') {
+ return ['<img src="/web/image/'+field.relation+'/'+record[fieldName][0]+'/'+imageField+'" />'];
+ } else {
+ var value = this._format(record, fieldName);
+ var color = this.getColor(value);
+ if (isNaN(color)) {
+ return ['<span class="o_avatar_square" style="background-color:'+color+';"/>'];
+ }
+ else {
+ return ['<span class="o_avatar_square o_calendar_color_'+color+'"/>'];
+ }
+ }
+ },
+ /**
+ * Note: this is not dead code, it is called by two template
+ *
+ * @param {any} key
+ * @returns {integer}
+ */
+ getColor: function (key) {
+ if (!key) {
+ return;
+ }
+ if (this.color_map[key]) {
+ return this.color_map[key];
+ }
+ // check if the key is a css color
+ if (typeof key === 'string' && key.match(/^((#[A-F0-9]{3})|(#[A-F0-9]{6})|((hsl|rgb)a?\(\s*(?:(\s*\d{1,3}%?\s*),?){3}(\s*,[0-9.]{1,4})?\))|)$/i)) {
+ return this.color_map[key] = key;
+ }
+ if (typeof key === 'number' && !(key in this.color_map)) {
+ return this.color_map[key] = key;
+ }
+ var index = (((_.keys(this.color_map).length + 1) * 5) % 24) + 1;
+ this.color_map[key] = index;
+ return index;
+ },
+ /**
+ * @override
+ */
+ getLocalState: function () {
+ var fcScroller = this.calendarElement.querySelector('.fc-scroller');
+ return {
+ scrollPosition: fcScroller.scrollTop,
+ };
+ },
+ /**
+ * @override
+ */
+ setLocalState: function (localState) {
+ if (localState.scrollPosition) {
+ var fcScroller = this.calendarElement.querySelector('.fc-scroller');
+ fcScroller.scrollTop = localState.scrollPosition;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Convert the new format of Event from FullCalendar V4 to a Event FullCalendar V3
+ * @param fc4Event
+ * @return {Object} FullCalendar V3 Object Event
+ * @private
+ */
+ _convertEventToFC3Event: function (fc4Event) {
+ var event = fc4Event;
+ if (!moment.isMoment(fc4Event.start)) {
+ event = {
+ id: fc4Event.id,
+ title: fc4Event.title,
+ start: moment(fc4Event.start).utcOffset(0, true),
+ end: fc4Event.end && moment(fc4Event.end).utcOffset(0, true),
+ allDay: fc4Event.allDay,
+ color: fc4Event.color,
+ };
+ if (fc4Event.extendedProps) {
+ event = Object.assign({}, event, {
+ r_start: fc4Event.extendedProps.r_start && moment(fc4Event.extendedProps.r_start).utcOffset(0, true),
+ r_end: fc4Event.extendedProps.r_end && moment(fc4Event.extendedProps.r_end).utcOffset(0, true),
+ record: fc4Event.extendedProps.record,
+ attendees: fc4Event.extendedProps.attendees,
+ });
+ }
+ }
+ return event;
+ },
+ /**
+ * @param {any} event
+ * @returns {string} the html for the rendered event
+ */
+ _eventRender: function (event) {
+ var qweb_context = {
+ event: event,
+ record: event.extendedProps.record,
+ color: this.getColor(event.extendedProps.color_index),
+ showTime: !self.hideTime && event.extendedProps.showTime,
+ };
+ this.qweb_context = qweb_context;
+ if (_.isEmpty(qweb_context.record)) {
+ return '';
+ } else {
+ return qweb.render("calendar-box", qweb_context);
+ }
+ },
+ /**
+ * @private
+ * @param {any} record
+ * @param {any} fieldName
+ * @returns {string}
+ */
+ _format: function (record, fieldName) {
+ var field = this.state.fields[fieldName];
+ if (field.type === "one2many" || field.type === "many2many") {
+ return field_utils.format[field.type]({data: record[fieldName]}, field);
+ } else {
+ return field_utils.format[field.type](record[fieldName], field, {forceString: true});
+ }
+ },
+ /**
+ * Return the Object options for FullCalendar
+ *
+ * @private
+ * @param {Object} fcOptions
+ * @return {Object}
+ */
+ _getFullCalendarOptions: function (fcOptions) {
+ var self = this;
+ const options = Object.assign({}, this.state.fc_options, {
+ plugins: [
+ 'moment',
+ 'interaction',
+ 'dayGrid',
+ 'timeGrid'
+ ],
+ eventDrop: function (eventDropInfo) {
+ var event = self._convertEventToFC3Event(eventDropInfo.event);
+ self.trigger_up('dropRecord', event);
+ },
+ eventResize: function (eventResizeInfo) {
+ self._unselectEvent();
+ var event = self._convertEventToFC3Event(eventResizeInfo.event);
+ self.trigger_up('updateRecord', event);
+ },
+ eventClick: function (eventClickInfo) {
+ eventClickInfo.jsEvent.preventDefault();
+ eventClickInfo.jsEvent.stopPropagation();
+ var eventData = eventClickInfo.event;
+ self._unselectEvent();
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', eventData.id)).addClass('o_cw_custom_highlight');
+ self._renderEventPopover(eventData, $(eventClickInfo.el));
+ },
+ yearDateClick: function (info) {
+ self._unselectEvent();
+ info.view.unselect();
+ if (!info.events.length) {
+ if (info.selectable) {
+ const data = {
+ start: info.date,
+ allDay: true,
+ };
+ if (self.state.context.default_name) {
+ data.title = self.state.context.default_name;
+ }
+ self.trigger_up('openCreate', self._convertEventToFC3Event(data));
+ }
+ } else {
+ self._renderYearEventPopover(info.date, info.events, $(info.dayEl));
+ }
+ },
+ select: function (selectionInfo) {
+ // Clicking on the view, dispose any visible popover. Otherwise create a new event.
+ if (self.$('.o_cw_popover').length) {
+ self._unselectEvent();
+ }
+ var data = {start: selectionInfo.start, end: selectionInfo.end, allDay: selectionInfo.allDay};
+ if (self.state.context.default_name) {
+ data.title = self.state.context.default_name;
+ }
+ self.trigger_up('openCreate', self._convertEventToFC3Event(data));
+ if (self.state.scale === 'year') {
+ self.calendar.view.unselect();
+ } else {
+ self.calendar.unselect();
+ }
+ },
+ eventRender: function (info) {
+ var event = info.event;
+ var element = $(info.el);
+ var view = info.view;
+ element.attr('data-event-id', event.id);
+ if (view.type === 'dayGridYear') {
+ const color = this.getColor(event.extendedProps.color_index);
+ if (typeof color === 'string') {
+ element.css({
+ backgroundColor: color,
+ });
+ } else if (typeof color === 'number') {
+ element.addClass(`o_calendar_color_${color}`);
+ } else {
+ element.addClass('o_calendar_color_1');
+ }
+ } else {
+ var $render = $(self._eventRender(event));
+ element.find('.fc-content').html($render.html());
+ element.addClass($render.attr('class'));
+
+ // Add background if doesn't exist
+ if (!element.find('.fc-bg').length) {
+ element.find('.fc-content').after($('<div/>', {class: 'fc-bg'}));
+ }
+
+ if (view.type === 'dayGridMonth' && event.extendedProps.record) {
+ var start = event.extendedProps.r_start || event.start;
+ var end = event.extendedProps.r_end || event.end;
+ // Detect if the event occurs in just one day
+ // note: add & remove 1 min to avoid issues with 00:00
+ var isSameDayEvent = moment(start).clone().add(1, 'minute').isSame(moment(end).clone().subtract(1, 'minute'), 'day');
+ if (!event.extendedProps.record.allday && isSameDayEvent) {
+ // For month view: do not show background for non allday, single day events
+ element.addClass('o_cw_nobg');
+ if (event.extendedProps.showTime && !self.hideTime) {
+ const displayTime = moment(start).clone().format(self._getDbTimeFormat());
+ element.find('.fc-content .fc-time').text(displayTime);
+ }
+ }
+ }
+
+ // On double click, edit the event
+ element.on('dblclick', function () {
+ self.trigger_up('edit_event', {id: event.id});
+ });
+ }
+ },
+ datesRender: function (info) {
+ const viewToMode = Object.fromEntries(
+ Object.entries(self.scalesInfo).map(([k, v]) => [v, k])
+ );
+ self.trigger_up('viewUpdated', {
+ mode: viewToMode[info.view.type],
+ title: info.view.title,
+ });
+ },
+ // Add/Remove a class on hover to style multiple days events.
+ // The css ":hover" selector can't be used because these events
+ // are rendered using multiple elements.
+ eventMouseEnter: function (mouseEnterInfo) {
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseEnterInfo.event.id)).addClass('o_cw_custom_hover');
+ },
+ eventMouseLeave: function (mouseLeaveInfo) {
+ if (!mouseLeaveInfo.event.id) {
+ return;
+ }
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseLeaveInfo.event.id)).removeClass('o_cw_custom_hover');
+ },
+ eventDragStart: function (mouseDragInfo) {
+ mouseDragInfo.el.classList.add(mouseDragInfo.view.type);
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseDragInfo.event.id)).addClass('o_cw_custom_hover');
+ self._unselectEvent();
+ },
+ eventResizeStart: function (mouseResizeInfo) {
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseResizeInfo.event.id)).addClass('o_cw_custom_hover');
+ self._unselectEvent();
+ },
+ eventLimitClick: function () {
+ self._unselectEvent();
+ return 'popover';
+ },
+ windowResize: function () {
+ self._render();
+ },
+ views: {
+ timeGridDay: {
+ columnHeaderFormat: 'LL'
+ },
+ timeGridWeek: {
+ columnHeaderFormat: 'ddd D'
+ },
+ dayGridMonth: {
+ columnHeaderFormat: 'dddd'
+ }
+ },
+ height: 'parent',
+ unselectAuto: false,
+ dir: _t.database.parameters.direction,
+ events: (info, successCB) => {
+ successCB(self.state.data);
+ },
+ }, fcOptions);
+ options.plugins.push(createYearCalendarView(FullCalendar, options));
+ return options;
+ },
+ /**
+ * Initialize the main calendar
+ *
+ * @private
+ */
+ _initCalendar: function () {
+ this.calendarElement = this.$(".o_calendar_widget")[0];
+ var locale = moment.locale();
+
+ var fcOptions = this._getFullCalendarOptions({
+ locale: locale, // reset locale when fullcalendar has already been instanciated before now
+ });
+
+ this.calendar = new FullCalendar.Calendar(this.calendarElement, fcOptions);
+ },
+ /**
+ * Initialize the mini calendar in the sidebar
+ *
+ * @private
+ */
+ _initCalendarMini: function () {
+ var self = this;
+ this.$small_calendar = this.$(".o_calendar_mini");
+ this.$small_calendar.datepicker({
+ 'onSelect': function (datum, obj) {
+ self.trigger_up('changeDate', {
+ date: moment(new Date(+obj.currentYear , +obj.currentMonth, +obj.currentDay))
+ });
+ },
+ 'showOtherMonths': true,
+ 'dayNamesMin' : this.state.fc_options.dayNamesShort.map(x => x[0]),
+ 'monthNames': this.state.fc_options.monthNamesShort,
+ 'firstDay': this.state.fc_options.firstDay,
+ });
+ },
+ /**
+ * Initialize the sidebar
+ *
+ * @private
+ */
+ _initSidebar: function () {
+ this.$sidebar = this.$('.o_calendar_sidebar');
+ this.$sidebar_container = this.$(".o_calendar_sidebar_container");
+ this._initCalendarMini();
+ },
+ /**
+ * Finalise the popover
+ *
+ * @param {jQueryElement} $popoverElement
+ * @param {web.CalendarPopover} calendarPopover
+ * @private
+ */
+ _onPopoverShown: function ($popoverElement, calendarPopover) {
+ var $popover = $($popoverElement.data('bs.popover').tip);
+ $popover.find('.o_cw_popover_close').on('click', this._unselectEvent.bind(this));
+ $popover.find('.o_cw_body').replaceWith(calendarPopover.$el);
+ },
+ /**
+ * Render the calendar view, this is the main entry point.
+ *
+ * @override
+ */
+ async _renderView() {
+ this.$('.o_calendar_view')[0].prepend(this.calendarElement);
+ if (this._isInDOM) {
+ this._renderCalendar();
+ }
+ this.$small_calendar.datepicker("setDate", this.state.highlight_date.toDate())
+ .find('.o_selected_range')
+ .removeClass('o_color o_selected_range');
+ var $a;
+ switch (this.state.scale) {
+ case 'year': $a = this.$small_calendar.find('td'); break;
+ case 'month': $a = this.$small_calendar.find('td'); break;
+ case 'week': $a = this.$small_calendar.find('tr:has(.ui-state-active)'); break;
+ case 'day': $a = this.$small_calendar.find('a.ui-state-active'); break;
+ }
+ $a.addClass('o_selected_range');
+ setTimeout(function () {
+ $a.not('.ui-state-active').addClass('o_color');
+ });
+
+ await this._renderFilters();
+ },
+ /**
+ * Render the specific code for the FullCalendar when it's in the DOM
+ *
+ * @private
+ */
+ _renderCalendar() {
+ this.calendar.unselect();
+
+ if (this.scalesInfo[this.state.scale] !== this.calendar.view.type) {
+ this.calendar.changeView(this.scalesInfo[this.state.scale]);
+ }
+
+ if (this.target_date !== this.state.target_date.toString()) {
+ this.calendar.gotoDate(moment(this.state.target_date).toDate());
+ this.target_date = this.state.target_date.toString();
+ } else {
+ // this.calendar.gotoDate already renders events when called
+ // so render events only when domain changes
+ this._renderEvents();
+ }
+
+ this._unselectEvent();
+ // this._scrollToScrollTime();
+ },
+ /**
+ * Render all events
+ *
+ * @private
+ */
+ _renderEvents: function () {
+ this.calendar.refetchEvents();
+ },
+ /**
+ * Render all filters
+ *
+ * @private
+ * @returns {Promise} resolved when all filters have been rendered
+ */
+ _renderFilters: function () {
+ // Dispose of filter popover
+ this.$('.o_calendar_filter_item').popover('dispose');
+ _.each(this.filters || (this.filters = []), function (filter) {
+ filter.destroy();
+ });
+ if (this.state.fullWidth) {
+ return Promise.resolve();
+ }
+ return this._renderFiltersOneByOne();
+ },
+ /**
+ * Renders each filter one by one, waiting for the first filter finished to
+ * be rendered and appended to render the next one.
+ * We need to do like this since render a filter is asynchronous, we don't
+ * know which one will be appened at first and we want tp force them to be
+ * rendered in order.
+ *
+ * @param {number} filterIndex if not set, 0 by default
+ * @returns {Promise} resolved when all filters have been rendered
+ */
+ _renderFiltersOneByOne: function (filterIndex) {
+ filterIndex = filterIndex || 0;
+ var arrFilters = _.toArray(this.state.filters);
+ var prom;
+ if (filterIndex < arrFilters.length) {
+ var options = arrFilters[filterIndex];
+ if (!_.find(options.filters, function (f) {return f.display == null || f.display;})) {
+ return this._renderFiltersOneByOne(filterIndex + 1);
+ }
+
+ var self = this;
+ options.getColor = this.getColor.bind(this);
+ options.fields = this.state.fields;
+ var sidebarFilter = new SidebarFilter(self, options);
+ prom = sidebarFilter.appendTo(this.$sidebar).then(function () {
+ // Show filter popover
+ if (options.avatar_field) {
+ _.each(options.filters, function (filter) {
+ if (!['all', false].includes(filter.value)) {
+ var selector = _.str.sprintf('.o_calendar_filter_item[data-value=%s]', filter.value);
+ sidebarFilter.$el.find(selector).popover({
+ animation: false,
+ trigger: 'hover',
+ html: true,
+ placement: 'top',
+ title: filter.label,
+ delay: {show: 300, hide: 0},
+ content: function () {
+ return $('<img>', {
+ src: _.str.sprintf('/web/image/%s/%s/%s', options.avatar_model, filter.value, options.avatar_field),
+ class: 'mx-auto',
+ });
+ },
+ });
+ }
+ });
+ }
+ return self._renderFiltersOneByOne(filterIndex + 1);
+ });
+ this.filters.push(sidebarFilter);
+ }
+ return Promise.resolve(prom);
+ },
+ /**
+ * Returns the time format from database parameters (only hours and minutes).
+ * FIXME: this looks like a weak heuristic...
+ *
+ * @private
+ * @returns {string}
+ */
+ _getDbTimeFormat: function () {
+ return _t.database.parameters.time_format.search('%H') !== -1 ? 'HH:mm' : 'hh:mm a';
+ },
+ /**
+ * Returns event's formatted date for popovers.
+ *
+ * @private
+ * @param {moment} start
+ * @param {moment} end
+ * @param {boolean} showDayName
+ * @param {boolean} allDay
+ */
+ _getFormattedDate: function (start, end, showDayName, allDay) {
+ const isSameDayEvent = start.clone().add(1, 'minute')
+ .isSame(end.clone().subtract(1, 'minute'), 'day');
+ if (allDay) {
+ // cancel correction done in _recordToCalendarEvent
+ end = end.clone().subtract(1, 'day');
+ }
+ if (!isSameDayEvent && start.isSame(end, 'month')) {
+ // Simplify date-range if an event occurs into the same month (eg. '4-5 August 2019')
+ return start.clone().format('MMMM D') + '-' + end.clone().format('D, YYYY');
+ } else {
+ return isSameDayEvent ?
+ start.clone().format(showDayName ? 'dddd, LL' : 'LL') :
+ start.clone().format('LL') + ' - ' + end.clone().format('LL');
+ }
+ },
+ /**
+ * Prepare context to display in the popover.
+ *
+ * @private
+ * @param {Object} eventData
+ * @returns {Object} context
+ */
+ _getPopoverContext: function (eventData) {
+ var context = {
+ hideDate: this.hideDate,
+ hideTime: this.hideTime,
+ eventTime: {},
+ eventDate: {},
+ fields: this.state.fields,
+ displayFields: this.displayFields,
+ event: eventData,
+ modelName: this.model,
+ canDelete: this.canDelete,
+ };
+
+ var start = moment((eventData.extendedProps && eventData.extendedProps.r_start) || eventData.start);
+ var end = moment((eventData.extendedProps && eventData.extendedProps.r_end) || eventData.end);
+ var isSameDayEvent = start.clone().add(1, 'minute').isSame(end.clone().subtract(1, 'minute'), 'day');
+
+ // Do not display timing if the event occur across multiple days. Otherwise use user's timing preferences
+ if (!this.hideTime && !eventData.extendedProps.record.allday && isSameDayEvent) {
+ var dbTimeFormat = this._getDbTimeFormat();
+
+ context.eventTime.time = start.clone().format(dbTimeFormat) + ' - ' + end.clone().format(dbTimeFormat);
+
+ // Calculate duration and format text
+ var durationHours = moment.duration(end.diff(start)).hours();
+ var durationHoursKey = (durationHours === 1) ? 'h' : 'hh';
+ var durationMinutes = moment.duration(end.diff(start)).minutes();
+ var durationMinutesKey = (durationMinutes === 1) ? 'm' : 'mm';
+
+ var localeData = moment.localeData(); // i18n for 'hours' and "minutes" strings
+ context.eventTime.duration = (durationHours > 0 ? localeData.relativeTime(durationHours, true, durationHoursKey) : '')
+ + (durationHours > 0 && durationMinutes > 0 ? ', ' : '')
+ + (durationMinutes > 0 ? localeData.relativeTime(durationMinutes, true, durationMinutesKey) : '');
+ }
+
+ if (!this.hideDate) {
+
+ if (eventData.extendedProps.record.allday && isSameDayEvent) {
+ context.eventDate.duration = _t("All day");
+ } else if (eventData.extendedProps.record.allday && !isSameDayEvent) {
+ var daysLocaleData = moment.localeData();
+ var days = moment.duration(end.diff(start)).days();
+ context.eventDate.duration = daysLocaleData.relativeTime(days, true, 'dd');
+ }
+
+ context.eventDate.date = this._getFormattedDate(start, end, true, eventData.extendedProps.record.allday);
+ }
+
+ return context;
+ },
+ /**
+ * Prepare the parameters for the popover.
+ * This allow the parameters to be extensible.
+ *
+ * @private
+ * @param {Object} eventData
+ */
+ _getPopoverParams: function (eventData) {
+ return {
+ animation: false,
+ delay: {
+ show: 50,
+ hide: 100
+ },
+ trigger: 'manual',
+ html: true,
+ title: eventData.extendedProps.record.display_name,
+ template: qweb.render('CalendarView.event.popover.placeholder', {color: this.getColor(eventData.extendedProps.color_index)}),
+ container: eventData.allDay ? '.fc-view' : '.fc-scroller',
+ }
+ },
+ /**
+ * Render event popover
+ *
+ * @private
+ * @param {Object} eventData
+ * @param {jQueryElement} $eventElement
+ */
+ _renderEventPopover: function (eventData, $eventElement) {
+ var self = this;
+
+ // Initialize popover widget
+ var calendarPopover = new self.config.CalendarPopover(self, self._getPopoverContext(eventData));
+ calendarPopover.appendTo($('<div>')).then(() => {
+ $eventElement.popover(
+ self._getPopoverParams(eventData)
+ ).on('shown.bs.popover', function () {
+ self._onPopoverShown($(this), calendarPopover);
+ }).popover('show');
+ });
+ },
+ /**
+ * Render year event popover
+ *
+ * @private
+ * @param {Date} date
+ * @param {Object[]} events
+ * @param {jQueryElement} $el
+ */
+ _renderYearEventPopover: function (date, events, $el) {
+ const groupKeys = [];
+ const groupedEvents = {};
+ for (const event of events) {
+ const start = moment(event.extendedProps.r_start);
+ const end = moment(event.extendedProps.r_end);
+ const key = this._getFormattedDate(start, end, false, event.extendedProps.record.allday);
+ if (!(key in groupedEvents)) {
+ groupedEvents[key] = [];
+ groupKeys.push({
+ key: key,
+ start: event.extendedProps.r_start,
+ end: event.extendedProps.r_end,
+ isSameDayEvent: start.clone().add(1, 'minute')
+ .isSame(end.clone().subtract(1, 'minute'), 'day'),
+ });
+ }
+ groupedEvents[key].push(event);
+ }
+
+ const popoverContent = qweb.render('CalendarView.yearEvent.popover', {
+ groupedEvents,
+ groupKeys: groupKeys
+ .sort((a, b) => {
+ if (a.isSameDayEvent) {
+ // if isSameDayEvent then put it before the others
+ return Number.MIN_SAFE_INTEGER;
+ } else if (b.isSameDayEvent) {
+ return Number.MAX_SAFE_INTEGER;
+ } else if (a.start.getTime() - b.start.getTime() === 0) {
+ return a.end.getTime() - b.end.getTime();
+ }
+ return a.start.getTime() - b.start.getTime();
+ })
+ .map(x => x.key),
+ canCreate: this.canCreate,
+ });
+
+ $el.popover({
+ animation: false,
+ delay: {
+ show: 50,
+ hide: 100
+ },
+ trigger: 'manual',
+ html: true,
+ content: popoverContent,
+ template: qweb.render('CalendarView.yearEvent.popover.placeholder'),
+ container: '.fc-dayGridYear-view',
+ }).on('shown.bs.popover', () => {
+ $('.o_cw_popover .o_cw_popover_close').on('click', () => this._unselectEvent());
+ $('.o_cw_popover .o_cw_popover_create').on('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._unselectEvent();
+ const data = {
+ start: date,
+ allDay: true,
+ };
+ if (this.state.context.default_name) {
+ data.title = this.state.context.default_name;
+ }
+ this.trigger_up('openCreate', this._convertEventToFC3Event(data));
+ });
+ $('.o_cw_popover .o_cw_popover_link').on('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._unselectEvent();
+ this.trigger_up('openEvent', {
+ _id: parseInt(e.target.dataset.id, 10),
+ title: e.target.dataset.title,
+ });
+ });
+ }).popover('show');
+ },
+ /**
+ * Scroll to the time set in the FullCalendar parameter
+ * @private
+ */
+ _scrollToScrollTime: function () {
+ var scrollTime = this.calendar.getOption('scrollTime');
+ this.calendar.scrollToTime(scrollTime);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Remove highlight classes and dispose of popovers
+ *
+ * @private
+ */
+ _unselectEvent: function () {
+ this.$('.fc-event').removeClass('o_cw_custom_highlight');
+ this.$('.o_cw_popover').popover('dispose');
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onWindowClick: function (e) {
+ const popover = this.el.querySelector('.o_cw_popover');
+ if (popover && !popover.contains(e.target)) {
+ this._unselectEvent();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onEditEvent: function (event) {
+ this._unselectEvent();
+ this.trigger_up('openEvent', {
+ _id: event.data.id,
+ title: event.data.title,
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onDeleteEvent: function (event) {
+ this._unselectEvent();
+ this.trigger_up('deleteRecord', {id: parseInt(event.data.id, 10)});
+ },
+});
+
+});