summaryrefslogtreecommitdiff
path: root/addons/web
diff options
context:
space:
mode:
authorMichael Mattiello (mcm) <mcm@odoo.com>2020-07-31 13:56:45 +0000
committerMichael Mattiello (mcm) <mcm@odoo.com>2020-08-11 12:32:54 +0000
commit441d9f65b3b90c37ce812d2addbe5b62d2d88229 (patch)
tree1a66c5fdaa27a2d0f5b6369299d334cf66426951 /addons/web
parent9a53b032395aba59cee9a2e5660ebf8d6136b978 (diff)
[IMP] base,web: add year scale to calendar
This commit adds a new year scale to the calendar view. It comes with a new view in the calendar, this view displays a 4x3 grid which cells are month views. Task: 2193921
Diffstat (limited to 'addons/web')
-rw-r--r--addons/web/static/src/js/libs/fullcalendar.js252
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js3
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js23
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js179
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js1
-rw-r--r--addons/web/static/src/scss/web_calendar.scss98
-rw-r--r--addons/web/static/src/xml/web_calendar.xml32
-rw-r--r--addons/web/static/tests/views/calendar_tests.js174
-rw-r--r--addons/web/views/webclient_templates.xml1
9 files changed, 740 insertions, 23 deletions
diff --git a/addons/web/static/src/js/libs/fullcalendar.js b/addons/web/static/src/js/libs/fullcalendar.js
new file mode 100644
index 00000000000..7f9714e80e6
--- /dev/null
+++ b/addons/web/static/src/js/libs/fullcalendar.js
@@ -0,0 +1,252 @@
+odoo.define('/web/static/src/js/libs/fullcalendar.js', function () {
+ "use strict";
+
+ function createYearCalendarView(FullCalendar) {
+ const {
+ Calendar,
+ createElement,
+ EventApi,
+ memoizeRendering,
+ View,
+ } = FullCalendar;
+
+ class YearView extends View {
+ constructor() {
+ super(...arguments);
+ this.months = null;
+ this.renderSubCalendarsMem = memoizeRendering(
+ this.renderSubCalendars, this.unrenderSubCalendars);
+ this.events = [];
+ }
+
+ //----------------------------------------------------------------------
+ // Getters
+ //----------------------------------------------------------------------
+
+ get currentDate() {
+ return this.context.calendar.state.currentDate;
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ destroy() {
+ this.renderSubCalendarsMem.unrender();
+ super.destroy();
+ }
+ /**
+ * Removes the selection on sub calendar.
+ * Selections on sub calendars are not propagated to this view so
+ * this view cannot manage them.
+ */
+ unselect() {
+ for (const { calendar } of this.months) {
+ calendar.unselect();
+ }
+ }
+ /**
+ * @override
+ */
+ render() {
+ this.renderSubCalendarsMem(this.context);
+ super.render(...arguments);
+ }
+ /**
+ * Renders the main layout (the 4x3 month grid)
+ */
+ renderSubCalendars() {
+ this.el.classList.add('fc-scroller');
+ if (!this.context.options.selectable) {
+ this.el.classList.add('fc-readonly-year-view');
+ }
+ this.months = [];
+ for (let monthNumber = 0; monthNumber < 12; monthNumber++) {
+ const monthDate = new Date(this.currentDate.getFullYear(), monthNumber);
+ const monthShortName = moment(monthDate).format('MMM').toLowerCase();
+ const container = createElement('div', { class: 'fc-month-container' });
+ this.el.appendChild(container);
+ const el = createElement('div', {
+ class: `fc-month fc-month-${monthShortName}`,
+ });
+ container.appendChild(el);
+ const calendar = this._createMonthCalendar(el, monthDate);
+ this.months.push({ el, calendar });
+ calendar.render();
+ }
+ }
+ /**
+ * Removes the main layout (the 4x3 month grid).
+ * Called when view is switched/destroyed.
+ */
+ unrenderSubCalendars() {
+ for (const { el, calendar } of this.months) {
+ calendar.destroy();
+ el.remove();
+ }
+ }
+ /**
+ * Renders events in sub calendars.
+ * Called every time event source changed (when changing the date,
+ * when changing filters, adding/removing filters).
+ */
+ renderEvents() {
+ // `renderDates` also renders events so if it's called just before
+ // then do not execute this as it will do a re-render.
+ if (this.datesRendered) {
+ this.datesRendered = false;
+ return;
+ }
+ this.events = this._computeEvents();
+ for (const { calendar } of this.months) {
+ calendar.refetchEvents();
+ }
+ this._setCursorOnEventDates();
+ }
+ /**
+ * Renders dates and events in sub calendars.
+ * Called when the year of the date changed to render a new
+ * 4*3 grid of month calendar based on the new year.
+ */
+ renderDates() {
+ this.events = this._computeEvents();
+ for (const [monthNumber, { calendar }] of Object.entries(this.months)) {
+ const monthDate = new Date(this.currentDate.getFullYear(), monthNumber);
+ calendar.gotoDate(monthDate);
+ }
+ this._setCursorOnEventDates();
+ this.datesRendered = true;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _computeEvents() {
+ const calendar = this.context.calendar;
+ return calendar.getEvents().map(event => {
+ const endUTC = calendar.dateEnv.toDate(event._instance.range.end);
+ const end = new Date(event._instance.range.end);
+ if (endUTC.getHours() > 0 || endUTC.getMinutes() > 0 ||
+ endUTC.getSeconds() > 0 || endUTC.getMilliseconds() > 0) {
+ end.setDate(end.getDate() + 1);
+ }
+ // clone event data to not trigger rerendering and issues
+ const instance = Object.assign({}, event._instance, {
+ range: { start: new Date(event._instance.range.start), end },
+ });
+ const def = Object.assign({}, event._def, {
+ rendering: 'background',
+ allDay: true,
+ });
+ return new EventApi(this.context.calendar, def, instance);
+ });
+ }
+ /**
+ * Create a month calendar for the date `monthDate` and mount it on container.
+ *
+ * @private
+ * @param {HTMLElement} container
+ * @param {Date} monthDate
+ */
+ _createMonthCalendar(container, monthDate) {
+ return new Calendar(container, Object.assign({}, this.context.options, {
+ defaultDate: monthDate,
+ defaultView: 'dayGridMonth',
+ header: { left: false, center: 'title', right: false },
+ titleFormat: { month: 'short', year: 'numeric' },
+ height: 0,
+ contentHeight: 0,
+ weekNumbers: false,
+ showNonCurrentDates: false,
+ views: {
+ dayGridMonth: {
+ columnHeaderText: (date) => moment(date).format("ddd")[0],
+ },
+ },
+ selectMinDistance: 5, // needed to not trigger select when click
+ dateClick: this._onYearDateClick.bind(this),
+ datesRender: undefined,
+ events: (info, successCB) => {
+ successCB(this.events);
+ },
+ windowResize: undefined,
+ }));
+ }
+ /**
+ * Sets fc-has-event class on every dates that have at least one event.
+ *
+ * @private
+ */
+ _setCursorOnEventDates() {
+ for (const el of this.el.querySelectorAll('.fc-has-event')) {
+ el.classList.remove('fc-has-event');
+ }
+ for (const event of Object.values(this.events)) {
+ let currentDate = moment(event._instance.range.start);
+ while (currentDate.isBefore(event._instance.range.end, 'day')) {
+ const formattedDate = currentDate.format('YYYY-MM-DD');
+ const el = this.el.querySelector(`.fc-day-top[data-date="${formattedDate}"]`);
+ if (el) {
+ el.classList.add('fc-has-event');
+ }
+ currentDate.add(1, 'days');
+ }
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {*} info
+ */
+ _onYearDateClick(info) {
+ const calendar = this.context.calendar;
+ const events = Object.values(this.events)
+ .filter(event => {
+ const startUTC = calendar.dateEnv.toDate(event._instance.range.start);
+ const endUTC = calendar.dateEnv.toDate(event._instance.range.end);
+ const start = moment(startUTC);
+ const end = moment(endUTC);
+ const inclusivity = start.isSame(end, 'day') ? '[]' : '[)';
+ return moment(info.date).isBetween(start, end, 'day', inclusivity);
+ })
+ .map(event => {
+ return Object.assign({}, event._def, event._instance.range);
+ });
+ const yearDateInfo = Object.assign({}, info, {
+ view: this,
+ monthView: info.view,
+ events,
+ selectable: this.context.options.selectable,
+ });
+ calendar.publiclyTrigger('yearDateClick', [yearDateInfo]);
+ }
+ }
+
+ return FullCalendar.createPlugin({
+ views: {
+ dayGridYear: {
+ class: YearView,
+ duration: { years: 1 },
+ defaults: {
+ fixedWeekCount: true,
+ },
+ },
+ },
+ });
+ }
+
+ return {
+ createYearCalendarView,
+ };
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
index cddd25ee56a..2483199fcf0 100644
--- a/addons/web/static/src/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -44,6 +44,7 @@ var CalendarController = AbstractController.extend({
'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
@@ -277,7 +278,7 @@ var CalendarController = AbstractController.extend({
*/
_onOpenCreate: function (event) {
var self = this;
- if (this.model.get().scale === "month") {
+ if (["year", "month"].includes(this.model.get().scale)) {
event.data.allDay = true;
}
var data = this.model.calendarEventToRecord(event.data);
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
index 530f4775aa1..3cca3f3452d 100644
--- a/addons/web/static/src/js/views/calendar/calendar_model.js
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -12,7 +12,8 @@ var _t = core._t;
var scales = [
'day',
'week',
- 'month'
+ 'month',
+ 'year',
];
function dateToServer (date) {
@@ -273,6 +274,19 @@ return AbstractModel.extend({
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');
@@ -426,7 +440,12 @@ return AbstractModel.extend({
hour12: false,
};
return {
- defaultView: (this.mode === "month")? "dayGridMonth" : ((this.mode === "week")? "timeGridWeek" : ((this.mode === "day")? "timeGridDay" : "timeGridWeek")),
+ defaultView: {
+ 'year': 'dayGridYear',
+ 'month': 'dayGridMonth',
+ 'week': 'timeGridWeek',
+ 'day': 'timeGridDay',
+ }[this.mode || 'week'],
header: false,
selectable: this.creatable && this.create_right,
selectMirror: true,
diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js
index 8a2b89d6287..2df67e8892f 100644
--- a/addons/web/static/src/js/views/calendar/calendar_renderer.js
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -3,7 +3,6 @@ odoo.define('web.CalendarRenderer', function (require) {
var AbstractRenderer = require('web.AbstractRenderer');
var CalendarPopover = require('web.CalendarPopover');
-var config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
var field_utils = require('web.field_utils');
@@ -11,6 +10,7 @@ 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;
@@ -18,7 +18,8 @@ var qweb = core.qweb;
var scales = {
day: 'timeGridDay',
week: 'timeGridWeek',
- month: 'dayGridMonth'
+ month: 'dayGridMonth',
+ year: 'dayGridYear',
};
var SidebarFilterM2O = relational_fields.FieldMany2One.extend({
@@ -192,6 +193,7 @@ return AbstractRenderer.extend({
this.hideDate = params.hideDate;
this.hideTime = params.hideTime;
this.canDelete = params.canDelete;
+ this.canCreate = params.canCreate;
this._isInDOM = false;
},
/**
@@ -386,7 +388,7 @@ return AbstractRenderer.extend({
*/
_getFullCalendarOptions: function (fcOptions) {
var self = this;
- return Object.assign({}, this.state.fc_options, {
+ const options = Object.assign({}, this.state.fc_options, {
plugins: [
'moment',
'interaction',
@@ -408,27 +410,60 @@ return AbstractRenderer.extend({
$(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 {
- 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));
+ self.calendar.unselect();
}
- 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'));
- element.attr('data-event-id', event.id);
// Add background if doesn't exist
if (!element.find('.fc-bg').length) {
@@ -455,10 +490,17 @@ return AbstractRenderer.extend({
element.on('dblclick', function () {
self.trigger_up('edit_event', {id: event.id});
});
+ }
},
datesRender: function (info) {
- // compute mode from view.type which is either 'dayGridMonth', 'timeGridWeek' or 'timeGridDay'
- var mode = info.view.type === 'dayGridMonth' ? 'month' : (info.view.type === 'timeGridWeek' ? 'week' : 'day');
+ // compute mode from view.type which is either
+ // 'dayGridYear', 'dayGridMonth', 'timeGridWeek' or 'timeGridDay'
+ const mode = {
+ dayGridYear: 'year',
+ dayGridMonth: 'month',
+ timeGridWeek: 'week',
+ timeGridDay: 'day',
+ }[info.view.type];
self.trigger_up('viewUpdated', {
mode: mode,
title: info.view.title,
@@ -509,6 +551,8 @@ return AbstractRenderer.extend({
successCB(self.state.data);
},
}, fcOptions);
+ options.plugins.push(createYearCalendarView(FullCalendar, options));
+ return options;
},
/**
* Initialize the main calendar
@@ -582,6 +626,7 @@ return AbstractRenderer.extend({
.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;
@@ -706,6 +751,26 @@ return AbstractRenderer.extend({
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
+ */
+ _getFormattedDate: function (start, end, showDayName) {
+ const isSameDayEvent = start.clone().add(1, 'minute')
+ .isSame(end.clone().subtract(1, 'minute'), '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
@@ -748,12 +813,7 @@ return AbstractRenderer.extend({
}
if (!this.hideDate) {
- if (!isSameDayEvent && start.isSame(end, 'month')) {
- // Simplify date-range if an event occurs into the same month (eg. '4-5 August 2019')
- context.eventDate.date = start.clone().format('MMMM D') + '-' + end.clone().format('D, YYYY');
- } else {
- context.eventDate.date = isSameDayEvent ? start.clone().format('dddd, LL') : start.clone().format('LL') + ' - ' + end.clone().format('LL');
- }
+ context.eventDate.date = this._getFormattedDate(start, end, true);
if (eventData.extendedProps.record.allday && isSameDayEvent) {
context.eventDate.duration = _t("All day");
@@ -808,6 +868,89 @@ return AbstractRenderer.extend({
});
},
/**
+ * 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);
+ 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
*/
diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js
index 592fffbafee..7a9073adf6e 100644
--- a/addons/web/static/src/js/views/calendar/calendar_view.js
+++ b/addons/web/static/src/js/views/calendar/calendar_view.js
@@ -162,6 +162,7 @@ var CalendarView = AbstractView.extend({
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.loadParams.fieldNames = _.uniq(fieldNames);
this.loadParams.mapping = mapping;
diff --git a/addons/web/static/src/scss/web_calendar.scss b/addons/web/static/src/scss/web_calendar.scss
index cb98cc47129..b3ab7d7bf6f 100644
--- a/addons/web/static/src/scss/web_calendar.scss
+++ b/addons/web/static/src/scss/web_calendar.scss
@@ -63,6 +63,10 @@ $o-cw-filter-avatar-size: 20px;
.o_calendar_widget {
height: 100%;
+ > .fc-view-container {
+ height: 100%;
+ }
+
// === Adapt calendar table borders ===
// =====================================
@@ -317,6 +321,100 @@ $o-cw-filter-avatar-size: 20px;
}
}
}
+ // ====== Year only
+ .fc-dayGridYear-view {
+ border: none;
+ height: 100%;
+ padding-top: 1rem;
+ padding-left: $o-horizontal-padding;
+ box-sizing: border-box;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-evenly;
+ overflow-y: auto;
+
+ > table {
+ height: 100%;
+ }
+
+ table, tr, th, td {
+ border: none;
+ }
+
+ &.fc-readonly-year-view {
+ .fc-day-top:not(.fc-has-event) {
+ cursor: default !important;
+ }
+ }
+
+ &:not(.fc-readonly-year-view) {
+ .fc-day-top:hover > .fc-day-number {
+ font-weight: bold;
+ border-radius: 100%;
+ text-align: center;
+ background-color: rgba(#87c0d1, 0.5);
+ color: gray('900');
+ }
+ }
+
+ > .fc-month-container {
+ width: 25%;
+ min-width: 25rem;
+ box-sizing: border-box;
+
+ > .fc-month {
+ width: 21rem;
+ margin: auto;
+
+ > .fc-toolbar.fc-header-toolbar {
+ margin-top: 15px;
+ margin-bottom: 5px;
+ cursor: default;
+
+ h2 {
+ font-size: 1.2rem;
+ color: gray('600');
+ }
+ }
+
+ .fc-widget-header {
+ padding: 2px 0;
+ cursor: default;
+ }
+
+ .fc-dayGridMonth-view {
+ padding-left: unset;
+ }
+
+ .fc-week.fc-row {
+ min-height: unset;
+ }
+
+ .fc-disabled-day {
+ background-color: unset;
+ }
+
+ .fc-day-top {
+ text-align: center;
+ padding: 5px;
+ cursor: pointer;
+
+ > .fc-day-number {
+ display: block;
+ float: unset;
+ line-height: unset;
+ margin: auto;
+ padding: 0.1rem 0;
+ font-size: 1.2rem;
+ }
+
+ &.fc-today > .fc-day-number {
+ font-weight: bold;
+ }
+ }
+ }
+ }
+ }
// ====== RTL layout(s)
&.fc-rtl {
.fc-timeGrid-view .fc-event {
diff --git a/addons/web/static/src/xml/web_calendar.xml b/addons/web/static/src/xml/web_calendar.xml
index a5e2924ed92..95ea6927a91 100644
--- a/addons/web/static/src/xml/web_calendar.xml
+++ b/addons/web/static/src/xml/web_calendar.xml
@@ -113,6 +113,7 @@
<button type="button" class="o_calendar_button_day btn btn-secondary">Day</button>
<button type="button" class="o_calendar_button_week btn btn-secondary">Week</button>
<button type="button" class="o_calendar_button_month btn btn-secondary">Month</button>
+ <button type="button" class="o_calendar_button_year btn btn-secondary">Year</button>
</div>
</t>
@@ -122,4 +123,35 @@
<input type='text' name='name' class="o_input o_required_modifier" t-att-value="widget.dataTemplate.name or None"/>
</div>
</main>
+
+ <t t-name="CalendarView.yearEvent.popover">
+ <div class="o_cw_body">
+ <t t-foreach="groupKeys" t-as="groupKey">
+ <div class="font-weight-bold mt-2" t-esc="groupKey"/>
+ <t t-foreach="groupedEvents[groupKey]" t-as="event">
+ <t t-set="record" t-value="event.extendedProps.record"/>
+ <a href="#" class="o_cw_popover_link btn-link d-block"
+ t-att-data-id="record.id"
+ t-att-data-title="record.display_name"
+ t-esc="record.display_name"/>
+ </t>
+ </t>
+ <div class="mt-2">
+ <a t-if="canCreate" href="#" class="btn-link o_cw_popover_create">
+ <i class="fa fa-plus"/> Create
+ </a>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="CalendarView.yearEvent.popover.placeholder">
+ <div class="popover o_cw_popover" style="position: relative">
+ <div class="arrow"></div>
+ <div class="popover-header"></div>
+ <div style="position: absolute; top: 0; right: 0.5rem;">
+ <span class="o_cw_popover_close"><i class="fa fa-close small"></i></span>
+ </div>
+ <div class="popover-body"></div>
+ </div>
+ </t>
</template>
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index ba8c1f35dfd..a5db6a3f799 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -63,7 +63,8 @@ QUnit.module('Views', {
{id: 3, user_id: 4, partner_id: 4, name: "event 3", start: "2016-12-12 15:55:05", stop: "2016-12-12 16:55:05", allday: false, partner_ids: [1], type: 2},
{id: 4, user_id: session.uid, partner_id: 1, name: "event 4", start: "2016-12-14 15:55:05", stop: "2016-12-14 18:55:05", allday: true, partner_ids: [1], type: 2},
{id: 5, user_id: 4, partner_id: 4, name: "event 5", start: "2016-12-13 15:55:05", stop: "2016-12-20 18:55:05", allday: false, partner_ids: [2,3], type: 2},
- {id: 6, user_id: session.uid, partner_id: 1, name: "event 6", start: "2016-12-18 08:00:00", stop: "2016-12-18 09:00:00", allday: false, partner_ids: [3], type: 3}
+ {id: 6, user_id: session.uid, partner_id: 1, name: "event 6", start: "2016-12-18 08:00:00", stop: "2016-12-18 09:00:00", allday: false, partner_ids: [3], type: 3},
+ {id: 7, user_id: session.uid, partner_id: 1, name: "event 7", start: "2016-11-14 08:00:00", stop: "2016-11-16 17:00:00", allday: false, partner_ids: [2], type: 1},
],
check_access_rights: function () {
return Promise.resolve(true);
@@ -280,7 +281,7 @@ QUnit.module('Views', {
});
QUnit.test('breadcrumbs are updated with the displayed period', async function (assert) {
- assert.expect(3);
+ assert.expect(4);
var archs = {
'event,1,calendar': '<calendar date_start="start" date_stop="stop" all_day="allday"/>',
@@ -321,6 +322,11 @@ QUnit.module('Views', {
assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
'Meetings Test (December 2016)', "should display the current month");
+ // switch to year mode
+ await testUtils.dom.click($('.o_control_panel .o_calendar_button_year'));
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(),
+ 'Meetings Test (2016)', "should display the current year");
+
actionManager.destroy();
});
@@ -3518,6 +3524,170 @@ QUnit.module('Views', {
calendar.destroy();
});
+ QUnit.test('correctly display year view', async function (assert) {
+ assert.expect(27);
+
+ const calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: `
+ <calendar
+ create="false"
+ event_open_popup="true"
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ mode="year"
+ attendee="partner_ids"
+ color="partner_id"
+ >
+ <field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>
+ <field name="partner_id" filters="1" invisible="1"/>
+ </calendar>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ }, {positionalClicks: true});
+
+ // Check view
+ assert.containsN(calendar, '.fc-month', 12);
+ assert.strictEqual(
+ calendar.el.querySelector('.fc-month:first-child .fc-header-toolbar').textContent,
+ 'Jan 2016'
+ );
+ assert.containsN(calendar.el, '.fc-bgevent', 7,
+ 'There should be 6 events displayed but there is 1 split on 2 weeks');
+
+ async function clickDate(date) {
+ const el = calendar.el.querySelector(`.fc-day-top[data-date="${date}"]`);
+ el.scrollIntoView(); // scroll to it as the calendar could be too small
+
+ testUtils.dom.triggerMouseEvent(el, "mousedown");
+ testUtils.dom.triggerMouseEvent(el, "mouseup");
+
+ await testUtils.nextTick();
+ }
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-11-17"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-17');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-11-16"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-16');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ let popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText, 'November 14-16, 2016 event 7');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-11-14"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-14');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText, 'November 14-16, 2016 event 7');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-11-13"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-11-13');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-12-10"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-10');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-12-12"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-12');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText, 'December 12, 2016 event 2 event 3');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.ok(calendar.el.querySelector('.fc-day-top[data-date="2016-12-14"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-14');
+ assert.containsOnce(calendar, '.o_cw_popover');
+ popoverText = calendar.el.querySelector('.o_cw_popover')
+ .textContent.replace(/\s{2,}/g, ' ').trim();
+ assert.strictEqual(popoverText,
+ 'December 14, 2016 event 4 December 13-20, 2016 event 5');
+ await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ assert.notOk(calendar.el.querySelector('.fc-day-top[data-date="2016-12-21"]')
+ .classList.contains('fc-has-event'));
+ await clickDate('2016-12-21');
+ assert.containsNone(calendar, '.o_cw_popover');
+
+ calendar.destroy();
+ });
+
+ QUnit.test('toggle filters in year view', async function (assert) {
+ assert.expect(42);
+
+ const calendar = await createCalendarView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: `
+ <calendar
+ event_open_popup="true"
+ date_start="start"
+ date_stop="stop"
+ all_day="allday"
+ mode="year"
+ attendee="partner_ids"
+ color="partner_id"
+ >
+ <field name="partner_ids" write_model="filter_partner" write_field="partner_id"/>
+ <field name="partner_id" filters="1" invisible="1"/>
+ '</calendar>`,
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ });
+
+ function checkEvents(countMap) {
+ for (const [id, count] of Object.entries(countMap)) {
+ assert.containsN(calendar, `.fc-bgevent[data-event-id="${id}"]`, count);
+ }
+ }
+
+ checkEvents({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 2, 7: 1, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="2"] label'));
+ checkEvents({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="1"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 1, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="4"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="1"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_attendees .o_calendar_filter_item[data-value="2"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 7: 0, });
+ await testUtils.dom.click(calendar.el.querySelector(
+ '#o_cw_filter_collapse_user .o_calendar_filter_item[data-value="4"] label'));
+ checkEvents({ 1: 0, 2: 0, 3: 0, 4: 0, 5: 2, 7: 0, });
+
+ calendar.destroy();
+ });
});
});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index c0812ace9da..deb37ef3f3c 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -129,6 +129,7 @@
<script type="text/javascript" src="/web/static/src/js/libs/download.js"></script>
<script type="text/javascript" src="/web/static/src/js/libs/content-disposition.js"></script>
<script type="text/javascript" src="/web/static/src/js/libs/underscore.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/libs/fullcalendar.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/abstract_service.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/abstract_storage_service.js"></script>