diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/calendar | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/calendar')
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; + +}); |
