diff options
| author | Michael Mattiello (mcm) <mcm@odoo.com> | 2020-07-31 13:56:45 +0000 |
|---|---|---|
| committer | Michael Mattiello (mcm) <mcm@odoo.com> | 2020-08-11 12:32:54 +0000 |
| commit | 441d9f65b3b90c37ce812d2addbe5b62d2d88229 (patch) | |
| tree | 1a66c5fdaa27a2d0f5b6369299d334cf66426951 /addons/web | |
| parent | 9a53b032395aba59cee9a2e5660ebf8d6136b978 (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.js | 252 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/calendar/calendar_controller.js | 3 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/calendar/calendar_model.js | 23 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/calendar/calendar_renderer.js | 179 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/calendar/calendar_view.js | 1 | ||||
| -rw-r--r-- | addons/web/static/src/scss/web_calendar.scss | 98 | ||||
| -rw-r--r-- | addons/web/static/src/xml/web_calendar.xml | 32 | ||||
| -rw-r--r-- | addons/web/static/tests/views/calendar_tests.js | 174 | ||||
| -rw-r--r-- | addons/web/views/webclient_templates.xml | 1 |
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> |
