summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/calendar
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
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/calendar')
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js477
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js777
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_popover.js220
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_quick_create.js114
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js1006
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js204
6 files changed, 2798 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
new file mode 100644
index 00000000..0ab74b34
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -0,0 +1,477 @@
+odoo.define('web.CalendarController', function (require) {
+"use strict";
+
+/**
+ * Calendar Controller
+ *
+ * This is the controller in the Model-Renderer-Controller architecture of the
+ * calendar view. Its role is to coordinate the data from the calendar model
+ * with the renderer, and with the outside world (such as a search view input)
+ */
+
+var AbstractController = require('web.AbstractController');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dialogs = require('web.view_dialogs');
+var QuickCreate = require('web.CalendarQuickCreate');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+function dateToServer (date, fieldType) {
+ date = date.clone().locale('en');
+ if (fieldType === "date") {
+ return date.local().format('YYYY-MM-DD');
+ }
+ return date.utc().format('YYYY-MM-DD HH:mm:ss');
+}
+
+var CalendarController = AbstractController.extend({
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, {
+ changeDate: '_onChangeDate',
+ changeFilter: '_onChangeFilter',
+ deleteRecord: '_onDeleteRecord',
+ dropRecord: '_onDropRecord',
+ next: '_onNext',
+ openCreate: '_onOpenCreate',
+ openEvent: '_onOpenEvent',
+ prev: '_onPrev',
+ quickCreate: '_onQuickCreate',
+ updateRecord: '_onUpdateRecord',
+ viewUpdated: '_onViewUpdated',
+ }),
+ events: _.extend({}, AbstractController.prototype.events, {
+ 'click button.o_calendar_button_new': '_onButtonNew',
+ 'click button.o_calendar_button_prev': '_onButtonNavigation',
+ 'click button.o_calendar_button_today': '_onButtonNavigation',
+ 'click button.o_calendar_button_next': '_onButtonNavigation',
+ 'click button.o_calendar_button_day': '_onButtonScale',
+ 'click button.o_calendar_button_week': '_onButtonScale',
+ 'click button.o_calendar_button_month': '_onButtonScale',
+ 'click button.o_calendar_button_year': '_onButtonScale',
+ }),
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {AbstractModel} model
+ * @param {AbstractRenderer} renderer
+ * @param {Object} params
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.current_start = null;
+ this.displayName = params.displayName;
+ this.quickAddPop = params.quickAddPop;
+ this.disableQuickCreate = params.disableQuickCreate;
+ this.eventOpenPopup = params.eventOpenPopup;
+ this.showUnusualDays = params.showUnusualDays;
+ this.formViewId = params.formViewId;
+ this.readonlyFormViewId = params.readonlyFormViewId;
+ this.mapping = params.mapping;
+ this.context = params.context;
+ this.previousOpen = null;
+ // The quickCreating attribute ensures that we don't do several create
+ this.quickCreating = false;
+ this.scales = params.scales;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Render the buttons according to the CalendarView.buttons template and
+ * add listeners on it. Set this.$buttons with the produced jQuery element
+ *
+ * @param {jQuery} [$node] a jQuery node where the rendered buttons
+ * should be inserted. $node may be undefined, in which case the Calendar
+ * inserts them into this.options.$buttons or into a div of its template
+ */
+ renderButtons: function ($node) {
+ this.$buttons = $(QWeb.render('CalendarView.buttons', this._renderButtonsParameters()));
+
+ this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
+
+ if ($node) {
+ this.$buttons.appendTo($node);
+ } else {
+ this.$('.o_calendar_buttons').replaceWith(this.$buttons);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Find a className in an array using the start of this class and
+ * return the last part of a string
+ * @private
+ * @param {string} startClassName start of string to find in the "array"
+ * @param {array|DOMTokenList} classList array of all class
+ * @return {string|undefined}
+ */
+ _extractLastPartOfClassName(startClassName, classList) {
+ var result;
+ classList.forEach(function (value) {
+ if (value && value.indexOf(startClassName) === 0) {
+ result = value.substring(startClassName.length);
+ }
+ });
+ return result;
+ },
+ /**
+ * Move to the requested direction and reload the view
+ *
+ * @private
+ * @param {string} to either 'prev', 'next' or 'today'
+ * @returns {Promise}
+ */
+ _move: function (to) {
+ this.model[to]();
+ return this.reload();
+ },
+ /**
+ * Parameter send to QWeb to render the template of Buttons
+ *
+ * @private
+ * @return {{}}
+ */
+ _renderButtonsParameters() {
+ return {
+ scales: this.scales,
+ };
+ },
+ /**
+ * @override
+ * @private
+ */
+ _update: function () {
+ var self = this;
+ if (!this.showUnusualDays) {
+ return this._super.apply(this, arguments);
+ }
+ return this._super.apply(this, arguments).then(function () {
+ self._rpc({
+ model: self.modelName,
+ method: 'get_unusual_days',
+ args: [dateToServer(self.model.data.start_date, 'date'), dateToServer(self.model.data.end_date, 'date')],
+ context: self.context,
+ }).then(function (data) {
+ _.each(self.$el.find('td.fc-day'), function (td) {
+ var $td = $(td);
+ if (data[$td.data('date')]) {
+ $td.addClass('o_calendar_disabled');
+ }
+ });
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {Object} record
+ * @param {integer} record.id
+ * @returns {Promise}
+ */
+ _updateRecord: function (record) {
+ var reload = this.reload.bind(this, {});
+ return this.model.updateRecord(record).then(reload, reload);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handler when a user clicks on button to create event
+ *
+ * @private
+ */
+ _onButtonNew() {
+ this.trigger_up('switch_view', {view_type: 'form'});
+ },
+ /**
+ * Handler when a user click on navigation button like prev, next, ...
+ *
+ * @private
+ * @param {Event|jQueryEvent} jsEvent
+ */
+ _onButtonNavigation(jsEvent) {
+ const action = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList);
+ if (action) {
+ this._move(action);
+ }
+ },
+ /**
+ * Handler when a user click on scale button like day, month, ...
+ *
+ * @private
+ * @param {Event|jQueryEvent} jsEvent
+ */
+ _onButtonScale(jsEvent) {
+ const scale = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList);
+ if (scale) {
+ this.model.setScale(scale);
+ this.reload();
+ }
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onChangeDate: function (event) {
+ var modelData = this.model.get();
+ if (modelData.target_date.format('YYYY-MM-DD') === event.data.date.format('YYYY-MM-DD')) {
+ // When clicking on same date, toggle between the two views
+ switch (modelData.scale) {
+ case 'month': this.model.setScale('week'); break;
+ case 'week': this.model.setScale('day'); break;
+ case 'day': this.model.setScale('month'); break;
+ }
+ } else if (modelData.target_date.week() === event.data.date.week()) {
+ // When clicking on a date in the same week, switch to day view
+ this.model.setScale('day');
+ } else {
+ // When clicking on a random day of a random other week, switch to week view
+ this.model.setScale('week');
+ }
+ this.model.setDate(event.data.date);
+ this.reload();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onChangeFilter: function (event) {
+ if (this.model.changeFilter(event.data) && !event.data.no_reload) {
+ this.reload();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onDeleteRecord: function (event) {
+ var self = this;
+ Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), {
+ confirm_callback: function () {
+ self.model.deleteRecords([event.data.id], self.modelName).then(function () {
+ self.reload();
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onDropRecord: function (event) {
+ this._updateRecord(_.extend({}, event.data, {
+ 'drop': true,
+ }));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onNext: function (event) {
+ event.stopPropagation();
+ this._move('next');
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onOpenCreate: function (event) {
+ var self = this;
+ if (["year", "month"].includes(this.model.get().scale)) {
+ event.data.allDay = true;
+ }
+ var data = this.model.calendarEventToRecord(event.data);
+
+ var context = _.extend({}, this.context, event.options && event.options.context);
+ // context default has more priority in default_get so if data.name is false then it may
+ // lead to error/warning while saving record in form view as name field can be required
+ if (data.name) {
+ context.default_name = data.name;
+ }
+ context['default_' + this.mapping.date_start] = data[this.mapping.date_start] || null;
+ if (this.mapping.date_stop) {
+ context['default_' + this.mapping.date_stop] = data[this.mapping.date_stop] || null;
+ }
+ if (this.mapping.date_delay) {
+ context['default_' + this.mapping.date_delay] = data[this.mapping.date_delay] || null;
+ }
+ if (this.mapping.all_day) {
+ context['default_' + this.mapping.all_day] = data[this.mapping.all_day] || null;
+ }
+
+ for (var k in context) {
+ if (context[k] && context[k]._isAMomentObject) {
+ context[k] = dateToServer(context[k]);
+ }
+ }
+
+ var options = _.extend({}, this.options, event.options, {
+ context: context,
+ title: _.str.sprintf(_t('Create: %s'), (this.displayName || this.renderer.arch.attrs.string))
+ });
+
+ if (this.quick != null) {
+ this.quick.destroy();
+ this.quick = null;
+ }
+
+ if (!options.disableQuickCreate && !event.data.disableQuickCreate && this.quickAddPop) {
+ this.quick = new QuickCreate(this, true, options, data, event.data);
+ this.quick.open();
+ this.quick.opened(function () {
+ self.quick.focus();
+ });
+ return;
+ }
+
+ var title = _t("Create");
+ if (this.renderer.arch.attrs.string) {
+ title += ': ' + this.renderer.arch.attrs.string;
+ }
+ if (this.eventOpenPopup) {
+ if (this.previousOpen) { this.previousOpen.close(); }
+ this.previousOpen = new dialogs.FormViewDialog(self, {
+ res_model: this.modelName,
+ context: context,
+ title: title,
+ view_id: this.formViewId || false,
+ disable_multiple_selection: true,
+ on_saved: function () {
+ if (event.data.on_save) {
+ event.data.on_save();
+ }
+ self.reload();
+ },
+ });
+ this.previousOpen.open();
+ } else {
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: this.modelName,
+ views: [[this.formViewId || false, 'form']],
+ target: 'current',
+ context: context,
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onOpenEvent: function (event) {
+ var self = this;
+ var id = event.data._id;
+ id = id && parseInt(id).toString() === id ? parseInt(id) : id;
+
+ if (!this.eventOpenPopup) {
+ this._rpc({
+ model: self.modelName,
+ method: 'get_formview_id',
+ //The event can be called by a view that can have another context than the default one.
+ args: [[id]],
+ context: event.context || self.context,
+ }).then(function (viewId) {
+ self.do_action({
+ type:'ir.actions.act_window',
+ res_id: id,
+ res_model: self.modelName,
+ views: [[viewId || false, 'form']],
+ target: 'current',
+ context: event.context || self.context,
+ });
+ });
+ return;
+ }
+
+ var options = {
+ res_model: self.modelName,
+ res_id: id || null,
+ context: event.context || self.context,
+ title: _t("Open: ") + _.escape(event.data.title),
+ on_saved: function () {
+ if (event.data.on_save) {
+ event.data.on_save();
+ }
+ self.reload();
+ },
+ };
+ if (this.formViewId) {
+ options.view_id = parseInt(this.formViewId);
+ }
+ new dialogs.FormViewDialog(this, options).open();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onPrev: function () {
+ event.stopPropagation();
+ this._move('prev');
+ },
+
+ /**
+ * Handles saving data coming from quick create box
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ var self = this;
+ if (this.quickCreating) {
+ return;
+ }
+ this.quickCreating = true;
+ this.model.createRecord(event)
+ .then(function () {
+ self.quick.destroy();
+ self.quick = null;
+ self.reload();
+ self.quickCreating = false;
+ })
+ .guardedCatch(function (result) {
+ var errorEvent = result.event;
+ // This will occurs if there are some more fields required
+ // Preventdefaulting the error event will prevent the traceback window
+ errorEvent.preventDefault();
+ event.data.options.disableQuickCreate = true;
+ event.data.data.on_save = self.quick.destroy.bind(self.quick);
+ self._onOpenCreate(event.data);
+ self.quickCreating = false;
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onUpdateRecord: function (event) {
+ this._updateRecord(event.data);
+ },
+ /**
+ * The internal state of the calendar (mode, period displayed) has changed,
+ * so update the control panel buttons and breadcrumbs accordingly.
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onViewUpdated: function (event) {
+ this.mode = event.data.mode;
+ if (this.$buttons) {
+ this.$buttons.find('.active').removeClass('active');
+ this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
+ }
+ const title = `${this.displayName} (${event.data.title})`;
+ return this.updateControlPanel({ title });
+ },
+});
+
+return CalendarController;
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
new file mode 100644
index 00000000..93999bee
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -0,0 +1,777 @@
+odoo.define('web.CalendarModel', function (require) {
+"use strict";
+
+var AbstractModel = require('web.AbstractModel');
+var Context = require('web.Context');
+var core = require('web.core');
+var fieldUtils = require('web.field_utils');
+var session = require('web.session');
+
+var _t = core._t;
+
+function dateToServer (date) {
+ return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss');
+}
+
+return AbstractModel.extend({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.end_date = null;
+ var week_start = _t.database.parameters.week_start;
+ // calendar uses index 0 for Sunday but Odoo stores it as 7
+ this.week_start = week_start !== undefined && week_start !== false ? week_start % 7 : moment().startOf('week').day();
+ this.week_stop = this.week_start + 6;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Transform fullcalendar event object to OpenERP Data object
+ */
+ calendarEventToRecord: function (event) {
+ // Normalize event_end without changing fullcalendars event.
+ var data = {'name': event.title};
+ var start = event.start.clone();
+ var end = event.end && event.end.clone();
+
+ // Set end date if not existing
+ if (!end || end.diff(start) < 0) { // undefined or invalid end date
+ if (event.allDay) {
+ end = start.clone();
+ } else {
+ // in week mode or day mode, convert allday event to event
+ end = start.clone().add(2, 'h');
+ }
+ } else if (event.allDay) {
+ // For an "allDay", FullCalendar gives the end day as the
+ // next day at midnight (instead of 23h59).
+ end.add(-1, 'days');
+ }
+
+ var isDateEvent = this.fields[this.mapping.date_start].type === 'date';
+ // An "allDay" event without the "all_day" option is not considered
+ // as a 24h day. It's just a part of the day (by default: 7h-19h).
+ if (event.allDay) {
+ if (!this.mapping.all_day && !isDateEvent) {
+ if (event.r_start) {
+ start.hours(event.r_start.hours())
+ .minutes(event.r_start.minutes())
+ .seconds(event.r_start.seconds())
+ .utc();
+ end.hours(event.r_end.hours())
+ .minutes(event.r_end.minutes())
+ .seconds(event.r_end.seconds())
+ .utc();
+ } else {
+ // default hours in the user's timezone
+ start.hours(7);
+ end.hours(19);
+ }
+ start.add(-this.getSession().getTZOffset(start), 'minutes');
+ end.add(-this.getSession().getTZOffset(end), 'minutes');
+ }
+ } else {
+ start.add(-this.getSession().getTZOffset(start), 'minutes');
+ end.add(-this.getSession().getTZOffset(end), 'minutes');
+ }
+
+ if (this.mapping.all_day) {
+ if (event.record) {
+ data[this.mapping.all_day] =
+ (this.data.scale !== 'month' && event.allDay) ||
+ event.record[this.mapping.all_day] &&
+ end.diff(start) < 10 ||
+ false;
+ } else {
+ data[this.mapping.all_day] = event.allDay;
+ }
+ }
+
+ data[this.mapping.date_start] = start;
+ if (this.mapping.date_stop) {
+ data[this.mapping.date_stop] = end;
+ }
+
+ if (this.mapping.date_delay) {
+ if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) {
+ data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600;
+ }
+ }
+
+ return data;
+ },
+ /**
+ * @param {Object} filter
+ * @returns {boolean}
+ */
+ changeFilter: function (filter) {
+ var Filter = this.data.filters[filter.fieldName];
+ if (filter.value === 'all') {
+ Filter.all = filter.active;
+ }
+ var f = _.find(Filter.filters, function (f) {
+ return f.value === filter.value;
+ });
+ if (f) {
+ if (f.active !== filter.active) {
+ f.active = filter.active;
+ } else {
+ return false;
+ }
+ } else if (filter.active) {
+ Filter.filters.push({
+ value: filter.value,
+ active: true,
+ });
+ }
+ return true;
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ createRecord: function (event) {
+ var data = this.calendarEventToRecord(event.data.data);
+ for (var k in data) {
+ if (data[k] && data[k]._isAMomentObject) {
+ data[k] = dateToServer(data[k]);
+ }
+ }
+ return this._rpc({
+ model: this.modelName,
+ method: 'create',
+ args: [data],
+ context: event.data.options.context,
+ });
+ },
+ /**
+ * @todo I think this is dead code
+ *
+ * @param {any} ids
+ * @param {any} model
+ * @returns
+ */
+ deleteRecords: function (ids, model) {
+ return this._rpc({
+ model: model,
+ method: 'unlink',
+ args: [ids],
+ context: session.user_context, // todo: combine with view context
+ });
+ },
+ /**
+ * @override
+ * @returns {Object}
+ */
+ __get: function () {
+ return _.extend({}, this.data, {
+ fields: this.fields
+ });
+ },
+ /**
+ * @override
+ * @param {any} params
+ * @returns {Promise}
+ */
+ __load: function (params) {
+ var self = this;
+ this.modelName = params.modelName;
+ this.fields = params.fields;
+ this.fieldNames = params.fieldNames;
+ this.fieldsInfo = params.fieldsInfo;
+ this.mapping = params.mapping;
+ this.mode = params.mode; // one of month, week or day
+ this.scales = params.scales; // one of month, week or day
+ this.scalesInfo = params.scalesInfo;
+
+ // Check whether the date field is editable (i.e. if the events can be
+ // dragged and dropped)
+ this.editable = params.editable;
+ this.creatable = params.creatable;
+
+ // display more button when there are too much event on one day
+ this.eventLimit = params.eventLimit;
+
+ // fields to display color, e.g.: user_id.partner_id
+ this.fieldColor = params.fieldColor;
+ if (!this.preloadPromise) {
+ this.preloadPromise = new Promise(function (resolve, reject) {
+ Promise.all([
+ self._rpc({model: self.modelName, method: 'check_access_rights', args: ["write", false]}),
+ self._rpc({model: self.modelName, method: 'check_access_rights', args: ["create", false]})
+ ]).then(function (result) {
+ var write = result[0];
+ var create = result[1];
+ self.write_right = write;
+ self.create_right = create;
+ resolve();
+ }).guardedCatch(reject);
+ });
+ }
+
+ this.data = {
+ domain: params.domain,
+ context: params.context,
+ // get in arch the filter to display in the sidebar and the field to read
+ filters: params.filters,
+ };
+
+ this.setDate(params.initialDate);
+ // Use mode attribute in xml file to specify zoom timeline (day,week,month)
+ // by default month.
+ this.setScale(params.mode);
+
+ _.each(this.data.filters, function (filter) {
+ if (filter.avatar_field && !filter.avatar_model) {
+ filter.avatar_model = self.modelName;
+ }
+ });
+
+ return this.preloadPromise.then(this._loadCalendar.bind(this));
+ },
+ /**
+ * Move the current date range to the next period
+ */
+ next: function () {
+ this.setDate(this.data.target_date.clone().add(1, this.data.scale));
+ },
+ /**
+ * Move the current date range to the previous period
+ */
+ prev: function () {
+ this.setDate(this.data.target_date.clone().add(-1, this.data.scale));
+ },
+ /**
+ * @override
+ * @param {Object} [params.context]
+ * @param {Array} [params.domain]
+ * @returns {Promise}
+ */
+ __reload: function (handle, params) {
+ if (params.domain) {
+ this.data.domain = params.domain;
+ }
+ if (params.context) {
+ this.data.context = params.context;
+ }
+ return this._loadCalendar();
+ },
+ /**
+ * @param {Moment} start. in local TZ
+ */
+ setDate: function (start) {
+ // keep highlight/target_date in localtime
+ this.data.highlight_date = this.data.target_date = start.clone();
+ this.data.start_date = this.data.end_date = start;
+ switch (this.data.scale) {
+ case 'year': {
+ const yearStart = this.data.start_date.clone().startOf('year');
+ let yearStartDay = this.week_start;
+ if (yearStart.day() < yearStartDay) {
+ // the 1st of January is before our week start (e.g. week start is Monday, and
+ // 01/01 is Sunday), so we go one week back
+ yearStartDay -= 7;
+ }
+ this.data.start_date = yearStart.day(yearStartDay).startOf('day');
+ this.data.end_date = this.data.end_date.clone()
+ .endOf('year').day(this.week_stop).endOf('day');
+ break;
+ }
+ case 'month':
+ var monthStart = this.data.start_date.clone().startOf('month');
+
+ var monthStartDay;
+ if (monthStart.day() >= this.week_start) {
+ // the month's first day is after our week start
+ // Then we are in the right week
+ monthStartDay = this.week_start;
+ } else {
+ // The month's first day is before our week start
+ // Then we should go back to the the previous week
+ monthStartDay = this.week_start - 7;
+ }
+
+ this.data.start_date = monthStart.day(monthStartDay).startOf('day');
+ this.data.end_date = this.data.start_date.clone().add(5, 'week').day(this.week_stop).endOf('day');
+ break;
+ case 'week':
+ var weekStart = this.data.start_date.clone().startOf('week');
+ var weekStartDay = this.week_start;
+ if (this.data.start_date.day() < this.week_start) {
+ // The week's first day is after our current day
+ // Then we should go back to the previous week
+ weekStartDay -= 7;
+ }
+ this.data.start_date = this.data.start_date.clone().day(weekStartDay).startOf('day');
+ this.data.end_date = this.data.end_date.clone().day(weekStartDay + 6).endOf('day');
+ break;
+ default:
+ this.data.start_date = this.data.start_date.clone().startOf('day');
+ this.data.end_date = this.data.end_date.clone().endOf('day');
+ }
+ // We have set start/stop datetime as definite begin/end boundaries of a period (month, week, day)
+ // in local TZ (what is the begining of the week *I am* in ?)
+ // The following code:
+ // - converts those to UTC using our homemade method (testable)
+ // - sets the moment UTC flag to true, to ensure compatibility with third party libs
+ var manualUtcDateStart = this.data.start_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes');
+ var formattedUtcDateStart = manualUtcDateStart.format('YYYY-MM-DDTHH:mm:ss') + 'Z';
+ this.data.start_date = moment.utc(formattedUtcDateStart);
+
+ var manualUtcDateEnd = this.data.end_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes');
+ var formattedUtcDateEnd = manualUtcDateEnd.format('YYYY-MM-DDTHH:mm:ss') + 'Z';
+ this.data.end_date = moment.utc(formattedUtcDateEnd);
+ },
+ /**
+ * @param {string} scale the scale to set
+ */
+ setScale: function (scale) {
+ if (!_.contains(this.scales, scale)) {
+ scale = "week";
+ }
+ this.data.scale = scale;
+ this.setDate(this.data.target_date);
+ },
+ /**
+ * Move the current date range to the period containing today
+ */
+ today: function () {
+ this.setDate(moment(new Date()));
+ },
+ /**
+ * @param {Object} record
+ * @param {integer} record.id
+ * @returns {Promise}
+ */
+ updateRecord: function (record) {
+ // Cannot modify actual name yet
+ var data = _.omit(this.calendarEventToRecord(record), 'name');
+ for (var k in data) {
+ if (data[k] && data[k]._isAMomentObject) {
+ data[k] = dateToServer(data[k]);
+ }
+ }
+ var context = new Context(this.data.context, {from_ui: true});
+ return this._rpc({
+ model: this.modelName,
+ method: 'write',
+ args: [[parseInt(record.id, 10)], data],
+ context: context
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts this.data.filters into a domain
+ *
+ * @private
+ * @returns {Array}
+ */
+ _getFilterDomain: function () {
+ // List authorized values for every field
+ // fields with an active 'all' filter are skipped
+ var authorizedValues = {};
+ var avoidValues = {};
+
+ _.each(this.data.filters, function (filter) {
+ // Skip 'all' filters because they do not affect the domain
+ if (filter.all) return;
+
+ // Loop over subfilters to complete authorizedValues
+ _.each(filter.filters, function (f) {
+ if (filter.write_model) {
+ if (!authorizedValues[filter.fieldName])
+ authorizedValues[filter.fieldName] = [];
+
+ if (f.active) {
+ authorizedValues[filter.fieldName].push(f.value);
+ }
+ } else {
+ if (!f.active) {
+ if (!avoidValues[filter.fieldName])
+ avoidValues[filter.fieldName] = [];
+
+ avoidValues[filter.fieldName].push(f.value);
+ }
+ }
+ });
+ });
+
+ // Compute the domain
+ var domain = [];
+ for (var field in authorizedValues) {
+ domain.push([field, 'in', authorizedValues[field]]);
+ }
+ for (var field in avoidValues) {
+ if (avoidValues[field].length > 0) {
+ domain.push([field, 'not in', avoidValues[field]]);
+ }
+ }
+
+ return domain;
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _getFullCalendarOptions: function () {
+ var format12Hour = {
+ hour: 'numeric',
+ minute: '2-digit',
+ omitZeroMinute: true,
+ meridiem: 'short'
+ };
+ var format24Hour = {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: false,
+ };
+ return {
+ defaultView: this.scalesInfo[this.mode || 'week'],
+ header: false,
+ selectable: this.creatable && this.create_right,
+ selectMirror: true,
+ editable: this.editable,
+ droppable: true,
+ navLinks: false,
+ eventLimit: this.eventLimit, // allow "more" link when too many events
+ snapMinutes: 15,
+ longPressDelay: 500,
+ eventResizableFromStart: true,
+ nowIndicator: true,
+ weekNumbers: true,
+ weekNumbersWithinDays: true,
+ weekNumberCalculation: function (date) {
+ // Since FullCalendar v4 ISO 8601 week date is preferred so we force the old system
+ return moment(date).week();
+ },
+ weekLabel: _t("Week"),
+ allDayText: _t("All day"),
+ monthNames: moment.months(),
+ monthNamesShort: moment.monthsShort(),
+ dayNames: moment.weekdays(),
+ dayNamesShort: moment.weekdaysShort(),
+ firstDay: this.week_start,
+ slotLabelFormat: _t.database.parameters.time_format.search("%H") !== -1 ? format24Hour : format12Hour,
+ allDaySlot: this.mapping.all_day || this.fields[this.mapping.date_start].type === 'date',
+ };
+ },
+ /**
+ * Return a domain from the date range
+ *
+ * @private
+ * @returns {Array} A domain containing datetimes start and stop in UTC
+ * those datetimes are formatted according to server's standards
+ */
+ _getRangeDomain: function () {
+ // Build OpenERP Domain to filter object by this.mapping.date_start field
+ // between given start, end dates.
+ var domain = [[this.mapping.date_start, '<=', dateToServer(this.data.end_date)]];
+ if (this.mapping.date_stop) {
+ domain.push([this.mapping.date_stop, '>=', dateToServer(this.data.start_date)]);
+ } else if (!this.mapping.date_delay) {
+ domain.push([this.mapping.date_start, '>=', dateToServer(this.data.start_date)]);
+ }
+ return domain;
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _loadCalendar: function () {
+ var self = this;
+ this.data.fc_options = this._getFullCalendarOptions();
+
+ var defs = _.map(this.data.filters, this._loadFilter.bind(this));
+
+ return Promise.all(defs).then(function () {
+ return self._rpc({
+ model: self.modelName,
+ method: 'search_read',
+ context: self.data.context,
+ fields: self.fieldNames,
+ domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain())
+ })
+ .then(function (events) {
+ self._parseServerData(events);
+ self.data.data = _.map(events, self._recordToCalendarEvent.bind(self));
+ return Promise.all([
+ self._loadColors(self.data, self.data.data),
+ self._loadRecordsToFilters(self.data, self.data.data)
+ ]);
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {any} element
+ * @param {any} events
+ * @returns {Promise}
+ */
+ _loadColors: function (element, events) {
+ if (this.fieldColor) {
+ var fieldName = this.fieldColor;
+ _.each(events, function (event) {
+ var value = event.record[fieldName];
+ event.color_index = _.isArray(value) ? value[0] % 30 : value % 30;
+ });
+ this.model_color = this.fields[fieldName].relation || element.model;
+ }
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {any} filter
+ * @returns {Promise}
+ */
+ _loadFilter: function (filter) {
+ if (!filter.write_model) {
+ return Promise.resolve();
+ }
+
+ var field = this.fields[filter.fieldName];
+ return this._rpc({
+ model: filter.write_model,
+ method: 'search_read',
+ domain: [["user_id", "=", session.uid]],
+ fields: [filter.write_field],
+ })
+ .then(function (res) {
+ var records = _.map(res, function (record) {
+ var _value = record[filter.write_field];
+ var value = _.isArray(_value) ? _value[0] : _value;
+ var f = _.find(filter.filters, function (f) {return f.value === value;});
+ var formater = fieldUtils.format[_.contains(['many2many', 'one2many'], field.type) ? 'many2one' : field.type];
+ return {
+ 'id': record.id,
+ 'value': value,
+ 'label': formater(_value, field),
+ 'active': !f || f.active,
+ };
+ });
+ records.sort(function (f1,f2) {
+ return _.string.naturalCmp(f2.label, f1.label);
+ });
+
+ // add my profile
+ if (field.relation === 'res.partner' || field.relation === 'res.users') {
+ var value = field.relation === 'res.partner' ? session.partner_id : session.uid;
+ var me = _.find(records, function (record) {
+ return record.value === value;
+ });
+ if (me) {
+ records.splice(records.indexOf(me), 1);
+ } else {
+ var f = _.find(filter.filters, function (f) {return f.value === value;});
+ me = {
+ 'value': value,
+ 'label': session.name + _t(" [Me]"),
+ 'active': !f || f.active,
+ };
+ }
+ records.unshift(me);
+ }
+ // add all selection
+ records.push({
+ 'value': 'all',
+ 'label': field.relation === 'res.partner' || field.relation === 'res.users' ? _t("Everybody's calendars") : _t("Everything"),
+ 'active': filter.all,
+ });
+
+ filter.filters = records;
+ });
+ },
+ /**
+ * @private
+ * @param {any} element
+ * @param {any} events
+ * @returns {Promise}
+ */
+ _loadRecordsToFilters: function (element, events) {
+ var self = this;
+ var new_filters = {};
+ var to_read = {};
+ var defs = [];
+ var color_filter = {};
+
+ _.each(this.data.filters, function (filter, fieldName) {
+ var field = self.fields[fieldName];
+
+ new_filters[fieldName] = filter;
+ if (filter.write_model) {
+ if (field.relation === self.model_color) {
+ _.each(filter.filters, function (f) {
+ f.color_index = f.value;
+ });
+ }
+ return;
+ }
+
+ _.each(filter.filters, function (filter) {
+ filter.display = !filter.active;
+ });
+
+ var fs = [];
+ var undefined_fs = [];
+ _.each(events, function (event) {
+ var data = event.record[fieldName];
+ if (!_.contains(['many2many', 'one2many'], field.type)) {
+ data = [data];
+ } else {
+ to_read[field.relation] = (to_read[field.relation] || []).concat(data);
+ }
+ _.each(data, function (_value) {
+ var value = _.isArray(_value) ? _value[0] : _value;
+ var f = {
+ 'color_index': self.model_color === (field.relation || element.model) ? value % 30 : false,
+ 'value': value,
+ 'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"),
+ 'avatar_model': field.relation || element.model,
+ };
+ // if field used as color does not have value then push filter in undefined_fs,
+ // such filters should come last in filter list with Undefined string, later merge it with fs
+ value ? fs.push(f) : undefined_fs.push(f);
+ });
+ });
+ _.each(_.union(fs, undefined_fs), function (f) {
+ var f1 = _.findWhere(filter.filters, _.omit(f, 'color_index'));
+ if (f1) {
+ f1.display = true;
+ } else {
+ f.display = f.active = true;
+ filter.filters.push(f);
+ }
+ });
+
+ if (filter.color_model && filter.field_color) {
+ var ids = filter.filters.reduce((acc, f) => {
+ if (!f.color_index && f.value) {
+ acc.push(f.value);
+ }
+ return acc;
+ }, []);
+ if (!color_filter[filter.color_model]) {
+ color_filter[filter.color_model] = {};
+ }
+ if (ids.length) {
+ defs.push(self._rpc({
+ model: filter.color_model,
+ method: 'read',
+ args: [_.uniq(ids), [filter.field_color]],
+ })
+ .then(function (res) {
+ _.each(res, function (c) {
+ color_filter[filter.color_model][c.id] = c[filter.field_color];
+ });
+ }));
+ }
+ }
+ });
+
+ _.each(to_read, function (ids, model) {
+ defs.push(self._rpc({
+ model: model,
+ method: 'name_get',
+ args: [_.uniq(ids)],
+ })
+ .then(function (res) {
+ to_read[model] = _.object(res);
+ }));
+ });
+ return Promise.all(defs).then(function () {
+ _.each(self.data.filters, function (filter) {
+ if (filter.write_model) {
+ return;
+ }
+ if (filter.filters.length && (filter.filters[0].avatar_model in to_read)) {
+ _.each(filter.filters, function (f) {
+ f.label = to_read[f.avatar_model][f.value];
+ });
+ }
+ if (filter.color_model && filter.field_color) {
+ _.each(filter.filters, function (f) {
+ if (!f.color_index) {
+ f.color_index = color_filter[filter.color_model] && color_filter[filter.color_model][f.value];
+ }
+ });
+ }
+ });
+ });
+ },
+ /**
+ * parse the server values to javascript framwork
+ *
+ * @private
+ * @param {Object} data the server data to parse
+ */
+ _parseServerData: function (data) {
+ var self = this;
+ _.each(data, function(event) {
+ _.each(self.fieldNames, function (fieldName) {
+ event[fieldName] = self._parseServerValue(self.fields[fieldName], event[fieldName]);
+ });
+ });
+ },
+ /**
+ * Transform OpenERP event object to fullcalendar event object
+ *
+ * @private
+ * @param {Object} evt
+ */
+ _recordToCalendarEvent: function (evt) {
+ var date_start;
+ var date_stop;
+ var date_delay = evt[this.mapping.date_delay] || 1.0,
+ all_day = this.fields[this.mapping.date_start].type === 'date' ||
+ this.mapping.all_day && evt[this.mapping.all_day] || false,
+ the_title = '',
+ attendees = [];
+
+ if (!all_day) {
+ date_start = evt[this.mapping.date_start].clone();
+ date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone() : null;
+ } else {
+ date_start = evt[this.mapping.date_start].clone().startOf('day');
+ date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone().startOf('day') : null;
+ }
+
+ if (!date_stop && date_delay) {
+ date_stop = date_start.clone().add(date_delay,'hours');
+ }
+
+ if (!all_day) {
+ date_start.add(this.getSession().getTZOffset(date_start), 'minutes');
+ date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes');
+ }
+
+ if (this.mapping.all_day && evt[this.mapping.all_day]) {
+ date_stop.add(1, 'days');
+ }
+ var r = {
+ 'record': evt,
+ 'start': date_start.local(true).toDate(),
+ 'end': date_stop.local(true).toDate(),
+ 'r_start': date_start.clone().local(true).toDate(),
+ 'r_end': date_stop.clone().local(true).toDate(),
+ 'title': the_title,
+ 'allDay': all_day,
+ 'id': evt.id,
+ 'attendees':attendees,
+ };
+
+ if (!(this.mapping.all_day && evt[this.mapping.all_day]) && this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') {
+ r.showTime = true;
+ }
+
+ return r;
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_popover.js b/addons/web/static/src/js/views/calendar/calendar_popover.js
new file mode 100644
index 00000000..18a3d1c2
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_popover.js
@@ -0,0 +1,220 @@
+odoo.define('web.CalendarPopover', function (require) {
+"use strict";
+
+var fieldRegistry = require('web.field_registry');
+const fieldRegistryOwl = require('web.field_registry_owl');
+const FieldWrapper = require('web.FieldWrapper');
+var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin');
+var Widget = require('web.Widget');
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+
+var CalendarPopover = Widget.extend(WidgetAdapterMixin, StandaloneFieldManagerMixin, {
+ template: 'CalendarView.event.popover',
+ events: {
+ 'click .o_cw_popover_edit': '_onClickPopoverEdit',
+ 'click .o_cw_popover_delete': '_onClickPopoverDelete',
+ },
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} eventInfo
+ */
+ init: function (parent, eventInfo) {
+ this._super.apply(this, arguments);
+ StandaloneFieldManagerMixin.init.call(this);
+ this.hideDate = eventInfo.hideDate;
+ this.hideTime = eventInfo.hideTime;
+ this.eventTime = eventInfo.eventTime;
+ this.eventDate = eventInfo.eventDate;
+ this.displayFields = eventInfo.displayFields;
+ this.fields = eventInfo.fields;
+ this.event = eventInfo.event;
+ this.modelName = eventInfo.modelName;
+ this._canDelete = eventInfo.canDelete;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([this._super.apply(this, arguments), this._processFields()]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ _.each(this.$fieldsList, function ($field) {
+ $field.appendTo(self.$('.o_cw_popover_fields_secondary'));
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ WidgetAdapterMixin.destroy.call(this);
+ },
+ /**
+ * Called each time the widget is attached into the DOM.
+ */
+ on_attach_callback: function () {
+ WidgetAdapterMixin.on_attach_callback.call(this);
+ },
+ /**
+ * Called each time the widget is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ WidgetAdapterMixin.on_detach_callback.call(this);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @return {boolean}
+ */
+ isEventDeletable() {
+ return this._canDelete;;
+ },
+ /**
+ * @return {boolean}
+ */
+ isEventDetailsVisible() {
+ return true;
+ },
+ /**
+ * @return {boolean}
+ */
+ isEventEditable() {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Generate fields to render into popover
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _processFields: function () {
+ var self = this;
+ var fieldsToGenerate = [];
+ var fields = _.keys(this.displayFields);
+ for (var i=0; i<fields.length; i++) {
+ var fieldName = fields[i];
+ var displayFieldInfo = self.displayFields[fieldName] || {attrs: {invisible: 1}};
+ var fieldInfo = self.fields[fieldName];
+ var field = {
+ name: fieldName,
+ string: displayFieldInfo.attrs.string || fieldInfo.string,
+ value: self.event.extendedProps.record[fieldName],
+ type: fieldInfo.type,
+ invisible: displayFieldInfo.attrs.invisible,
+ };
+ if (field.type === 'selection') {
+ field.selection = fieldInfo.selection;
+ }
+ if (field.type === 'monetary') {
+ var currencyField = field.currency_field || 'currency_id';
+ if (!fields.includes(currencyField) && _.has(self.event.extendedProps.record, currencyField)) {
+ fields.push(currencyField);
+ }
+ }
+ if (fieldInfo.relation) {
+ field.relation = fieldInfo.relation;
+ }
+ if (displayFieldInfo.attrs.widget) {
+ field.widget = displayFieldInfo.attrs.widget;
+ } else if (_.contains(['many2many', 'one2many'], field.type)) {
+ field.widget = 'many2many_tags';
+ }
+ if (_.contains(['many2many', 'one2many'], field.type)) {
+ field.fields = [{
+ name: 'id',
+ type: 'integer',
+ }, {
+ name: 'display_name',
+ type: 'char',
+ }];
+ }
+ fieldsToGenerate.push(field);
+ };
+
+ this.$fieldsList = [];
+ return this.model.makeRecord(this.modelName, fieldsToGenerate).then(function (recordID) {
+ var defs = [];
+
+ var record = self.model.get(recordID);
+ _.each(fieldsToGenerate, function (field) {
+ if (field.invisible) return;
+ let isLegacy = true;
+ let fieldWidget;
+ let FieldClass = fieldRegistryOwl.getAny([field.widget, field.type]);
+ if (FieldClass) {
+ isLegacy = false;
+ fieldWidget = new FieldWrapper(this, FieldClass, {
+ fieldName: field.name,
+ record,
+ options: self.displayFields[field.name],
+ });
+ } else {
+ FieldClass = fieldRegistry.getAny([field.widget, field.type]);
+ fieldWidget = new FieldClass(self, field.name, record, self.displayFields[field.name]);
+ }
+ if (fieldWidget.attrs && !_.isObject(fieldWidget.attrs.modifiers)) {
+ fieldWidget.attrs.modifiers = fieldWidget.attrs.modifiers ? JSON.parse(fieldWidget.attrs.modifiers) : {};
+ }
+ self._registerWidget(recordID, field.name, fieldWidget);
+
+ var $field = $('<li>', {class: 'list-group-item flex-shrink-0 d-flex flex-wrap'});
+ var $fieldLabel = $('<strong>', {class: 'mr-2', text: _.str.sprintf('%s : ', field.string)});
+ $fieldLabel.appendTo($field);
+ var $fieldContainer = $('<div>', {class: 'flex-grow-1'});
+ $fieldContainer.appendTo($field);
+
+ let def;
+ if (isLegacy) {
+ def = fieldWidget.appendTo($fieldContainer);
+ } else {
+ def = fieldWidget.mount($fieldContainer[0]);
+ }
+ self.$fieldsList.push($field);
+ defs.push(def);
+ });
+ return Promise.all(defs);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onClickPopoverEdit: function (ev) {
+ ev.preventDefault();
+ this.trigger_up('edit_event', {
+ id: this.event.id,
+ title: this.event.extendedProps.record.display_name,
+ });
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onClickPopoverDelete: function (ev) {
+ ev.preventDefault();
+ this.trigger_up('delete_event', {id: this.event.id});
+ },
+});
+
+return CalendarPopover;
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_quick_create.js b/addons/web/static/src/js/views/calendar/calendar_quick_create.js
new file mode 100644
index 00000000..0f6f8bd6
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_quick_create.js
@@ -0,0 +1,114 @@
+odoo.define('web.CalendarQuickCreate', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+/**
+ * Quick creation view.
+ *
+ * Triggers a single event "added" with a single parameter "name", which is the
+ * name entered by the user
+ *
+ * @class
+ * @type {*}
+ */
+var QuickCreate = Dialog.extend({
+ events: _.extend({}, Dialog.events, {
+ 'keyup input': '_onkeyup',
+ }),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} buttons
+ * @param {Object} options
+ * @param {Object} dataTemplate
+ * @param {Object} dataCalendar
+ */
+ init: function (parent, buttons, options, dataTemplate, dataCalendar) {
+ this._buttons = buttons || false;
+ this.options = options;
+
+ // Can hold data pre-set from where you clicked on agenda
+ this.dataTemplate = dataTemplate || {};
+ this.dataCalendar = dataCalendar;
+
+ var self = this;
+ this._super(parent, {
+ title: options.title,
+ size: 'small',
+ buttons: this._buttons ? [
+ {text: _t("Create"), classes: 'btn-primary', click: function () {
+ if (!self._quickAdd(dataCalendar)) {
+ self.focus();
+ }
+ }},
+ {text: _t("Edit"), click: function () {
+ dataCalendar.disableQuickCreate = true;
+ dataCalendar.title = self.$('input').val().trim();
+ dataCalendar.on_save = self.destroy.bind(self);
+ self.trigger_up('openCreate', dataCalendar);
+ }},
+ {text: _t("Cancel"), close: true},
+ ] : [],
+ $content: QWeb.render('CalendarView.quick_create', {widget: this})
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ focus: function () {
+ this.$('input').focus();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Gathers data from the quick create dialog a launch quick_create(data) method
+ */
+ _quickAdd: function (dataCalendar) {
+ dataCalendar = $.extend({}, this.dataTemplate, dataCalendar);
+ var val = this.$('input').val().trim();
+ if (!val) {
+ this.$('label, input').addClass('o_field_invalid');
+ var warnings = _.str.sprintf('<ul><li>%s</li></ul>', _t("Summary"));
+ this.do_warn(_t("Invalid fields:"), warnings);
+ }
+ dataCalendar.title = val;
+ return (val)? this.trigger_up('quickCreate', {data: dataCalendar, options: this.options}) : false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {keyEvent} event
+ */
+ _onkeyup: function (event) {
+ if (this._flagEnter) {
+ return;
+ }
+ if(event.keyCode === $.ui.keyCode.ENTER) {
+ this._flagEnter = true;
+ if (!this._quickAdd(this.dataCalendar)){
+ this._flagEnter = false;
+ }
+ } else if (event.keyCode === $.ui.keyCode.ESCAPE && this._buttons) {
+ this.close();
+ }
+ },
+});
+
+return QuickCreate;
+
+});
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)});
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js
new file mode 100644
index 00000000..b83b8072
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_view.js
@@ -0,0 +1,204 @@
+odoo.define('web.CalendarView', function (require) {
+"use strict";
+
+var AbstractView = require('web.AbstractView');
+var CalendarModel = require('web.CalendarModel');
+var CalendarController = require('web.CalendarController');
+var CalendarRenderer = require('web.CalendarRenderer');
+var core = require('web.core');
+var pyUtils = require('web.py_utils');
+var utils = require('web.utils');
+
+var _lt = core._lt;
+
+// gather the fields to get
+var fieldsToGather = [
+ "date_start",
+ "date_delay",
+ "date_stop",
+ "all_day",
+ "recurrence_update"
+];
+
+const scalesInfo = {
+ day: 'timeGridDay',
+ week: 'timeGridWeek',
+ month: 'dayGridMonth',
+ year: 'dayGridYear',
+};
+
+var CalendarView = AbstractView.extend({
+ display_name: _lt('Calendar'),
+ icon: 'fa-calendar',
+ jsLibs: [
+ '/web/static/lib/fullcalendar/core/main.js',
+ '/web/static/lib/fullcalendar/interaction/main.js',
+ '/web/static/lib/fullcalendar/moment/main.js',
+ '/web/static/lib/fullcalendar/daygrid/main.js',
+ '/web/static/lib/fullcalendar/timegrid/main.js',
+ '/web/static/lib/fullcalendar/list/main.js'
+ ],
+ cssLibs: [
+ '/web/static/lib/fullcalendar/core/main.css',
+ '/web/static/lib/fullcalendar/daygrid/main.css',
+ '/web/static/lib/fullcalendar/timegrid/main.css',
+ '/web/static/lib/fullcalendar/list/main.css'
+ ],
+ config: _.extend({}, AbstractView.prototype.config, {
+ Model: CalendarModel,
+ Controller: CalendarController,
+ Renderer: CalendarRenderer,
+ }),
+ viewType: 'calendar',
+ searchMenuTypes: ['filter', 'favorite'],
+
+ /**
+ * @override
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+ var arch = this.arch;
+ var fields = this.fields;
+ var attrs = arch.attrs;
+
+ if (!attrs.date_start) {
+ throw new Error(_lt("Calendar view has not defined 'date_start' attribute."));
+ }
+
+ var mapping = {};
+ var fieldNames = fields.display_name ? ['display_name'] : [];
+ var displayFields = {};
+
+ _.each(fieldsToGather, function (field) {
+ if (arch.attrs[field]) {
+ var fieldName = attrs[field];
+ mapping[field] = fieldName;
+ fieldNames.push(fieldName);
+ }
+ });
+
+ var filters = {};
+
+ var eventLimit = attrs.event_limit !== null && (isNaN(+attrs.event_limit) ? _.str.toBool(attrs.event_limit) : +attrs.event_limit);
+
+ var modelFilters = [];
+ _.each(arch.children, function (child) {
+ if (child.tag !== 'field') return;
+ var fieldName = child.attrs.name;
+ fieldNames.push(fieldName);
+ if (!child.attrs.invisible || child.attrs.filters) {
+ child.attrs.options = child.attrs.options ? pyUtils.py_eval(child.attrs.options) : {};
+ if (!child.attrs.invisible) {
+ displayFields[fieldName] = {attrs: child.attrs};
+ }
+
+ if (params.sidebar === false) return; // if we have not sidebar, (eg: Dashboard), we don't use the filter "coworkers"
+
+ if (child.attrs.avatar_field) {
+ filters[fieldName] = filters[fieldName] || {
+ 'title': fields[fieldName].string,
+ 'fieldName': fieldName,
+ 'filters': [],
+ };
+ filters[fieldName].avatar_field = child.attrs.avatar_field;
+ filters[fieldName].avatar_model = fields[fieldName].relation;
+ }
+ if (child.attrs.write_model) {
+ filters[fieldName] = filters[fieldName] || {
+ 'title': fields[fieldName].string,
+ 'fieldName': fieldName,
+ 'filters': [],
+ };
+ filters[fieldName].write_model = child.attrs.write_model;
+ filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields
+
+ modelFilters.push(fields[fieldName].relation);
+ }
+ if (child.attrs.filters) {
+ filters[fieldName] = filters[fieldName] || {
+ 'title': fields[fieldName].string,
+ 'fieldName': fieldName,
+ 'filters': [],
+ };
+ if (child.attrs.color) {
+ filters[fieldName].field_color = child.attrs.color;
+ filters[fieldName].color_model = fields[fieldName].relation;
+ }
+ if (!child.attrs.avatar_field && fields[fieldName].relation) {
+ if (fields[fieldName].relation.includes(['res.users', 'res.partner', 'hr.employee'])) {
+ filters[fieldName].avatar_field = 'image_128';
+ }
+ filters[fieldName].avatar_model = fields[fieldName].relation;
+ }
+ }
+ }
+ });
+
+ if (attrs.color) {
+ var fieldName = attrs.color;
+ fieldNames.push(fieldName);
+ }
+
+ //if quick_add = False, we don't allow quick_add
+ //if quick_add = not specified in view, we use the default widgets.QuickCreate
+ //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate'
+ this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true));
+ this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop;
+
+ // If form_view_id is set, then the calendar view will open a form view
+ // with this id, when it needs to edit or create an event.
+ this.controllerParams.formViewId =
+ attrs.form_view_id ? parseInt(attrs.form_view_id, 10) : false;
+ if (!this.controllerParams.formViewId && params.action) {
+ var formViewDescr = _.find(params.action.views, function (v) {
+ return v.type === 'form';
+ });
+ if (formViewDescr) {
+ this.controllerParams.formViewId = formViewDescr.viewID;
+ }
+ }
+
+ let scales;
+ const allowedScales = Object.keys(scalesInfo);
+ if (arch.attrs.scales) {
+ scales = arch.attrs.scales.split(',')
+ .filter(x => allowedScales.includes(x));
+ } else {
+ scales = allowedScales;
+ }
+
+ this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false);
+ this.controllerParams.showUnusualDays = utils.toBoolElse(attrs.show_unusual_days || '', false);
+ this.controllerParams.mapping = mapping;
+ this.controllerParams.context = params.context || {};
+ this.controllerParams.displayName = params.action && params.action.name;
+ this.controllerParams.scales = scales;
+
+ this.rendererParams.displayFields = displayFields;
+ this.rendererParams.model = viewInfo.model;
+ this.rendererParams.hideDate = utils.toBoolElse(attrs.hide_date || '', false);
+ this.rendererParams.hideTime = utils.toBoolElse(attrs.hide_time || '', false);
+ this.rendererParams.canDelete = this.controllerParams.activeActions.delete;
+ this.rendererParams.canCreate = this.controllerParams.activeActions.create;
+ this.rendererParams.scalesInfo = scalesInfo;
+
+ this.loadParams.fieldNames = _.uniq(fieldNames);
+ this.loadParams.mapping = mapping;
+ this.loadParams.fields = fields;
+ this.loadParams.fieldsInfo = viewInfo.fieldsInfo;
+ this.loadParams.editable = !fields[mapping.date_start].readonly;
+ this.loadParams.creatable = this.controllerParams.activeActions.create;
+ this.loadParams.eventLimit = eventLimit;
+ this.loadParams.fieldColor = attrs.color;
+
+ this.loadParams.filters = filters;
+ this.loadParams.mode = (params.context && params.context.default_mode) || attrs.mode;
+ this.loadParams.scales = scales;
+ this.loadParams.initialDate = moment(params.initialDate || new Date());
+ this.loadParams.scalesInfo = scalesInfo;
+ },
+});
+
+return CalendarView;
+
+});