From e68071a073bbd622f519ba966652fb446755ce73 Mon Sep 17 00:00:00 2001 From: nathangray Date: Thu, 27 Feb 2020 11:44:28 -0700 Subject: [PATCH] WIP Calendar to typescript --- calendar/js/View.ts | 432 +++++ calendar/js/et2_widget_daycol.ts | 1221 ++++++++++++ calendar/js/et2_widget_event.ts | 1317 +++++++++++++ calendar/js/et2_widget_owner.ts | 142 ++ calendar/js/et2_widget_planner.ts | 2500 +++++++++++++++++++++++++ calendar/js/et2_widget_planner_row.ts | 839 +++++++++ calendar/js/et2_widget_timegrid.ts | 2389 +++++++++++++++++++++++ calendar/js/et2_widget_view.ts | 700 +++++++ 8 files changed, 9540 insertions(+) create mode 100644 calendar/js/View.ts create mode 100644 calendar/js/et2_widget_daycol.ts create mode 100644 calendar/js/et2_widget_event.ts create mode 100644 calendar/js/et2_widget_owner.ts create mode 100644 calendar/js/et2_widget_planner.ts create mode 100644 calendar/js/et2_widget_planner_row.ts create mode 100644 calendar/js/et2_widget_timegrid.ts create mode 100644 calendar/js/et2_widget_view.ts diff --git a/calendar/js/View.ts b/calendar/js/View.ts new file mode 100644 index 0000000000..6502defdbe --- /dev/null +++ b/calendar/js/View.ts @@ -0,0 +1,432 @@ +/** + * Super class for the different views. + * + * Each separate view overrides what it needs + */ +import {etemplate2} from "../../api/js/etemplate/etemplate2"; + +export abstract class View +{ + // List of etemplates to show for this view + public static etemplates : (string | etemplate2)[] = ['calendar.view']; + + /** + * Translated label for header + * @param {Object} state + * @returns {string} + */ + static header(state) + { + let formatDate = new Date(state.date); + formatDate = new Date(formatDate.valueOf() + formatDate.getTimezoneOffset() * 60 * 1000); + return View._owner(state) + date(egw.preference('dateformat'), formatDate); + } + + /** + * If one owner, get the owner text + * + * @param {object} state + */ + static _owner(state) + { + let owner = ''; + if(state.owner.length && state.owner.length == 1 && app.calendar.sidebox_et2) + { + var own = app.calendar.sidebox_et2.getWidgetById('owner').getDOMNode(); + if(own.selectedIndex >= 0) + { + owner = own.options[own.selectedIndex].innerHTML + ": "; + } + } + return owner; + } + + /** + * Get the start date for this view + * @param {Object} state + * @returns {Date} + */ + static start_date(state) + { + const d = state.date ? new Date(state.date) : new Date(); + d.setUTCHours(0); + d.setUTCMinutes(0); + d.setUTCSeconds(0); + d.setUTCMilliseconds(0); + return d; + } + + /** + * Get the end date for this view + * @param {Object} state + * @returns {Date} + */ + static end_date(state) + { + const d = state.date ? new Date(state.date) : new Date(); + d.setUTCHours(23); + d.setUTCMinutes(59); + d.setUTCSeconds(59); + d.setUTCMilliseconds(0); + return d; + } + + /** + * Get the owner for this view + * + * This is always the owner from the given state, we use a function + * to trigger setting the widget value. + * + * @param {number[]|String} state state.owner List of owner IDs, or a comma seperated list + * @returns {number[]|String} + */ + static owner(state) + { + return state.owner || 0; + } + + /** + * Should the view show the weekends + * + * @param {object} state + * @returns {boolean} Current preference to show 5 or 7 days in weekview + */ + static show_weekend(state) + { + return state.weekend; + } + + /** + * How big or small are the displayed time chunks? + * + * @param {object} state + */ + static granularity(state) + { + var list = egw.preference('use_time_grid', 'calendar'); + if(list == '0' || typeof list === 'undefined') + { + return parseInt('' + egw.preference('interval', 'calendar')) || 30; + } + if(typeof list == 'string') list = list.split(','); + if(!(list).indexOf && jQuery.isPlainObject(list)) + { + list = jQuery.map(list, function (el) + { + return el; + }); + } + return list.indexOf(state.view) >= 0 ? + 0 : + parseInt(egw.preference('interval', 'calendar')) || 30; + } + + static extend(sub) + { + return jQuery.extend({}, this, {_super: this}, sub); + } + + /** + * Determines the new date after scrolling. The default is 1 week. + * + * @param {number} delta Integer for how many 'ticks' to move, positive for + * forward, negative for backward + * @returns {Date} + */ + static scroll(delta) + { + var d = new Date(app.calendar.state.date); + d.setUTCDate(d.getUTCDate() + (7 * delta)); + return d; + } +} + +/** + * Etemplates and settings for the different views. Some (day view) + * use more than one template, some use the same template as others, + * most need different handling for their various attributes. + */ + +export class day extends View +{ + public static etemplates : (string | etemplate2)[] = ['calendar.view', 'calendar.todo']; + + static header(state) + { + var formatDate = new Date(state.date); + formatDate = new Date(formatDate.valueOf() + formatDate.getTimezoneOffset() * 60 * 1000); + return date('l, ', formatDate) + super.header(state); + } + + static start_date(state) + { + var d = super.start_date(state); + state.date = app.calendar.date.toString(d); + return d; + } + + static show_weekend(state) + { + state.days = '1'; + return true; + } + + static scroll(delta) + { + var d = new Date(app.calendar.state.date); + d.setUTCDate(d.getUTCDate() + (delta)); + return d; + } +} + +export class day4 extends View +{ + static end_date(state) + { + var d = super.end_date(state); + state.days = '4'; + d.setUTCHours(24 * 4 - 1); + d.setUTCMinutes(59); + d.setUTCSeconds(59); + d.setUTCMilliseconds(0); + return d; + } + + static show_weekend(state) + { + state.weekend = 'true'; + return true; + } + + static scroll(delta) + { + var d = new Date(app.calendar.state.date); + d.setUTCDate(d.getUTCDate() + (4 * delta)); + return d; + } +} + +export class week extends View +{ + static header(state) + { + var end_date = state.last; + if(!week.show_weekend(state)) + { + end_date = new Date(state.last); + end_date.setUTCDate(end_date.getUTCDate() - 2); + } + return super._owner(state) + app.calendar.egw.lang('Week') + ' ' + + app.calendar.date.week_number(state.first) + ': ' + + app.calendar.date.long_date(state.first, end_date); + } + + static start_date(state) + { + return app.calendar.date.start_of_week(super.start_date(state)); + } + + static end_date(state) + { + var d = app.calendar.date.start_of_week(state.date || new Date()); + // Always 7 days, we just turn weekends on or off + d.setUTCHours(24 * 7 - 1); + d.setUTCMinutes(59); + d.setUTCSeconds(59); + d.setUTCMilliseconds(0); + return d; + } +} + +export class weekN extends View +{ + static header(state) + { + return super._owner(state) + app.calendar.egw.lang('Week') + ' ' + + app.calendar.date.week_number(state.first) + ' - ' + + app.calendar.date.week_number(state.last) + ': ' + + app.calendar.date.long_date(state.first, state.last); + } + + static start_date(state) + { + return app.calendar.date.start_of_week(super.start_date(state)); + } + + static end_date(state) + { + state.days = '' + (state.days >= 5 ? state.days : egw.preference('days_in_weekview', 'calendar') || 7); + + var d = app.calendar.date.start_of_week(app.calendar.View.start_date.call(this, state)); + // Always 7 days, we just turn weekends on or off + d.setUTCHours(24 * 7 * (parseInt(this.egw.preference('multiple_weeks', 'calendar')) || 3) - 1); + return d; + } +} + +export class month extends View +{ + static header(state) + { + var formatDate = new Date(state.date); + formatDate = new Date(formatDate.valueOf() + formatDate.getTimezoneOffset() * 60 * 1000); + return super._owner(state) + app.calendar.egw.lang(date('F', formatDate)) + ' ' + date('Y', formatDate); + } + + static start_date(state) + { + var d = super.start_date(state); + d.setUTCDate(1); + return app.calendar.date.start_of_week(d); + } + + static end_date(state) + { + var d = super.end_date(state); + d = new Date(d.getFullYear(), d.getUTCMonth() + 1, 1, 0, -d.getTimezoneOffset(), 0); + d.setUTCSeconds(d.getUTCSeconds() - 1); + return app.calendar.date.end_of_week(d); + } + + static scroll(delta) + { + var d = new Date(app.calendar.state.date); + // Set day to 15 so we don't get overflow on short months + // eg. Aug 31 + 1 month = Sept 31 -> Oct 1 + d.setUTCDate(15); + d.setUTCMonth(d.getUTCMonth() + delta); + return d; + } +} + +export class planner extends View +{ + public static etemplates : (string | etemplate2)[] = ['calendar.planner']; + + static header(state) + { + var startDate = new Date(state.first); + startDate = new Date(startDate.valueOf() + startDate.getTimezoneOffset() * 60 * 1000); + + var endDate = new Date(state.last); + endDate = new Date(endDate.valueOf() + endDate.getTimezoneOffset() * 60 * 1000); + return super._owner(state) + date(egw.preference('dateformat'), startDate) + + (startDate == endDate ? '' : ' - ' + date(egw.preference('dateformat'), endDate)); + } + + static group_by(state) + { + return state.sortby ? state.sortby : 0; + } + + // Note: Planner uses the additional value of planner_view to determine + // the start & end dates using other view's functions + static start_date(state) + { + // Start here, in case we can't find anything better + var d = super.start_date( state); + + if(state.sortby && state.sortby === 'month') + { + d.setUTCDate(1); + } + else if(state.planner_view && app.classes.calendar.views[state.planner_view]) + { + d = app.classes.calendar.views[state.planner_view].start_date.call(this, state); + } + else + { + d = app.calendar.date.start_of_week(d); + d.setUTCHours(0); + d.setUTCMinutes(0); + d.setUTCSeconds(0); + d.setUTCMilliseconds(0); + return d; + } + return d; + } + + static end_date(state) + { + + var d = super.end_date( state); + if(state.sortby && state.sortby === 'month') + { + d.setUTCDate(0); + d.setUTCFullYear(d.getUTCFullYear() + 1); + } + else if(state.planner_view && app.classes.calendar.views[state.planner_view]) + { + d = app.classes.calendar.views[state.planner_view].end_date(state); + } + else if(state.days) + { + // This one comes from a grid view, but we'll use it + d.setUTCDate(d.getUTCDate() + parseInt(state.days) - 1); + delete state.days; + } + else + { + d = app.calendar.date.end_of_week(d); + } + return d; + } + + static hide_empty(state) + { + var check = state.sortby == 'user' ? ['user', 'both'] : ['cat', 'both']; + return (check.indexOf(egw.preference('planner_show_empty_rows', 'calendar') + '') === -1); + } + + static scroll(delta) + { + if(app.calendar.state.planner_view) + { + return app.classes.calendar.views[app.calendar.state.planner_view].scroll(delta); + } + var d = new Date(app.calendar.state.date); + var days = 1; + + // Yearly view, grouped by month - scroll 1 month + if(app.calendar.state.sortby === 'month') + { + d.setUTCMonth(d.getUTCMonth() + delta); + d.setUTCDate(1); + d.setUTCHours(0); + d.setUTCMinutes(0); + return d; + } + // Need to set the day count, or auto date ranging takes over and + // makes things buggy + if(app.calendar.state.first && app.calendar.state.last) + { + var diff = new Date(app.calendar.state.last) - new Date(app.calendar.state.first); + days = Math.round(diff / (1000 * 3600 * 24)); + } + d.setUTCDate(d.getUTCDate() + (days * delta)); + if(days > 8) + { + d = app.calendar.date.start_of_week(d); + } + return d; + } +} + +export class listview extends View +{ + public static etemplates : (string | etemplate2)[] = ['calendar.list']; + + static header(state) + { + var startDate = new Date(state.first || state.date); + startDate = new Date(startDate.valueOf() + startDate.getTimezoneOffset() * 60 * 1000); + var start_check = '' + startDate.getFullYear() + startDate.getMonth() + startDate.getDate(); + + var endDate = new Date(state.last || state.date); + endDate = new Date(endDate.valueOf() + endDate.getTimezoneOffset() * 60 * 1000); + var end_check = '' + endDate.getFullYear() + endDate.getMonth() + endDate.getDate(); + return super._owner(state) + + date(egw.preference('dateformat'), startDate) + + (start_check == end_check ? '' : ' - ' + date(egw.preference('dateformat'), endDate)); + } +} diff --git a/calendar/js/et2_widget_daycol.ts b/calendar/js/et2_widget_daycol.ts new file mode 100644 index 0000000000..5ea852bdde --- /dev/null +++ b/calendar/js/et2_widget_daycol.ts @@ -0,0 +1,1221 @@ +/* + * Egroupware + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package etemplate + * @subpackage api + * @link http://www.egroupware.org + * @author Nathan Gray + * @version $Id$ + */ + + +/*egw:uses + et2_core_valueWidget; + /calendar/js/et2_widget_event.js; +*/ + +import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget"; +import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget"; +import {et2_calendar_timegrid} from "./et2_widget_timegrid"; +import {et2_calendar_view} from "./et2_widget_view"; +import {et2_calendar_event} from "./et2_widget_event"; +import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance"; +import {et2_date} from "../../api/js/etemplate/et2_widget_date"; + +/** + * Class which implements the "calendar-timegrid" XET-Tag for displaying a single days + * + * This widget is responsible mostly for positioning its events + * + */ +export class et2_calendar_daycol extends et2_valueWidget implements et2_IDetachedDOM, et2_IResizeable +{ + + static readonly _attributes: any = { + date: { + name: "Date", + type: "any", + description: "What date is this daycol for. YYYYMMDD or Date", + default: et2_no_init + }, + owner: { + name: "Owner", + type: "any", // Integer, string, or array of either + default: et2_no_init, + description: "Account ID number of the calendar owner, if not the current user" + }, + display_birthday_as_event: { + name: "Birthdays", + type: "boolean", + default: false, + description: "Display birthdays as events" + }, + display_holiday_as_event: { + name: "Holidays", + type: "boolean", + default: false, + description: "Display holidays as events" + } + }; + private div: JQuery; + private header: JQuery; + private title: JQuery; + private event_wrapper: JQuery; + private user_spacer: JQuery; + private all_day: JQuery; + + private _date_helper : et2_date; + private registeredUID: string = null; + + // Init to defaults, just in case - they will be updated from parent + private display_settings: any = { + wd_start: 60*9, + wd_end: 60*17, + granularity: 30, + rowsToDisplay: 10, + rowHeight: 20, + // Percentage; not yet available + titleHeight: 2.0 + }; + private date: Date; + private class: string; + + /** + * Constructor + */ + constructor(_parent, _attrs? : WidgetConfig, _child? : object) + { + // Call the inherited constructor + super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_daycol._attributes, _child || {})); + + // Main container + this.div = jQuery(document.createElement("div")) + .addClass("calendar_calDayCol") + .css('width',this.options.width) + .css('left', this.options.left); + this.header = jQuery(document.createElement('div')) + .addClass("calendar_calDayColHeader") + .css('width',this.options.width) + .css('left', this.options.left); + this.title = jQuery(document.createElement('div')) + .addClass('et2_clickable et2_link') + .appendTo(this.header); + this.user_spacer = jQuery(document.createElement('div')) + .addClass("calendar_calDayColHeader_spacer") + .appendTo(this.header); + this.all_day = jQuery(document.createElement('div')) + .addClass("calendar_calDayColAllDay") + .css('max-height', (egw.preference('limit_all_day_lines', 'calendar') || 3 ) * 1.4 + 'em') + .appendTo(this.header); + this.event_wrapper = jQuery(document.createElement('div')) + .addClass("event_wrapper") + .appendTo(this.div); + + this.setDOMNode(this.div[0]); + + // Used for its date calculations - note this is a datetime, parent + // uses just a date + this._date_helper = et2_createWidget('date-time', {}, null); + this._date_helper.loadingFinished(); + } + + doLoadingFinished( ) + { + let result = super.doLoadingFinished(); + + // Parent will have everything we need, just load it from there + + if(this.getParent() && this.getParent().options.owner) + { + this.set_owner(this.getParent().options.owner); + } + if(this.title.text() === '' && this.options.date && + this.getParent() && this.getParent().instanceOf(et2_calendar_timegrid)) + { + // Forces an update + const date = this.options.date; + this.options.date = ''; + this.set_date(date); + } + + return result; + } + + destroy( ) + { + super.destroy(); + this.div.off(); + this.header.off().remove(); + this.title.off(); + this.div = null; + this.header = null; + this.title = null; + this.user_spacer = null; + + // date_helper has no parent, so we must explicitly remove it + this._date_helper.destroy(); + this._date_helper = null; + + egw.dataUnregisterUID(this.registeredUID,null,this); + } + + getDOMNode(sender) + { + if(!sender || sender === this) return this.div[0]; + if(sender.instanceOf && sender.instanceOf(et2_calendar_event)) + { + if(this.display_settings.granularity === 0) + { + return this.event_wrapper[0]; + } + if(sender.options.value.whole_day_on_top || + sender.options.value.whole_day && sender.options.value.non_blocking === true) + { + return this.all_day[0]; + } + return this.div[0]; + } + } + + /** + * Draw the individual divs for clicking to add an event + */ + _draw( ) + { + // Remove any existing + jQuery('.calendar_calAddEvent',this.div).remove(); + + // Grab real values from parent + if(this.getParent() && this.getParent().instanceOf(et2_calendar_timegrid)) + { + this.display_settings.wd_start = 60*this.getParent().options.day_start; + this.display_settings.wd_end = 60*this.getParent().options.day_end; + this.display_settings.granularity = this.getParent().options.granularity; + const header = this.getParent().dayHeader.children(); + + // Figure out insert index + let idx = 0; + const siblings = this.getParent().getDOMNode(this).childNodes; + while(idx < siblings.length && siblings[idx] != this.getDOMNode()) + { + idx++; + } + // Stick header in the right place + if(idx == 0) + { + this.getParent().dayHeader.prepend(this.header); + } + else if(header.length) + { + header.eq(Math.min(header.length,idx)-1).after(this.header); + } + } + + this.div.attr('data-date', this.options.date); + } + + getDate() : Date + { + return this.date; + } + + get date_helper(): et2_date + { + return this._date_helper; + } + + /** + * Set the date + * + * @param {string|Date} _date New date + * @param {Object[]} events =false List of event data to be displayed, or false to + * automatically fetch data from content array + * @param {boolean} force_redraw =false Redraw even if the date is the same. + * Used for when new data is available. + */ + set_date(_date, events?, force_redraw?) + { + if(typeof events === 'undefined' || !events) + { + events = false; + } + if(typeof force_redraw === 'undefined' || !force_redraw) + { + force_redraw = false; + } + if(!this.getParent() || !this.getParent().date_helper) + { + egw.debug('warn', 'Day col widget "' + this.id + '" is missing its parent.'); + return false; + } + if(typeof _date === "object") + { + this.getParent().date_helper.set_value(_date); + } + else if(typeof _date === "string") + { + // Need a new date to avoid invalid month/date combinations when setting + // month then day. Use a string to avoid browser timezone. + this.getParent().date_helper.set_value(_date.substring(0,4)+'-'+(_date.substring(4,6))+'-'+_date.substring(6,8)+'T00:00:00Z'); + } + + this.date = new Date(this.getParent().date_helper.getValue()); + + // Keep internal option in Ymd format, it gets passed around in this format + const new_date = "" + this.getParent().date_helper.get_year() + + sprintf("%02d", this.getParent().date_helper.get_month()) + + sprintf("%02d", this.getParent().date_helper.get_date()); + + // Set label + if(!this.options.label) + { + // Add timezone offset back in, or formatDate will lose those hours + const formatDate = new Date(this.date.valueOf() + this.date.getTimezoneOffset() * 60 * 1000); + + this.title.html(''+jQuery.datepicker.formatDate('DD',formatDate)+ + ''+jQuery.datepicker.formatDate('D',formatDate)+''+ + jQuery.datepicker.formatDate('d',formatDate)); + } + this.title + .attr("data-date", new_date) + .toggleClass('et2_label', !!this.options.label); + this.header + .attr('data-date',new_date) + .attr('data-whole_day',true); + + // Avoid redrawing if date is the same + if(new_date === this.options.date && + this.display_settings.granularity === this.getParent().options.granularity && + !force_redraw + ) + { + return; + } + + const cache_id = CalendarApp._daywise_cache_id(new_date, this.options.owner); + if(this.options.date && this.registeredUID && + cache_id !== this.registeredUID) + { + egw.dataUnregisterUID(this.registeredUID,null,this); + + // Remove existing events + while(this._children.length > 0) + { + const node = this._children[this._children.length - 1]; + this.removeChild(node); + node.destroy(); + } + } + + this.options.date = new_date; + + // Set holiday and today classes + this.day_class_holiday(); + + // Update all the little boxes + this._draw(); + + + // Register for updates on events for this day + if(this.registeredUID !== cache_id) + { + this.registeredUID = cache_id; + egw.dataRegisterUID(this.registeredUID, this._data_callback,this,this.getInstanceManager().execId,this.id); + } + } + + /** + * Set the owner of this day + * + * @param {number|number[]|string|string[]} _owner - Owner ID, which can + * be an account ID, a resource ID (as defined in calendar_bo, not + * necessarily an entry from the resource app), or a list containing a + * combination of both. + */ + set_owner( _owner) + { + + this.title + .attr("data-owner", _owner); + this.header.attr('data-owner',_owner); + this.div.attr('data-owner',_owner); + + // Simple comparison, both numbers + if(_owner === this.options.owner) return; + + // More complicated comparison, one or the other is an array + if((typeof _owner == 'object' || typeof this.options.owner == 'object') && + _owner.toString() == this.options.owner.toString()) + { + return; + } + + this.options.owner = typeof _owner !== 'object' ? [_owner] : _owner; + + const cache_id = CalendarApp._daywise_cache_id(this.options.date, _owner); + if(this.options.date && this.registeredUID && + cache_id !== this.registeredUID) + { + egw.dataUnregisterUID(this.registeredUID,null,this); + } + + if(this.registeredUID !== cache_id) + { + this.registeredUID = cache_id; + egw.dataRegisterUID(this.registeredUID, this._data_callback,this,this.getInstanceManager().execId,this.id); + } + } + + set_class( classnames) + { + this.header.removeClass(this.class); + super.set_class(classnames); + this.header.addClass(classnames); + } + + /** + * Callback used when the daywise data changes + * + * Events should update themselves when their data changes, here we are + * dealing with a change in which events are displayed on this day. + * + * @param {String[]} event_ids + * @returns {undefined} + */ + _data_callback( event_ids) + { + const events = []; + if(event_ids == null || typeof event_ids.length == 'undefined') event_ids = []; + for(let i = 0; i < event_ids.length; i++) + { + let event : any = egw.dataGetUIDdata('calendar::'+event_ids[i]); + event = event && event.data || false; + if(event && event.date && et2_calendar_event.owner_check(event, this) && ( + event.date === this.options.date || + // Accept multi-day events + new Date(event.start) <= this.date //&& new Date(event.end) >= this.date + )) + { + events.push(event); + } + else if (event) + { + // Got an ID that doesn't belong + event_ids.splice(i--,1); + } + } + if(!this.getParent().disabled) + this._update_events(events); + } + + set_label( label) + { + this.options.label = label; + this.title.text(label); + this.title.toggleClass('et2_clickable et2_link',label === ''); + } + set_left( left) + { + if(this.div) + { + this.div.css('left',left); + } + } + set_width(width) + { + this.options.width = width; + + if(this.div) + { + this.div.outerWidth(this.options.width); + this.header.outerWidth(this.options.width); + } + } + + /** + * Applies class for today, and any holidays for current day + */ + day_class_holiday( ) + { + this.title + // Remove all special day classes + .removeClass('calendar_calToday calendar_calBirthday calendar_calHoliday') + // Except this one... + .addClass("et2_clickable et2_link"); + this.title.attr('data-holiday',''); + + // Set today class - note +1 when dealing with today, as months in JS are 0-11 + const today = new Date(); + today.setUTCMinutes(today.getUTCMinutes() - today.getTimezoneOffset()); + + this.title.toggleClass("calendar_calToday", this.options.date === ''+today.getUTCFullYear()+ + sprintf("%02d",today.getUTCMonth()+1)+ + sprintf("%02d",today.getUTCDate()) + ); + + // Holidays and birthdays + let holidays = et2_calendar_view.get_holidays(this, this.options.date.substring(0, 4)); + const holiday_list = []; + let holiday_pref = (egw.preference('birthdays_as_events', 'calendar') || []); + if(typeof holiday_pref === 'string') + { + holiday_pref = holiday_pref.split(','); + } + else + { + holiday_pref = jQuery.extend([], holiday_pref); + } + + // Show holidays as events on mobile or by preference + const holidays_as_events = egwIsMobile() || egw.preference('birthdays_as_events', 'calendar') === true || + holiday_pref.indexOf('holiday') >= 0; + + const birthdays_as_events = egwIsMobile() || holiday_pref.indexOf('birthday') >= 0; + + if(holidays && holidays[this.options.date]) + { + holidays = holidays[this.options.date]; + for(let i = 0; i < holidays.length; i++) + { + if (typeof holidays[i]['birthyear'] !== 'undefined') + { + // Show birthdays as events on mobile or by preference + if(birthdays_as_events) + { + // Create event + this.getParent().date_helper.set_value(this.options.date.substring(0,4)+'-'+ + (this.options.date.substring(4,6))+'-'+this.options.date.substring(6,8)+ + 'T00:00:00Z'); + var event = et2_createWidget('calendar-event',{ + id:'event_'+holidays[i].name, + value: { + title: holidays[i].name, + whole_day: true, + whole_day_on_top: true, + start: new Date(this.getParent().date_helper.get_value()), + end: this.options.date, + owner: this.options.owner, + participants: this.options.owner, + app: 'calendar', + class: 'calendar_calBirthday' + }, + readonly: true, + class: 'calendar_calBirthday' + },this); + event.doLoadingFinished(); + event._update(); + } + if (!egwIsMobile()) + { + //If the birthdays are already displayed as event, don't + //show them in the caption + this.title.addClass('calendar_calBirthday'); + holiday_list.push(holidays[i]['name']); + } + } + else + { + // Show holidays as events on mobile + if(holidays_as_events) + { + // Create event + this.getParent().date_helper.set_value(this.options.date.substring(0,4)+'-'+ + (this.options.date.substring(4,6))+'-'+this.options.date.substring(6,8)+ + 'T00:00:00Z'); + var event = et2_createWidget('calendar-event',{ + id:'event_'+holidays[i].name, + value: { + title: holidays[i].name, + whole_day: true, + whole_day_on_top: true, + start: new Date(this.getParent().date_helper.get_value()), + end: this.options.date, + owner: this.options.owner, + participants: this.options.owner, + app: 'calendar', + class: 'calendar_calHoliday' + }, + readonly: true, + class: 'calendar_calHoliday' + },this); + event.doLoadingFinished(); + event._update(); + } + else + { + this.title.addClass('calendar_calHoliday'); + this.title.attr('data-holiday', holidays[i]['name']); + + //If the birthdays are already displayed as event, don't + //show them in the caption + if (!this.options.display_holiday_as_event) + { + holiday_list.push(holidays[i]['name']); + } + } + } + } + } + this.title.attr('title', holiday_list.join(', ')); + } + + /** + * Load the event data for this day and create event widgets for each. + * + * If event information is not provided, it will be pulled from the content array. + * + * @param {Object[]} [_events] Array of event information, one per event. + */ + _update_events(_events) + { + let c; + const events = _events || this.getArrayMgr('content').getEntry(this.options.date) || []; + + // Remove extra events + while(this._children.length > 0) + { + const node = this._children[this._children.length - 1]; + this.removeChild(node); + node.destroy(); + } + + // Make sure children are in cronological order, or columns are backwards + events.sort(function(a,b) { + const start = new Date(a.start) - new Date(b.start); + const end = new Date(a.end) - new Date(b.end); + // Whole day events sorted by ID, normal events by start / end time + if(a.whole_day && b.whole_day) + { + return (a.app_id - b.app_id); + } + else if (a.whole_day || b.whole_day) + { + return a.whole_day ? -1 : 1; + } + return start ? start : end; + }); + + for(c = 0; c < events.length; c++) + { + // Create event + var event = et2_createWidget('calendar-event',{ + id:'event_'+events[c].id, + value: events[c] + },this); + } + + // Seperate loop so column sorting finds all children in the right place + for(c = 0; c < events.length && c < this._children.length; c++) + { + let event = this.getWidgetById('event_'+events[c].id); + if(!event) continue; + if(this.isInTree()) + { + event.doLoadingFinished(); + } + } + + + // Show holidays as events on mobile or by preference + if(egwIsMobile() || egw.preference('birthdays_as_events','calendar')) + { + this.day_class_holiday(); + } + + // Apply styles to hidden events + this._out_of_view(); + } + + /** + * Apply styles for out-of-view and partially hidden events + * + * There are 3 different states or modes of display: + * + * - 'Normal' - When showing events positioned by time, the indicator is just + * a bar colored by the last category color. On hover it shows either the + * title of a single event or "x event(s)" if more than one are hidden. + * Clicking adjusts the current view to show the earliest / latest hidden + * event + * + * - Fixed - When showing events positioned by time but in a fixed-height + * week (not auto-sized to fit screen) the indicator is the same as sized. + * On hover it shows the titles of the hidden events, clicking changes + * the view to the selected day. + * + * - GridList - When showing just a list, the indicator shows "x event(s)", + * and on hover shows the category color, title & time. Clicking changes + * the view to the selected day, and opens the event for editing. + */ + _out_of_view() + { + // Reset + this.header.children('.hiddenEventBefore').remove(); + this.div.children('.hiddenEventAfter').remove(); + this.event_wrapper.css('overflow','visible'); + this.all_day.removeClass('overflown'); + jQuery('.calendar_calEventBody', this.div).css({'padding-top': '','margin-top':''}); + + const timegrid = this.getParent(); + + // elem is jquery div of event + function isHidden(elem) { + // Add an extra 5px top and bottom to include events just on the + // edge of visibility + const docViewTop = timegrid.scrolling.scrollTop() + 5, + docViewBottom = docViewTop + ( + this.display_settings.granularity === 0 ? + this.event_wrapper.height() : + timegrid.scrolling.height() - 10 + ), + elemTop = elem.position().top, + elemBottom = elemTop + elem.outerHeight(true); + if((elemBottom <= docViewBottom) && (elemTop >= docViewTop)) + { + // Entirely visible + return false; + } + const visible = { + hidden: elemTop > docViewTop ? 'bottom' : 'top', + completely: false + }; + visible.completely = visible.hidden == 'top' ? elemBottom < docViewTop : elemTop > docViewBottom; + return visible; + } + + // In gridlist view, we can quickly check if we need it at all + if(this.display_settings.granularity === 0 && this._children.length) + { + jQuery('div.calendar_calEvent',this.div).show(0); + if(Math.ceil(this.div.height() / this._children[0].div.height()) > this._children.length) + { + return; + } + } + // Check all day overflow + this.all_day.toggleClass('overflown', + this.all_day[0].scrollHeight - this.all_day.innerHeight() > 5 + ); + + // Check each event + this.iterateOver(function(event) { + // Skip whole day events and events missing value + if(this.display_settings.granularity && ( + (!event.options || !event.options.value || event.options.value.whole_day_on_top)) + ) + { + return; + } + // Reset + event.title.css({'top':'','background-color':''}); + event.body.css({'padding-top':'','margin-top':''}); + const hidden = isHidden.call(this, event.div); + const day = this; + if(!hidden) + { + return; + } + // Only top is hidden, move label + // Bottom hidden is fine + if(hidden.hidden === 'top' && !hidden.completely && !event.div.hasClass('calendar_calEventSmall')) + { + const title_height = event.title.outerHeight(); + event.title.css({ + 'top': timegrid.scrolling.scrollTop() - event.div.position().top, + 'background-color': 'transparent' + }); + event.body.css({ + 'padding-top': timegrid.scrolling.scrollTop() - event.div.position().top + title_height, + 'margin-top' : -title_height + }); + } + // Too many in gridlist view, show indicator + else if (this.display_settings.granularity === 0 && hidden) + { + if(jQuery('.hiddenEventAfter',this.div).length == 0) + { + this.event_wrapper.css('overflow','hidden'); + } + this._hidden_indicator(event, false, function() { + app.calendar.update_state({view: 'day', date: day.date}); + }); + // Avoid partially visible events + // We need to hide all, or the next row will be visible + event.div.hide(0); + } + // Completely out of view, show indicator + else if (hidden.completely) + { + this._hidden_indicator(event, hidden.hidden == 'top',false); + } + }, this, et2_calendar_event); + } + + /** + * Show an indicator that there are hidden events + * + * The indicator works 3 different ways, depending on if the day can be + * scrolled, is fixed, or if in gridview. + * + * @see _out_of_view() + * + * @param {et2_calendar_event} event Event we're creating the indicator for + * @param {boolean} top Events hidden at the top (true) or bottom (false) + * @param {function} [onclick] Callback for when user clicks on the indicator + */ + _hidden_indicator(event, top, onclick) + { + let indicator = null; + const day = this; + const timegrid = this.getParent(); + const fixed_height = timegrid.div.hasClass('calendar_calTimeGridFixed'); + + // Event is before the displayed times + if(top) + { + // Create if not already there + if(jQuery('.hiddenEventBefore',this.header).length === 0) + { + indicator = jQuery('
') + .appendTo(this.header) + .attr('data-hidden_count', 1); + if(!fixed_height) + { + indicator + .text(event.options.value.title) + .on('click', typeof onclick === 'function' ? onclick : function() { + jQuery('.calendar_calEvent',day.div).first()[0].scrollIntoView(); + return false; + }); + } + } + else + { + indicator = jQuery('.hiddenEventBefore',this.header); + indicator.attr('data-hidden_count', parseInt(indicator.attr('data-hidden_count')) + 1); + + if (!fixed_height) + { + indicator.text(day.egw().lang('%1 event(s) %2',indicator.attr('data-hidden_count'),'')); + } + } + } + // Event is after displayed times + else + { + indicator = jQuery('.hiddenEventAfter',this.div); + // Create if not already there + if(indicator.length === 0) + { + indicator = jQuery('
') + .attr('data-hidden_count', 0) + .appendTo(this.div); + if(!fixed_height) + { + indicator + .on('click', typeof onclick === 'function' ? onclick : function() { + jQuery('.calendar_calEvent',day.div).last()[0].scrollIntoView(false); + // Better re-run this to clean up + day._out_of_view(); + return false; + }); + } + else + { + indicator + .on('mouseover', function() { + indicator.css({ + 'height': (indicator.attr('data-hidden_count')*1.2) + 'em', + 'margin-top': -(indicator.attr('data-hidden_count')*1.2) + 'em' + }); + }) + .on('mouseout', function() { + indicator.css({ + 'height': '', + 'margin-top': '' + }); + }); + } + } + const count = parseInt(indicator.attr('data-hidden_count')) + 1; + indicator.attr('data-hidden_count', count); + if(this.display_settings.granularity === 0) + { + indicator.append(event.div.clone()); + indicator.attr('data-hidden_label', day.egw().lang('%1 event(s) %2',indicator.attr('data-hidden_count'),'')); + } + else if (!fixed_height) + { + indicator.text(day.egw().lang('%1 event(s) %2',indicator.attr('data-hidden_count'),'')); + } + indicator.css('top',timegrid.scrolling.height() + timegrid.scrolling.scrollTop()-indicator.innerHeight()); + } + // Show different stuff for fixed height + if(fixed_height) + { + indicator + .append("
"+ + event.options.value.title+ + "
" + ); + } + // Match color to the event + if(indicator !== null) + { + // Avoid white, which is hard to see + // Use border-bottom-color, Firefox doesn't give a value with border-color + const color = jQuery.Color(event.div.css('background-color')).toString() !== jQuery.Color('white').toString() ? + event.div.css('background-color') : event.div.css('border-bottom-color'); + if(color !== 'rgba(0, 0, 0, 0)') + { + indicator.css('border-color', color); + } + } + } + + /** + * Sort a day's events into minimally overlapping columns + * + * @returns {Array[]} Events sorted into columns + */ + _spread_events() + { + if(!this.date) return []; + + let day_start = this.date.valueOf() / 1000; + const dst_check = new Date(this.date); + dst_check.setUTCHours(12); + + // if daylight saving is switched on or off, correct $day_start + // gives correct times after 2am, times between 0am and 2am are wrong + const daylight_diff = day_start + 12 * 60 * 60 - (dst_check.valueOf() / 1000); + if(daylight_diff) + { + day_start -= daylight_diff; + } + + const eventCols = [], col_ends = []; + + // Make sure children are in cronological order, or columns are backwards + this._children.sort(function(a,b) { + const start = new Date(a.options.value.start) - new Date(b.options.value.start); + const end = new Date(a.options.value.end) - new Date(b.options.value.end); + // Whole day events sorted by ID, normal events by start / end time + if(a.options.value.whole_day && b.options.value.whole_day) + { + // Longer duration comes first so we have nicer bars across the top + const duration = + (new Date(b.options.value.end) - new Date(b.options.value.start)) - + (new Date(a.options.value.end) - new Date(a.options.value.start)); + + return duration ? duration : (a.options.value.app_id - b.options.value.app_id); + } + else if (a.options.value.whole_day || b.options.value.whole_day) + { + return a.options.value.whole_day ? -1 : 1; + } + return start ? start : end; + }); + + for(let i = 0; i < this._children.length; i++) + { + const event = this._children[i].options.value || false; + if(!event) continue; + if(event.date && event.date != this.options.date && + // Multi-day events date may be different + (new Date(event.start) >= this.date || new Date(event.end) < this.date ) + ) + { + // Still have a child event that has changed date (DnD) + this._children[i].destroy(); + this.removeChild(this._children[i]); + continue; + } + + let c = 0; + event['multiday'] = false; + if(typeof event.start !== 'object') + { + event.start = new Date(event.start); + } + if(typeof event.end !== 'object') + { + event.end = new Date(event.end); + } + event['start_m'] = (event.start.valueOf()/1000 - day_start) / 60; + if (event['start_m'] < 0) + { + event['start_m'] = 0; + event['multiday'] = true; + } + event['end_m'] = (event.end.valueOf()/1000 - day_start) / 60; + if (event['end_m'] >= 24*60) + { + event['end_m'] = 24*60-1; + event['multiday'] = true; + } + if(!event.start.getUTCHours() && !event.start.getUTCMinutes() && event.end.getUTCHours() == 23 && event.end.getUTCMinutes() == 59) + { + event.whole_day_on_top = (event.non_blocking && event.non_blocking != '0'); + } + if (!event['whole_day_on_top']) + { + for(c = 0; event['start_m'] < col_ends[c]; ++c); + col_ends[c] = event['end_m']; + } + if(typeof eventCols[c] === 'undefined') + { + eventCols[c] = []; + } + eventCols[c].push(this._children[i]); + } + return eventCols; + } + + /** + * Position the event according to its time and how this widget is laid + * out. + * + * @param {et2_calendar_event} [event] - Event to be updated + * If a single event is not provided, all events are repositioned. + */ + position_event(event?) + { + // If hidden, skip it - it takes too long + if(!this.div.is(':visible')) return; + + // Sort events into minimally-overlapping columns + const columns = this._spread_events(); + + for(let c = 0; c < columns.length; c++) + { + // Calculate horizontal positioning + let left = Math.ceil(5 + (1.5 * 100 / (parseFloat(this.options.width) || 100))); + let right = 2; + if (columns.length !== 1) + { + right = !c ? 30 : 2; + left += c * (100.0-left) / columns.length; + } + + for(let i = 0; (columns[c].indexOf(event) >= 0 || !event) && i < columns[c].length; i++) + { + // Calculate vertical positioning + let top = 0; + let height = 0; + // Position the event + if(this.display_settings.granularity === 0) + { + if(this.all_day.has(columns[c][i].div).length) + { + columns[c][i].div.prependTo(this.event_wrapper); + } + columns[c][i].div.css('top', ''); + columns[c][i].div.css('height', ''); + columns[c][i].div.css('left', ''); + columns[c][i].div.css('right', ''); + // Strip out of view padding + columns[c][i].body.css('padding-top',''); + continue; + } + if(columns[c][i].options.value.whole_day_on_top) + { + if(!this.all_day.has(columns[c][i].div).length) + { + columns[c][i].div.css('top', ''); + columns[c][i].div.css('height',''); + columns[c][i].div.css('left', ''); + columns[c][i].div.css('right', ''); + columns[c][i].body.css('padding-top',''); + columns[c][i].div + .appendTo(this.all_day); + this.getParent().resizeTimes(); + } + continue; + } + else + { + if(this.all_day.has(columns[c][i].div).length) + { + columns[c][i].div.appendTo(this.event_wrapper); + this.getParent().resizeTimes(); + } + top = this._time_to_position(columns[c][i].options.value.start_m); + height = this._time_to_position(columns[c][i].options.value.end_m)-top; + } + + // Position the event + if(event && columns[c].indexOf(event) >= 0 || !event) + { + columns[c][i].div.css('top', top+'%'); + columns[c][i].div.css('height', height+'%'); + // Remove spacing from border, but only if visible or the height will be wrong + if(columns[c][i].div.is(':visible')) + { + const border_diff = columns[c][i].div.outerHeight() - columns[c][i].div.height(); + columns[c][i].div.css('height','calc('+height+'% - ' +border_diff+')'); + } + // This gives the wrong height + //columns[c][i].div.outerHeight(height+'%'); + columns[c][i].div.css('left', left.toFixed(1)+'%'); + columns[c][i].div.css('right', right.toFixed(1)+'%'); + columns[c][i].div.css('z-index',parseInt(20)+c); + columns[c][i]._small_size(); + } + } + // Only wanted to position this event, leave the other columns alone + if(event && columns[c].indexOf(event) >= 0) + { + return; + } + } + } + + /** + * Calculates the vertical position based on the time + * + * This calculation is a percentage from 00:00 to 23:59 + * + * @param {int} time in minutes from midnight + * @return {float} position in percent + */ + _time_to_position(time) + { + let pos = 0.0; + + // 24h + pos = ((time / 60) / 24) * 100; + + return pos.toFixed(1); + } + + attachToDOM() + { + let result = super.attachToDOM(); + + // Remove the binding for the click handler, unless there's something + // custom here. + if (!this.onclick) + { + jQuery(this.node).off("click"); + } + // But we do want to listen to certain clicks, and handle them internally + jQuery(this.node).on('click.et2_daycol', + '.calendar_calDayColHeader,.calendar_calAddEvent', + jQuery.proxy(this.click, this) + ); + + return result; + } + + /** + * Click handler calling custom handler set via onclick attribute to this.onclick, + * or the default which is to open a new event at that time. + * + * Normally, you don't bind to this one, but the attribute is supported if you + * can get a reference to the widget. + * + * @param {Event} _ev + * @returns {boolean} + */ + click(_ev) + { + if(this.getParent().options.readonly ) return; + + // Drag to create in progress + if(this.getParent().drag_create.start !== null) return; + + // Click on the title + if (jQuery(_ev.target).hasClass('calendar_calAddEvent')) + { + if(this.header.has(_ev.target).length == 0 && !_ev.target.dataset.whole_day) + { + // Default handler to open a new event at the selected time + var options = { + date: _ev.target.dataset.date || this.options.date, + hour: _ev.target.dataset.hour || this.getParent().options.day_start, + minute: _ev.target.dataset.minute || 0, + owner: this.options.owner + }; + app.calendar.add(options); + return false; + } + // Header, all day non-blocking + else if (this.header.has(_ev.target).length && !jQuery('.hiddenEventBefore',this.header).has(_ev.target).length || + this.header.is(_ev.target) + ) + { + // Click on the header, but not the title. That's an all-day non-blocking + const end = this.date.getFullYear() + '-' + (this.date.getUTCMonth() + 1) + '-' + this.date.getUTCDate() + 'T23:59'; + let options = { + start: this.date.toJSON(), + end: end, + non_blocking: true, + owner: this.options.owner + }; + app.calendar.add(options); + return false; + } + } + // Day label + else if(this.title.is(_ev.target) || this.title.has(_ev.target).length) + { + app.calendar.update_state({view: 'day',date: this.date.toJSON()}); + return false; + } + + } + + /** + * Code for implementing et2_IDetachedDOM + * + * @param {array} _attrs array to add further attributes to + */ + getDetachedAttributes( _attrs) + { + + } + + getDetachedNodes() { + return [this.getDOMNode(this)]; + } + + setDetachedAttributes( _nodes, _values) + { + + } + + // Resizable interface + /** + * Resize + * + * Parent takes care of setting proper width & height for the containing div + * here we just need to adjust the events to fit the new size. + */ + resize () + { + if(this.disabled || !this.div.is(':visible') || this.getParent().disabled) + { + return; + } + + if(this.display_settings.granularity !== this.getParent().options.granularity) + { + // Layout has changed + this._draw(); + + // Resize & position all events + this.position_event(); + } + else + { + // Don't need to resize & reposition, just clear some stuff + // to reset for _out_of_view() + this.iterateOver(function(widget) { + widget._small_size(); + }, this, et2_calendar_event); + } + this._out_of_view(); + } +} +et2_register_widget(et2_calendar_daycol, ["calendar-daycol"]); diff --git a/calendar/js/et2_widget_event.ts b/calendar/js/et2_widget_event.ts new file mode 100644 index 0000000000..c41f772cf5 --- /dev/null +++ b/calendar/js/et2_widget_event.ts @@ -0,0 +1,1317 @@ +/* + * Egroupware Calendar event widget + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package etemplate + * @subpackage api + * @link http://www.egroupware.org + * @author Nathan Gray + * @version $Id$ + */ + + +/*egw:uses + /etemplate/js/et2_core_valueWidget; +*/ + +import {et2_register_widget, et2_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget"; +import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget"; +import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance"; +import {et2_action_object_impl, et2_DOMWidget} from "../../api/js/etemplate/et2_core_DOMWidget"; +import {et2_calendar_daycol} from "./et2_widget_daycol"; +import {et2_calendar_planner_row} from "./et2_widget_planner_row"; + +/** + * Class for a single event, displayed in either the timegrid or planner view + * + * It is possible to directly provide all information directly, but calendar + * uses egw.data for caching, so ID is all that is needed. + * + * Note that there are several pieces of information that have 'ID' in them: + * - row_id - used by both et2_calendar_event and the nextmatch to uniquely + * identify a particular entry or entry ocurrence + * - id - Recurring events may have their recurrence as a timestamp after their ID, + * such as '194:1453318200', or not. It's usually (always?) the same as row ID. + * - app_id - the ID according to the source application. For calendar, this + * is the same as ID (but always with the recurrence), for other apps this is + * usually just an integer. With app_id and app, you should be able to call + * egw.open() and get the specific entry. + * - Events from other apps will have their app name prepended to their ID, such + * as 'infolog123', so app_id and id will be different for these events + * - Cache ID is the same as other apps, and looks like 'calendar::' + * - The DOM ID for the containing div is event_ + * + * Events are expected to be added to either et2_calendar_daycol or + * et2_calendar_planner_row rather than either et2_calendar_timegrid or + * et2_calendar_planner directly. + * + */ +export class et2_calendar_event extends et2_valueWidget implements et2_IDetachedDOM +{ + + static readonly _attributes : any = { + "value": { + type: "any", + default: et2_no_init + }, + "onclick": { + "description": "JS code which is executed when the element is clicked. " + + "If no handler is provided, or the handler returns true and the event is not read-only, the " + + "event will be opened according to calendar settings." + } + }; + private div: JQuery; + private title: JQuery; + private body: JQuery; + private icons: JQuery; + private _need_actions_linked: boolean = false; + private _actionObject: egwActionObject; + + /** + * Constructor + */ + constructor(_parent, _attrs? : WidgetConfig, _child? : object) + { + // Call the inherited constructor + super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_event._attributes, _child || {})); + + const event = this; + + // Main container + this.div = jQuery(document.createElement("div")) + .addClass("calendar_calEvent") + .addClass(this.options.class) + .css('width',this.options.width) + .on('mouseenter', function() { + // Bind actions on first mouseover for faster creation + if(event._need_actions_linked) + { + event._copy_parent_actions(); + } + // Tooltip + if(!event._tooltipElem) + { + event.options.statustext_html = true; + event.set_statustext(event._tooltip()); + if(event.statustext) + { + return event.div.trigger('mouseenter'); + } + } + // Hacky to remove egw's tooltip border and let the mouse in + window.setTimeout(function() { + jQuery('body .egw_tooltip') + .css('border','none') + .on('mouseenter', function() { + event.div.off('mouseleave.tooltip'); + jQuery('body.egw_tooltip').remove(); + jQuery('body').append(this); + jQuery(this).stop(true).fadeTo(400, 1) + .on('mouseleave', function() { + jQuery(this).fadeOut('400', function() { + jQuery(this).remove(); + // Set up to work again + event.set_statustext(event._tooltip()); + }); + }); + }); + + },105); + }); + this.title = jQuery(document.createElement('div')) + .addClass("calendar_calEventHeader") + .appendTo(this.div); + this.body = jQuery(document.createElement('div')) + .addClass("calendar_calEventBody") + .appendTo(this.div); + this.icons = jQuery(document.createElement('div')) + .addClass("calendar_calEventIcons") + .appendTo(this.title); + + this.setDOMNode(this.div[0]); + } + + doLoadingFinished( ) + { + super.doLoadingFinished(); + + // Already know what is needed to hook to cache + if(this.options.value && this.options.value.row_id) + { + egw.dataRegisterUID( + 'calendar::'+this.options.value.row_id, + this._UID_callback, + this, + this.getInstanceManager().execId, + this.id + ); + } + return true; + } + + destroy( ) + { + super.destroy(); + + if(this._actionObject) + { + this._actionObject.remove(); + this._actionObject = null; + } + + this.div.off(); + this.title.remove(); + this.title = null; + this.body.remove(); + this.body = null; + this.icons = null; + this.div.remove(); + this.div = null; + + jQuery('body.egw_tooltip').remove(); + + // Unregister, or we'll continue to be notified... + if(this.options.value) + { + const old_app_id = this.options.value.row_id; + egw.dataUnregisterUID('calendar::'+old_app_id,null,this); + } + } + + set_value( _value) + { + // Un-register for updates + if(this.options.value) + { + var old_id = this.options.value.row_id; + if(!_value || !_value.row_id || old_id !== _value.row_id) + { + egw.dataUnregisterUID('calendar::'+old_id,null,this); + } + } + this.options.value = _value; + + // Register for updates + const id = this.options.value.row_id; + if(!old_id || old_id !== id) + { + egw.dataRegisterUID('calendar::'+id, this._UID_callback ,this,this.getInstanceManager().execId,this.id); + } + if(_value && !egw.dataHasUID('calendar::'+id)) + { + egw.dataStoreUID('calendar::'+id, _value); + } + } + + /** + * Callback for changes in cached data + */ + _UID_callback(event) + { + // Copy to avoid changes, which may cause nm problems + const value = event === null ? null : jQuery.extend({}, event); + let parent = this.getParent(); + + // Make sure id is a string, check values + if(value) + { + this._values_check(value); + } + + // Check for changing days in the grid view + if(!this._sameday_check(value)) + { + // May need to update parent to remove out-of-view events + parent.removeChild(this); + if(event === null && parent && parent.instanceOf(et2_calendar_daycol)) + { + (parent)._out_of_view(); + } + + // This should now cease to exist, as new events have been created + this.destroy(); + return; + } + + // Copy to avoid changes, which may cause nm problems + this.options.value = jQuery.extend({},value); + + if(this.getParent().options.date) + { + this.options.value.date = this.getParent().options.date; + } + + // Let parent position - could also be et2_calendar_planner_row + (this.getParent()).position_event(this); + + // Parent may remove this if the date isn't the same + if(this.getParent()) + { + this._update(); + } + } + + /** + * Draw the event + */ + _update( ) + { + + // Update to reflect new information + const event = this.options.value; + + const id = event.row_id ? event.row_id : event.id + (event.recur_type ? ':' + event.recur_date : ''); + const formatted_start = event.start.toJSON(); + + this.set_id('event_' + id); + if(this._actionObject) + { + this._actionObject.id = 'calendar::' + id; + } + + this._need_actions_linked = !this.options.readonly; + + // Make sure category stuff is there + // Fake it to use the cache / call - if already there, these will return + // immediately. + const im = this.getInstanceManager(); + et2_selectbox.cat_options({ + _type:'select-cat', + getInstanceManager: function() {return im;} + }, + {application:event.app||'calendar'} + ); + + // Need cleaning? (DnD helper removes content) + // @ts-ignore + if(!this.div.has(this.title).length) + { + this.div + .empty() + .append(this.title) + .append(this.body); + } + if(!this.getParent().options.readonly && !this.options.readonly && this.div.droppable('instance')) + { + this.div + // Let timegrid always get the drag + .droppable('option','greedy',false); + } + // DOM nodes + this.div + // Set full day flag + .attr('data-full_day', event.whole_day) + + // Put everything we need for basic interaction here, so it's available immediately + .attr('data-id', event.id) + .attr('data-app', event.app || 'calendar') + .attr('data-app_id', event.app_id) + .attr('data-start', formatted_start) + .attr('data-owner', event.owner) + .attr('data-recur_type', event.recur_type) + .attr('data-resize', event.whole_day ? 'WD' : '' + (event.recur_type ? 'S':'')) + .attr('data-priority', event.priority) + // Remove any category classes + .removeClass(function(index, css) { + return (css.match (/(^|\s)cat_\S+/g) || []).join(' '); + }) + // Remove any status classes + .removeClass(function(index, css) { + return (css.match(/calendar_calEvent\S+/g) || []).join(' '); + }) + .removeClass('calendar_calEventSmall') + .addClass(event.class) + .toggleClass('calendar_calEventPrivate', typeof event.private !== 'undefined' && event.private); + this.options.class = event.class; + const status_class = this._status_class(); + + // Add category classes, if real categories are set + if(event.category && event.category != '0') + { + const cats = event.category.split(','); + for(let i = 0; i < cats.length; i++) + { + this.div.addClass('cat_' + cats[i]); + } + } + + this.div.toggleClass('calendar_calEventUnknown', event.participants[egw.user('account_id')] ? event.participants[egw.user('account_id')][0] === 'U' : false); + this.div.addClass(status_class); + + this.body.toggleClass('calendar_calEventBodySmall', event.whole_day_on_top || false); + + // Header + const title = !event.is_private ? egw.htmlspecialchars(event['title']) : egw.lang('private'); + + this.title + .html(''+this._get_timespan(event) + '
') + .append(''+title+''); + + // Colors - don't make them transparent if there is no color + // @ts-ignore + if(jQuery.Color("rgba(0,0,0,0)").toRgbaString() != jQuery.Color(this.div,'background-color').toRgbaString()) + { + // Most statuses use colored borders + this.div.css('border-color',this.div.css('background-color') ); + } + + this.icons.appendTo(this.title) + .html(this._icons().join('')); + + // Body + if(event.whole_day_on_top) + { + this.body.html(title); + } + else + { + // @ts-ignore + const start_time = jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: event.start_m / 60, + minute: event.start_m % 60, + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") === "12")} + ).trim(); + + this.body + .html(''+title+'') + .append(''+start_time + ''); + if(this.options.value.description.trim()) + { + this.body + .append('

'+egw.htmlspecialchars(this.options.value.description)+'

'); + } + } + + // Clear tooltip for regeneration + this.set_statustext(''); + + // Height specific section + // This can take an unreasonable amount of time if parent is hidden + if(jQuery((this.getParent()).getDOMNode(this)).is(':visible')) + { + this._small_size(); + } + } + + /** + * Calculate display variants for when event is too short for full display + * + * Display is based on the number of visible lines, calculated off the header + * height: + * 1 - show just the event title, with ellipsis + * 2 - Show timespan and title, with ellipsis + * > 4 - Show description as well, truncated to fit + */ + _small_size( ) + { + + if(this.options.value.whole_day_on_top) return; + + // Skip for planner view, it's always small + if(this.getParent() && this.getParent().instanceOf(et2_calendar_planner_row)) return; + + // Pre-calculation reset + this.div.removeClass('calendar_calEventSmall'); + this.body.css('height', 'auto'); + + const line_height = parseFloat(this.div.css('line-height')); + let visible_lines = Math.floor(this.div.innerHeight() / line_height); + + if(!this.title.height()) + { + // Handle sizing while hidden, such as when calendar is not the active tab + visible_lines = Math.floor(egw.getHiddenDimensions(this.div).h / egw.getHiddenDimensions(this.title).h); + } + visible_lines = Math.max(1,visible_lines); + + if(this.getParent() && this.getParent().instanceOf(et2_calendar_daycol)) + { + this.div.toggleClass('calendar_calEventSmall',visible_lines < 4); + this.div + .attr('data-visible_lines', visible_lines); + } + else if (this.getParent() && this.getParent().instanceOf(et2_calendar_planner_row)) + { + // Less than 8 hours is small + this.div.toggleClass('calendar_calEventSmall',this.options.value.end.valueOf() - this.options.value.start.valueOf() < 28800000); + } + + + if(this.body.height() > this.div.height() - this.title.height() && visible_lines >= 4) + { + this.body.css('height', Math.floor((visible_lines-1)*line_height - this.title.height()) + 'px'); + } + else + { + this.body.css('height', ''); + } + } + + /** + * Examines the participants & returns CSS classname for status + * + * @returns {String} + */ + _status_class( ) + { + let status_class = 'calendar_calEventAllAccepted'; + for(let id in this.options.value.participants) + { + let status = this.options.value.participants[id]; + + status = et2_calendar_event.split_status(status); + + switch (status) + { + case 'A': + case '': // app without status + break; + case 'U': + status_class = 'calendar_calEventSomeUnknown'; + return status_class; // break for + default: + status_class = 'calendar_calEventAllAnswered'; + break; + } + } + return status_class; + } + + /** + * Create tooltip shown on hover + * + * @return {String} + */ + _tooltip( ) + { + if(!this.div || !this.options.value || !this.options.value.app_id) return ''; + + const border = this.div.css('borderTopColor'); + const bg_color = this.div.css('background-color'); + const header_color = this.title.css('color'); + const timespan = this._get_timespan(this.options.value); + const parent = this.getParent() instanceof et2_calendar_daycol ? (this.getParent()) : (this.getParent()); + + parent.date_helper.set_value(this.options.value.start.valueOf ? new Date(this.options.value.start) : this.options.value.start); + const start = parent.date_helper.input_date.val(); + parent.date_helper.set_value(this.options.value.end.valueOf ? new Date(this.options.value.end) : this.options.value.end); + const end = parent.date_helper.input_date.val(); + + const times = !this.options.value.multiday ? + '' + this.egw().lang('Time') + ':' + timespan : + '' + this.egw().lang('Start') + ':' + start + ' ' + + '' + this.egw().lang('End') + ':' + end; + let cat_label = ''; + if(this.options.value.category) + { + const cat = et2_createWidget('select-cat', {'readonly': true}, this); + cat.set_value(this.options.value.category); + let cat_label : (string | string[]) = this.options.value.category.indexOf(',') <= 0 ? cat.span.text() : []; + if(typeof cat_label != 'string') + { + cat.span.children().each(function() { + (cat_label).push(jQuery(this).text()); + }); + cat_label = cat_label.join(', '); + } + cat.destroy(); + } + let participants = ''; + if(this.options.value.participant_types['']) + { + participants += this.options.value.participant_types[''].join("
"); + } + for(let type_name in this.options.value.participant_types) + { + if(type_name) + { + participants += '

'+type_name+':
'; + participants += this.options.value.participant_types[type_name].join("
"); + } + } + + return '

'+ + '
'+ + ''+timespan+''+ + this.icons[0].outerHTML+ + '
'+ + '
'+ + '

'+ + ''+egw.htmlspecialchars(this.options.value.title)+'
'+ + egw.htmlspecialchars(this.options.value.description)+'

'+ + '

'+times+'

'+ + (this.options.value.location ? '

'+this.egw().lang('Location') + ':' + + egw.htmlspecialchars(this.options.value.location)+'

' : '')+ + (cat_label ? '

'+this.egw().lang('Category') + ':' + cat_label +'

' : '')+ + '

'+this.egw().lang('Participants')+':
'+ + participants + '

'+ this._participant_summary(this.options.value.participants) + + '
'+ + '
'; + } + + /** + * Generate participant summary line + * + * @returns {String} + */ + _participant_summary(participants) + { + if( Object.keys(this.options.value.participants).length < 2) + { + return ''; + } + + const participant_status = {A: 0, R: 0, T: 0, U: 0, D: 0}; + const status_label = {A: 'accepted', R: 'rejected', T: 'tentative', U: 'unknown', D: 'delegated'}; + const participant_summary = Object.keys(this.options.value.participants).length + ' ' + this.egw().lang('Participants') + ': '; + const status_totals = []; + + for(let id in this.options.value.participants) + { + var status = this.options.value.participants[id].substr(0,1); + participant_status[status]++; + } + for(let status in participant_status) + { + if(participant_status[status] > 0) + { + status_totals.push(participant_status[status] + ' ' + this.egw().lang(status_label[status])); + } + } + return participant_summary + status_totals.join(', '); + } + + /** + * Get actual icons from list + */ + _icons( ) : string[] + { + const icons = []; + + if(this.options.value.is_private) + { + // Hide everything + icons.push(''); + } + else + { + if(this.options.value.app !== 'calendar') + { + icons.push(''); + } + if(this.options.value.priority == 3) + { + icons.push(''); + } + if(this.options.value.public == '0') + { + // Show private flag + icons.push(''); + } + if(this.options.value['recur_type']) + { + icons.push(''); + } + // icons for single user, multiple users or group(s) and resources + const single = ''; + const multiple = ''; + for(const uid in this.options.value['participants']) + { + // @ts-ignore + if(Object.keys(this.options.value.participants).length == 1 && !isNaN(uid)) + { + icons.push(single); + break; + } + // @ts-ignore + if(!isNaN(uid) && icons.indexOf(multiple) === -1) + { + icons.push(multiple); + } + /* + * TODO: resource icons + elseif(!isset($icons[$uid[0]]) && isset($this->bo->resources[$uid[0]]) && isset($this->bo->resources[$uid[0]]['icon'])) + { + $icons[$uid[0]] = html::image($this->bo->resources[$uid[0]]['app'], + ($this->bo->resources[$uid[0]]['icon'] ? $this->bo->resources[$uid[0]]['icon'] : 'navbar'), + lang($this->bo->resources[$uid[0]]['app']), + 'width="16px" height="16px"'); + } + */ + } + + if(this.options.value.alarm && !jQuery.isEmptyObject(this.options.value.alarm) && !this.options.value.is_private) + { + icons.push(''); + } + if(this.options.value.participants[egw.user('account_id')] && this.options.value.participants[egw.user('account_id')][0] == 'U') + { + icons.push(''); + } + } + + // Always include non-blocking, regardless of privacy + if(this.options.value.non_blocking) + { + icons.push(''); + } + return icons; + } + + /** + * Get a text representation of the timespan of the event. Either start + * - end, or 'all day' + * + * @param {Object} event Event to get the timespan for + * @param {number} event.start_m Event start, in minutes from midnight + * @param {number} event.end_m Event end, in minutes from midnight + * + * @return {string} Timespan + */ + _get_timespan( event) + { + let timespan = ''; + if (event['start_m'] === 0 && event['end_m'] >= 24*60-1) + { + if (event['end_m'] > 24*60) + { + // @ts-ignore + timespan = jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: event.start_m / 60, + minute: event.start_m % 60, + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") === "12")} + // @ts-ignore + ).trim()+' - '+jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: event.end_m / 60, + minute: event.end_m % 60, + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") === "12")} + ).trim(); + } + else + { + timespan = this.egw().lang('Whole day'); + } + } + else + { + let duration : string | number = event.multiday ? + (event.end - event.start) / 60000 : + (event.end_m - event.start_m); + duration = Math.floor(duration/60) + this.egw().lang('h')+(duration%60 ? duration%60 : ''); + + // @ts-ignore + timespan = jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: event.start_m / 60, + minute: event.start_m % 60, + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") === "12")} + ).trim(); + + // @ts-ignore + timespan += ' - ' + jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: event.end_m / 60, + minute: event.end_m % 60, + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") === "12")} + ).trim(); + + timespan += ': ' + duration; + } + return timespan; + } + + /** + * Make sure event data has all proper values, and format them as expected + * @param {Object} event + */ + _values_check(event) + { + // Make sure ID is a string + if(event.id) + { + event.id = ''+event.id; + } + + // Parent might be a daycol or a planner_row + let parent = this.getParent(); + + // Use dates as objects + if(typeof event.start !== 'object') + { + parent.date_helper.set_value(event.start); + event.start = new Date(parent.date_helper.getValue()); + } + if(typeof event.end !== 'object') + { + parent.date_helper.set_value(event.end); + event.end = new Date(parent.date_helper.getValue()); + } + + // We need minutes for durations + if(typeof event.start_m === 'undefined') + { + event.start_m = event.start.getUTCHours() * 60 + event.start.getUTCMinutes(); + event.end_m = event.end.getUTCHours() * 60 + event.end.getUTCMinutes(); + } + if(typeof event.multiday === 'undefined') + { + event.multiday = (event.start.getUTCFullYear() !== event.end.getUTCFullYear() || + event.start.getUTCMonth() !== event.end.getUTCMonth() || + event.start.getUTCDate() != event.end.getUTCDate()); + } + if(!event.start.getUTCHours() && !event.start.getUTCMinutes() && event.end.getUTCHours() == 23 && event.end.getUTCMinutes() == 59) + { + event.whole_day_on_top = (event.non_blocking && event.non_blocking != '0'); + } + } + + /** + * Check to see if the provided event information is for the same date as + * what we're currently expecting, and that it has not been changed. + * + * If the date has changed, we adjust the associated daywise caches to move + * the event's ID to where it should be. This check allows us to be more + * directly reliant on the data cache, and less on any other control logic + * elsewhere first. + * + * @param {Object} event Map of event data from cache + * @param {string} event.date For non-recurring, single day events, this is + * the date the event is on. + * @param {string} event.start Start of the event (used for multi-day events) + * @param {string} event.end End of the event (used for multi-day events) + * + * @return {Boolean} Provided event data is for the same date + */ + _sameday_check(event) + { + // Event somehow got orphaned, or deleted + if(!this.getParent() || event === null) + { + return false; + } + + // Also check participants against owner + const owner_match = et2_calendar_event.owner_check(event, this.getParent()); + + // Simple, same day + if(owner_match && this.options.value.date && event.date == this.options.value.date) + { + return true; + } + + // Multi-day non-recurring event spans days - date does not match + const event_start = new Date(event.start); + const event_end = new Date(event.end); + const parent = this.getParent(); + if(owner_match && (parent instanceof et2_calendar_daycol) && parent.getDate() >= event_start && parent.getDate() <= event_end) + { + return true; + } + + // Delete all old actions + if(this._actionObject) + { + this._actionObject.clear(); + this._actionObject.unregisterActions(); + this._actionObject = null; + } + + // Update daywise caches + const new_cache_id = CalendarApp._daywise_cache_id(event.date, this.getParent().options.owner); + let new_daywise: any = egw.dataGetUIDdata(new_cache_id); + new_daywise = new_daywise && new_daywise.data ? new_daywise.data : []; + let old_cache_id = ''; + if(this.options.value && this.options.value.date) + { + old_cache_id = CalendarApp._daywise_cache_id(this.options.value.date,parent.options.owner); + } + + if(new_cache_id != old_cache_id) + { + let old_daywise: any = egw.dataGetUIDdata(old_cache_id); + old_daywise = old_daywise && old_daywise.data ? old_daywise.data : []; + old_daywise.splice(old_daywise.indexOf(this.options.value.row_id),1); + egw.dataStoreUID(old_cache_id,old_daywise); + + if (new_daywise.indexOf(event.row_id) < 0) + { + new_daywise.push(event.row_id); + } + if(egw.dataHasUID(new_cache_id)) + { + egw.dataStoreUID(new_cache_id,new_daywise); + } + } + + return false; + } + + attachToDOM() + { + let result = super.attachToDOM(); + + // Remove the binding for the click handler, unless there's something + // custom here. + if (!this.onclick) + { + jQuery(this.node).off("click"); + } + return result; + } + + /** + * Click handler calling custom handler set via onclick attribute to this.onclick. + * All other handling is done by the timegrid widget. + * + * @param {Event} _ev + * @returns {boolean} + */ + click( _ev) + { + let result = true; + if(typeof this.onclick == 'function') + { + // Make sure function gets a reference to the widget, splice it in as 2. argument if not + const args = Array.prototype.slice.call(arguments); + if(args.indexOf(this) == -1) args.splice(1, 0, this); + + result = this.onclick.apply(this, args); + } + return result; + } + + /** + * Show the recur prompt for this event + * + * Calls et2_calendar_event.recur_prompt with this event's value. + * + * @param {et2_calendar_event~prompt_callback} callback + * @param {Object} [extra_data] + */ + recur_prompt(callback, extra_data) + { + et2_calendar_event.recur_prompt(this.options.value,callback,extra_data); + } + + /** + * Show the series split prompt for this event + * + * Calls et2_calendar_event.series_split_prompt with this event's value. + * + * @param {et2_calendar_event~prompt_callback} callback + */ + series_split_prompt(callback) + { + et2_calendar_event.series_split_prompt(this.options.value,this.options.value.recur_date, callback); + } + + /** + * Copy the actions set on the parent, apply them to self + * + * This can take a while to do, so we try to do it only when needed - on mouseover + */ + _copy_parent_actions() + { + // Copy actions set in parent + if(!this.options.readonly && !this.getParent().options.readonly) + { + let action_parent : et2_widget = this; + while(action_parent != null && !action_parent.options.actions && + !(action_parent instanceof et2_container) + ) + { + action_parent = action_parent.getParent(); + } + try { + this._link_actions(action_parent.options.actions||{}); + this._need_actions_linked = false; + } catch (e) { + // something went wrong, but keep quiet about it + } + } + } + + /** + * Link the actions to the DOM nodes / widget bits. + * + * @param {object} actions {ID: {attributes..}+} map of egw action information + */ + _link_actions(actions) + { + if(!this._actionObject) + { + // Get the top level element - timegrid or so + var objectManager = this.getParent()._actionObject || this.getParent().getParent()._actionObject || + egw_getAppObjectManager(true).getObjectById(this.getParent().getParent().getParent().id) || egw_getAppObjectManager(true); + this._actionObject = objectManager.getObjectById('calendar::'+this.options.value.row_id); + } + + if (this._actionObject == null) { + // Add a new container to the object manager which will hold the widget + // objects + this._actionObject = objectManager.insertObject(false, new egwActionObject( + 'calendar::'+this.options.value.row_id, objectManager, et2_calendar_event.et2_event_action_object_impl(this,this.getDOMNode()), + this._actionManager || objectManager.manager.getActionById('calendar::'+this.options.value.row_id) || objectManager.manager + )); + } + else + { + this._actionObject.setAOI(et2_calendar_event.et2_event_action_object_impl(this, this.getDOMNode(this))); + } + + // Delete all old objects + this._actionObject.clear(); + this._actionObject.unregisterActions(); + + // Go over the widget & add links - this is where we decide which actions are + // 'allowed' for this widget at this time + const action_links = this._get_action_links(actions); + action_links.push('egw_link_drag'); + action_links.push('egw_link_drop'); + if(this._actionObject.parent.getActionLink('invite')) + { + action_links.push('invite'); + } + this._actionObject.updateActionLinks(action_links); + } + + /** + * Code for implementing et2_IDetachedDOM + * + * @param {array} _attrs array to add further attributes to + */ + getDetachedAttributes( _attrs) + { + + } + + getDetachedNodes( ) + { + return [this.getDOMNode()]; + } + + setDetachedAttributes( _nodes, _values) + { + + } + + // Static class stuff + /** + * Check event owner against a parent object + * + * As an event is edited, its participants may change. Also, as the state + * changes we may change which events are displayed and show the same event + * in several places for different users. Here we check the event participants + * against an owner value (which may be an array) to see if the event should be + * displayed or included. + * + * @param {Object} event - Event information + * @param {et2_widget_daycol|et2_widget_planner_row} parent - potential parent object + * that has an owner option + * @param {boolean} [owner_too] - Include the event owner in consideration, or only + * event participants + * + * @return {boolean} Should the event be displayed + */ + static owner_check(event, parent, owner_too?) + { + let owner_match = true; + if(typeof owner_too === 'undefined' && app.calendar.state.status_filter) + { + owner_too = app.calendar.state.status_filter === 'owner'; + } + let options : any = null; + if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner')) + { + options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection(); + } + else + { + options = parent.getArrayMgr("sel_options").getRoot().getEntry('owner'); + } + if(event.participants && typeof parent.options.owner != 'undefined' && parent.options.owner.length > 0) + { + var parent_owner = jQuery.extend([], typeof parent.options.owner !== 'object' ? + [parent.options.owner] : + parent.options.owner); + owner_match = false; + const length = parent_owner.length; + for(var i = 0; i < length; i++ ) + { + // Handle groups & grouped resources like mailing lists, they won't match so + // we need the list - pull it from sidebox owner + if((isNaN(parent_owner[i]) || parent_owner[i] < 0) && options && typeof options.find == "function") + { + var resource = options.find(function(element) {return element.id == parent_owner[i];}) || {}; + if(resource && resource.resources) + { + parent_owner.splice(i,1); + parent_owner = parent_owner.concat(resource.resources); + + } + } + } + let participants = jQuery.extend([], Object.keys(event.participants)); + for(var i = 0; i < participants.length; i++ ) + { + const id = participants[i]; + // Expand group invitations + if (parseInt(id) < 0) + { + // Add in groups, if we can get them from options, great + var resource; + if(options && options.find && (resource = options.find(function(element) {return element.id === id;})) && resource.resources) + { + participants = participants.concat(resource.resources); + } + else + { + // Add in groups, if we can get them (this is asynchronous) + egw.accountData(id,'account_id',true,function(members) { + participants = participants.concat(Object.keys(members)); + }, this); + } + } + if(parent.options.owner == id || + parent_owner.indexOf && + parent_owner.indexOf(id) >= 0) + { + owner_match = true; + break; + } + } + } + if(owner_too && !owner_match) + { + owner_match = (parent.options.owner == event.owner || + parent_owner.indexOf && + parent_owner.indexOf(event.owner) >= 0); + } + return owner_match; + } + + /** + * @callback et2_calendar_event~prompt_callback + * @param {string} button_id - One of ok, exception, series, single or cancel + * depending on which buttons are on the prompt + * @param {Object} event_data - Event information - whatever you passed in to + * the prompt. + */ + /** + * Recur prompt + * If the event is recurring, asks the user if they want to edit the event as + * an exception, or change the whole series. Then the callback is called. + * + * If callback is not provided, egw.open() will be used to open an edit dialog. + * + * If you call this on a single (non-recurring) event, the callback will be + * executed immediately, with the passed button_id as 'single'. + * + * @param {Object} event_data - Event information + * @param {string} event_data.id - Unique ID for the event, possibly with a + * timestamp + * @param {string|Date} event_data.start - Start date/time for the event + * @param {number} event_data.recur_type - Recur type, or 0 for a non-recurring event + * @param {et2_calendar_event~prompt_callback} [callback] - Callback is + * called with the button (exception, series, single or cancel) and the event + * data. + * @param {Object} [extra_data] - Additional data passed to the callback, used + * as extra parameters for default callback + * + * @augments {et2_calendar_event} + */ + public static recur_prompt(event_data, callback?, extra_data?) + { + let egw; + const edit_id = event_data.app_id; + const edit_date = event_data.start; + + // seems window.opener somehow in certain conditions could be from different origin + // we try to catch the exception and in this case retrieve the egw object from current window. + try + { + egw = this.egw ? (typeof this.egw == 'function' ? this.egw() : this.egw) : window.opener && typeof window.opener.egw != 'undefined' ? window.opener.egw('calendar'):window.egw('calendar'); + } + catch(e){ + egw = window.egw('calendar'); + } + + const that = this; + + const extra_params = extra_data && typeof extra_data == 'object' ? extra_data : {}; + extra_params.date = edit_date.toJSON ? edit_date.toJSON() : edit_date; + if(typeof callback != 'function') + { + callback = function(_button_id) + { + switch(_button_id) + { + case 'exception': + extra_params.exception = '1'; + egw.open(edit_id, event_data.app||'calendar', 'edit', extra_params); + break; + case 'series': + case 'single': + egw.open(edit_id, event_data.app||'calendar', 'edit', extra_params); + break; + case 'cancel': + default: + break; + } + }; + } + if(parseInt(event_data.recur_type)) + { + const buttons = [ + {text: egw.lang("Edit exception"), id: "exception", class: "ui-priority-primary", "default": true}, + {text: egw.lang("Edit series"), id: "series"}, + {text: egw.lang("Cancel"), id: "cancel"} + ]; + et2_dialog.show_dialog( + function(button_id) {callback.call(that, button_id, event_data);}, + (!event_data.is_private ? event_data['title'] : egw.lang('private')) + "\n" + + egw.lang("Do you want to edit this event as an exception or the whole series?"), + egw.lang("This event is part of a series"), {}, buttons, et2_dialog.QUESTION_MESSAGE + ); + } + else + { + callback.call(this,'single',event_data); + } + } + + /** + * Split series prompt + * + * If the event is recurring and the user adjusts the time or duration, we may need + * to split the series, ending the current one and creating a new one with the changes. + * This prompts the user if they really want to do that. + * + * There is no default callback, and nothing happens if you call this on a + * single (non-recurring) event + * + * @param {Object} event_data - Event information + * @param {string} event_data.id - Unique ID for the event, possibly with a timestamp + * @param {string|Date} instance_date - The date of the edited instance of the event + * @param {et2_calendar_event~prompt_callback} callback - Callback is + * called with the button (ok or cancel) and the event data. + * @augments {et2_calendar_event} + */ + public static series_split_prompt(event_data, instance_date, callback) + { + let egw; + // seems window.opener somehow in certian conditions could be from different origin + // we try to catch the exception and in this case retrieve the egw object from current window. + try { + egw = this.egw ? (typeof this.egw == 'function' ? this.egw() : this.egw) : window.opener && typeof window.opener.egw != 'undefined' ? window.opener.egw('calendar'):window.egw('calendar'); + } + catch(e){ + egw = window.egw('calendar'); + } + + const that = this; + + if(typeof instance_date == 'string') + { + instance_date = new Date(instance_date); + } + + // Check for modifying a series that started before today + const tempDate = new Date(); + const today = new Date(tempDate.getFullYear(), tempDate.getMonth(), tempDate.getDate(), tempDate.getHours(), -tempDate.getTimezoneOffset(), tempDate.getSeconds()); + const termination_date = instance_date < today ? egw.lang('today') : date(egw.preference('dateformat'), instance_date); + + if(parseInt(event_data.recur_type)) + { + et2_dialog.show_dialog( + function(button_id) {callback.call(that, button_id, event_data);}, + (!event_data.is_private ? event_data['title'] : egw.lang('private')) + "\n" + + egw.lang("Do you really want to change the start of this series? If you do, the original series will be terminated as of %1 and a new series for the future reflecting your changes will be created.", termination_date), + egw.lang("This event is part of a series"), {}, et2_dialog.BUTTONS_OK_CANCEL , et2_dialog.WARNING_MESSAGE + ); + } + } + + public static drag_helper (event,ui) { + ui.helper.width(ui.width()); + }; + + /** + * splits the combined status, quantity and role + * + * @param {string} status - combined value, O: status letter: U, T, A, R + * @param {int} [quantity] - quantity + * @param {string} [role] + * @return string status U, T, A or R, same as $status parameter on return + */ + public static split_status (status,quantity?,role?) + { + quantity = 1; + role = 'REQ-PARTICIPANT'; + //error_log(__METHOD__.__LINE__.array2string($status)); + let matches = null; + if (typeof status === 'string' && status.length > 1) + { + matches = status.match(/^.([0-9]*)(.*)$/gi); + } + if(matches) + { + if (parseInt(matches[1]) > 0) quantity = parseInt(matches[1]); + if (matches[2]) role = matches[2]; + status = status[0]; + } + else if (status === true) + { + status = 'U'; + } + return status; + } + + /** + * The egw_action system requires an egwActionObjectInterface Interface implementation + * to tie actions to DOM nodes. I'm not sure if we need this. + * + * The class extension is different than the widgets + * + * @param {et2_DOMWidget} widget + * @param {Object} node + * + */ + public static et2_event_action_object_impl(widget, node) + { + const aoi = new et2_action_object_impl(widget, node).getAOI(); + + // _outerCall may be used to determine, whether the state change has been + // evoked from the outside and the stateChangeCallback has to be called + // or not. + aoi.doSetState = function(_state, _outerCall) { + }; + + return aoi; + }; + +} +et2_register_widget(et2_calendar_event, ["calendar-event"]); \ No newline at end of file diff --git a/calendar/js/et2_widget_owner.ts b/calendar/js/et2_widget_owner.ts new file mode 100644 index 0000000000..c2682150a5 --- /dev/null +++ b/calendar/js/et2_widget_owner.ts @@ -0,0 +1,142 @@ +/* + * Egroupware + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package + * @subpackage + * @link http://www.egroupware.org + * @author Nathan Gray + * @version $Id$ + */ + + +/*egw:uses + et2_widget_taglist; +*/ + +import {et2_register_widget} from "../../api/js/etemplate/et2_core_widget"; + +/** + * Tag list widget customised for calendar owner, which can be a user + * account or group, or an entry from almost any app, or an email address + * + * A cross between auto complete, selectbox and chosen multiselect + * + * Uses MagicSuggest library + * @see http://nicolasbize.github.io/magicsuggest/ + * @augments et2_selectbox + */ +export class et2_calendar_owner extends et2_taglist_email +{ + static readonly _attributes = { + "autocomplete_url": { + "default": "calendar_owner_etemplate_widget::ajax_owner" + }, + "autocomplete_params": { + "name": "Autocomplete parameters", + "type": "any", + "default": {}, + "description": "Extra parameters passed to autocomplete URL. It should be a stringified JSON object." + }, + allowFreeEntries: { + "default": false, + ignore: true + }, + select_options: { + "type": "any", + "name": "Select options", + // Set to empty object to use selectbox's option finding + "default": {}, + "description": "Internally used to hold the select options." + } + }; + + // Allows sub-widgets to override options to the library + lib_options = { + autoSelect: false, + groupBy: 'app', + minChars: 2, + selectFirst: true, + // This option will also expand when the selection is changed + // via code, which we do not want + //expandOnFocus: true + toggleOnClick: true + }; + + + doLoadingFinished() + { + super.doLoadingFinished(); + + var widget = this; + // onChange fired when losing focus, which is different from normal + this._oldValue = this.taglist.getValue(); + + return true; + } + + selectionRenderer(item) + { + if(this && this.options && this.options.allowFreeEntries) + { + return super.selectionRenderer(item); + } + else + { + var label = jQuery('').text(item.label); + if (item.class) label.addClass(item.class); + if (typeof item.title != 'undefined') label.attr('title', item.title); + if (typeof item.data != 'undefined') label.attr('data', item.data); + if (typeof item.icon != 'undefined') + { + var wrapper = jQuery('
').addClass('et2_taglist_tags_icon_wrapper'); + jQuery('') + .addClass('et2_taglist_tags_icon') + .css({"background-image": "url("+(item.icon.match(/^(http|https|\/)/) ? item.icon : egw.image(item.icon, item.app))+")"}) + .appendTo(wrapper); + label.appendTo(wrapper); + return wrapper; + } + return label; + } + } + + getValue() + { + if(this.taglist == null) return null; + return this.taglist.getValue(); + } + + /** + * Override parent to handle our special additional data types (c#,r#,etc.) when they + * are not available client side. + * + * @param {string|string[]} _value array of selected owners, which can be a number, + * or a number prefixed with one character indicating the resource type. + */ + set_value(_value) + { + super.set_value(_value); + + // If parent didn't find a label, label will be the same as ID so we + // can find them that way + for(var i = 0; i < this.options.value.length; i++) + { + var value = this.options.value[i]; + if(value.id == value.label) + { + // Proper label was not fount by parent - ask directly + egw.json('calendar_owner_etemplate_widget::ajax_owner',value.id,function(data) { + this.widget.options.value[this.i].label = data; + this.widget.set_value(this.widget.options.value); + }, this,true,{widget: this, i: i}).sendRequest(); + } + } + + if(this.taglist) + { + this.taglist.clear(true); + this.taglist.addToSelection(this.options.value,true); + } + } +} +et2_register_widget(et2_calendar_owner, ["calendar-owner"]); \ No newline at end of file diff --git a/calendar/js/et2_widget_planner.ts b/calendar/js/et2_widget_planner.ts new file mode 100644 index 0000000000..bdfac4a1ae --- /dev/null +++ b/calendar/js/et2_widget_planner.ts @@ -0,0 +1,2500 @@ +/* + * Egroupware Calendar timegrid + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package etemplate + * @subpackage api + * @link http://www.egroupware.org + * @author Nathan Gray + * @version $Id$ + */ + + +/*egw:uses + /calendar/js/et2_widget_view.js; + /calendar/js/et2_widget_planner_row.js; + /calendar/js/et2_widget_event.js; +*/ + +import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget"; +import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance"; +import {et2_calendar_view} from "./et2_widget_view"; +import {et2_action_object_impl} from "../../api/js/etemplate/et2_core_DOMWidget"; +import {et2_calendar_event} from "./et2_widget_event"; +import {et2_calendar_planner_row} from "./et2_widget_planner_row"; + +/** + * Class which implements the "calendar-planner" XET-Tag for displaying a longer + * ( > 10 days) span of time. Events can be grouped into rows by either user, + * category, or month. Their horizontal position and size in the row is determined + * by their start date and duration relative to the displayed date range. + * + * @augments et2_calendar_view + */ +export class et2_calendar_planner extends et2_calendar_view implements et2_IDetachedDOM, et2_IResizeable, et2_IPrint +{ + static readonly _attributes: any = { + group_by: { + name: "Group by", + type: "string", // or category ID + default: "0", + description: "Display planner by 'user', 'month', or the given category" + }, + filter: { + name: "Filter", + type: "string", + default: '', + description: 'A filter that is used to select events. It is passed along when events are queried.' + }, + show_weekend: { + name: "Weekends", + type: "boolean", + default: egw.preference('days_in_weekview','calendar') != 5, + description: "Display weekends. The date range should still include them for proper scrolling, but they just won't be shown." + }, + hide_empty: { + name: "Hide empty rows", + type: "boolean", + default: false, + description: "Hide rows with no events." + }, + value: { + type: "any", + description: "A list of events, optionally you can set start_date, end_date and group_by as keys and events will be fetched" + }, + "onchange": { + "name": "onchange", + "type": "js", + "default": et2_no_init, + "description": "JS code which is executed when the date range changes." + }, + "onevent_change": { + "name": "onevent_change", + "type": "js", + "default": et2_no_init, + "description": "JS code which is executed when an event changes." + } + }; + + public static readonly DEFERRED_ROW_TIME: number = 100; + + private gridHeader: JQuery; + private headerTitle: JQuery; + private headers: JQuery; + private rows: JQuery; + private grid: JQuery; + private vertical_bar: JQuery; + + private doInvalidate: boolean; + + private registeredCallbacks: any[]; + private cache: {}; + private _deferred_row_updates: {}; + private grouper: any; + + /** + * Constructor + */ + constructor(_parent, _attrs? : WidgetConfig, _child? : object) + { + // Call the inherited constructor + super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_planner._attributes, _child || {})); + + + // Main container + this.div = jQuery(document.createElement("div")) + .addClass("calendar_plannerWidget"); + + // Header + this.gridHeader = jQuery(document.createElement("div")) + .addClass("calendar_plannerHeader") + .appendTo(this.div); + this.headerTitle = jQuery(document.createElement("div")) + .addClass("calendar_plannerHeaderTitle") + .appendTo(this.gridHeader); + this.headers = jQuery(document.createElement("div")) + .addClass("calendar_plannerHeaderRows") + .appendTo(this.gridHeader); + + this.rows = jQuery(document.createElement("div")) + .addClass("calendar_plannerRows") + .appendTo(this.div); + this.grid = jQuery(document.createElement("div")) + .addClass("calendar_plannerGrid") + .appendTo(this.div); + + this.vertical_bar = jQuery(document.createElement("div")) + .addClass('verticalBar') + .appendTo(this.div); + + this.value = []; + + // Update timer, to avoid redrawing twice when changing start & end date + this.update_timer = null; + this.doInvalidate = true; + + this.setDOMNode(this.div[0]); + + this.registeredCallbacks = []; + this.cache = {}; + this._deferred_row_updates = {}; + } + + destroy( ) + { + super.destroy(); + + this.div.off(); + + for(var i = 0; i < this.registeredCallbacks.length; i++) + { + egw.dataUnregisterUID(this.registeredCallbacks[i],null,this); + } + } + + doLoadingFinished( ) + { + super.doLoadingFinished(); + + // Don't bother to draw anything if there's no date yet + if(this.options.start_date) + { + this._drawGrid(); + } + + // Automatically bind drag and resize for every event using jQuery directly + // - no action system - + var planner = this; + + this.cache = {}; + this._deferred_row_updates = {}; + + /** + * If user puts the mouse over an event, then we'll set up resizing so + * they can adjust the length. Should be a little better on resources + * than binding it for every calendar event. + */ + this.div.on('mouseover', '.calendar_calEvent:not(.ui-resizable):not(.rowNoEdit)', function() { + // Load the event + planner._get_event_info(this); + var that = this; + + //Resizable event handler + jQuery(this).resizable + ({ + distance: 10, + grid: [5, 10000], + autoHide: false, + handles: 'e', + containment:'parent', + + /** + * Triggered when the resizable is created. + * + * @param {event} event + * @param {Object} ui + */ + create:function(event, ui) + { + var resizeHelper = event.target.getAttribute('data-resize'); + if (resizeHelper == 'WD' || resizeHelper == 'WDS') + { + jQuery(this).resizable('destroy'); + } + }, + + /** + * If dragging to resize an event, abort drag to create + * + * @param {jQuery.Event} event + * @param {Object} ui + */ + start: function(event, ui) + { + if(planner.drag_create.start) + { + // Abort drag to create, we're dragging to resize + planner._drag_create_end({}); + } + }, + + /** + * Triggered at the end of resizing the calEvent. + * + * @param {event} event + * @param {Object} ui + */ + stop:function(event, ui) + { + var e = new jQuery.Event('change'); + e.originalEvent = event; + e.data = {duration: 0}; + var event_data = planner._get_event_info(this); + var event_widget = planner.getWidgetById(event_data.widget_id); + var sT = event_widget.options.value.start_m; + if (typeof this.dropEnd != 'undefined') + { + var eT = parseInt(this.dropEnd.getUTCHours() * 60) + parseInt(this.dropEnd.getUTCMinutes()); + e.data.duration = ((eT - sT)/60) * 3600; + + if(event_widget) + { + event_widget.options.value.end_m = eT; + event_widget.options.value.duration = e.data.duration; + } + + // Leave the helper there until the update is done + var loading = ui.helper.clone().appendTo(ui.helper.parent()); + + // and add a loading icon so user knows something is happening + jQuery('.calendar_timeDemo',loading).after('
'); + + jQuery(this).trigger(e); + + // That cleared the resize handles, so remove for re-creation... + jQuery(this).resizable('destroy'); + + // Remove loading, done or not + loading.remove(); + } + // Clear the helper, re-draw + if(event_widget) + { + (event_widget.getParent()).position_event(event_widget); + } + }, + + /** + * Triggered during the resize, on the drag of the resize handler + * + * @param {event} event + * @param {Object} ui + */ + resize:function(event, ui) + { + let position; + if(planner.options.group_by == 'month') + { + position = {left: event.clientX, top: event.clientY}; + } + else + { + position = {top: ui.position.top, left: ui.position.left + ui.helper.width()}; + } + planner._drag_helper(this,position,ui.helper.outerHeight()); + } + }); + }) + .on('mousemove', function(event) { + // Ignore headers + if(planner.headers.has(event.target).length !== 0) + { + planner.vertical_bar.hide(); + return; + } + // Position bar by mouse + planner.vertical_bar.position({ + my: 'right-1', + of: event, + collision: 'fit' + }); + planner.vertical_bar.css('top','0px'); + + // Get time at mouse + if(jQuery(event.target).closest('.calendar_eventRows').length == 0) + { + // "Invalid" times, from space after the last planner row, or header + var time = planner._get_time_from_position(event.pageX - planner.grid.offset().left, 10); + } + else if(planner.options.group_by == 'month') + { + var time = planner._get_time_from_position(event.clientX, event.clientY); + } + else + { + var time = planner._get_time_from_position(event.offsetX, event.offsetY); + } + // Passing to formatter, cancel out timezone + if(time) + { + const formatDate = new Date(time.valueOf() + time.getTimezoneOffset() * 60 * 1000); + planner.vertical_bar + .html(''+date(egw.preference('timeformat','calendar') == 12 ? 'h:ia' : 'H:i',formatDate)+'') + .show(); + + if(planner.drag_create.event && planner.drag_create.parent && planner.drag_create.end) + { + + planner.drag_create.end.date = time.toJSON(); + planner._drag_update_event(); + } + } + else + { + // No (valid) time, just hide + planner.vertical_bar.hide(); + } + }) + .on('mousedown', jQuery.proxy(this._mouse_down, this)) + .on('mouseup', jQuery.proxy(this._mouse_up, this)); + + // Actions may be set on a parent, so we need to explicitly get in here + // and get ours + this._link_actions(this.options.actions || this.getParent().options.actions || []); + + // Customize and override some draggable settings + this.div.on('dragcreate','.calendar_calEvent', function(event, ui) { + jQuery(this).draggable('option','cancel','.rowNoEdit'); + // Act like you clicked the header, makes it easier to position + jQuery(this).draggable('option','cursorAt', {top: 5, left: 5}); + }) + .on('dragstart', '.calendar_calEvent', function(event,ui) { + jQuery('.calendar_calEvent',ui.helper).width(jQuery(this).width()) + .height(jQuery(this).outerHeight()) + .css('top', '').css('left','') + .appendTo(ui.helper); + ui.helper.width(jQuery(this).width()); + + // Cancel drag to create, we're dragging an existing event + planner._drag_create_end(); + }); + return true; + } + + _createNamespace() { + return true; + } + + /** + * These handle the differences between the different group types. + * They provide the different titles, labels and grouping + */ + groupers = { + // Group by user has one row for each user + user : + { + // Title in top left corner + title: function() : string + { + return this.egw().lang('User'); + }, + // Column headers + headers: function() + { + var start = new Date(this.options.start_date); + var end = new Date(this.options.end_date); + var start_date = new Date(start.getUTCFullYear(), start.getUTCMonth(),start.getUTCDate()); + var end_date = new Date(end.getUTCFullYear(), end.getUTCMonth(),end.getUTCDate()); + var day_count = Math.round((end_date - start_date) /(1000*3600*24))+1; + if(day_count >= 6) + { + this.headers.append(this._header_months(start, day_count)); + } + if(day_count < 120) + { + var weeks = this._header_weeks(start, day_count); + this.headers.append(weeks); + this.grid.append(weeks); + } + if(day_count < 60) + { + var days = this._header_days(start, day_count); + this.headers.append(days); + this.grid.append(days); + } + if(day_count <= 7) + { + var hours = this._header_hours(start, day_count); + this.headers.append(hours); + this.grid.append(hours); + } + }, + // Labels for the rows + row_labels: function() { + var labels = []; + var already_added = []; + var options = false; + var resource = null; + if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner')) + { + options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection(); + } + else + { + options = this.getArrayMgr("sel_options").getRoot().getEntry('owner'); + } + for(var i = 0; i < this.options.owner.length; i++) + { + var user = this.options.owner[i]; + // Handle grouped resources like mailing lists - pull it from sidebox owner + // and expand to their contents + if(options && options.find && + ((resource = options.find(function(element) {return element.id == user;}) || {}) || isNaN(user))) + { + if(resource && resource.resources) + { + for(var j = 0; j < resource.resources.length; j++) + { + var id = resource.resources[j]; + if(already_added.indexOf(''+id) < 0) + { + labels.push({ + id: id, + label: this._get_owner_name(id)||'', + data: {participants:id,owner:id} + }); + already_added.push(''+id); + } + } + } + else if(already_added.indexOf(''+user) < 0) + { + labels.push({ + id: user, + label: this._get_owner_name(user), + data: {participants:user,owner:user} + }); + already_added.push(''+user); + } + } + else if (user < 0) // groups + { + egw.accountData(parseInt(user),'account_fullname',true,function(result) { + for(var id in result) + { + if(already_added.indexOf(''+id) < 0) + { + this.push({id: id, label: result[id]||'', data: {participants:id,owner:id}}); + already_added.push(''+id); + } + } + },labels); + } + else // users + { + if(already_added.indexOf(user) < 0) + { + var label = this._get_owner_name(user)||''; + labels.push({id: user, label: label, data: {participants:user,owner:''}}); + already_added.push(''+user); + } + } + } + + return labels.sort(function(a,b) { + return a.label.localeCompare(b.label); + }); + }, + // Group the events into the rows + group: function(labels, rows, event) { + // convert filter to allowed status + var status_to_show = ['U','A','T','D','G']; + switch(this.options.filter) + { + case 'unknown': + status_to_show = ['U','G']; break; + case 'accepted': + status_to_show = ['A']; break; + case 'tentative': + status_to_show = ['T']; break; + case 'rejected': + status_to_show = ['R']; break; + case 'delegated': + status_to_show = ['D']; break; + case 'all': + status_to_show = ['U','A','T','D','G','R']; break; + default: + status_to_show = ['U','A','T','D','G']; break; + } + var participants = event.participants; + var add_row = function(user, participant) { + var label_index = false; + for(var i = 0; i < labels.length; i++) + { + if(labels[i].id == user) + { + label_index = i; + break; + } + } + if(participant && label_index !== false && status_to_show.indexOf(participant.substr(0,1)) >= 0 || + !participant && label_index !== false || + this.options.filter === 'owner' && event.owner === user) + { + if(typeof rows[label_index] === 'undefined') + { + rows[label_index] = []; + } + rows[label_index].push(event); + } + }; + for(var user in participants) + { + var participant = participants[user]; + if (parseInt(user) < 0) // groups + { + var planner = this; + egw.accountData(user,'account_fullname',true,function(result) { + for(var id in result) + { + if(!participants[id]) add_row.call(planner,id,participant); + } + },labels); + continue; + } + add_row.call(this, user, participant); + } + }, + // Draw a single row + draw_row: function(sort_key, label, events) { + var row = this._drawRow(sort_key, label,events,this.options.start_date, this.options.end_date); + if(this.options.hide_empty && !events.length) + { + row.set_disabled(true); + } + // Highlight current user, sort_key is account_id + if(sort_key === egw.user('account_id')) + { + row.set_class('current_user') + } + // Since the daywise cache is by user, we can tap in here + var t = new Date(this.options.start_date); + var end = new Date(this.options.end_date); + do + { + var cache_id = CalendarApp._daywise_cache_id(t, sort_key); + egw.dataRegisterUID(cache_id, row._data_callback, row); + + t.setUTCDate(t.getUTCDate() + 1); + } + while(t < end); + return row; + } + }, + + // Group by month has one row for each month + month: + { + title: function() { return this.egw().lang('Month');}, + headers: function() { + this.headers.append(this._header_day_of_month()); + }, + row_labels: function() { + var labels = []; + var d = new Date(this.options.start_date); + d = new Date(d.valueOf() + d.getTimezoneOffset() * 60 * 1000); + for(var i = 0; i < 12; i++) + { + // Not using UTC because we corrected for timezone offset + labels.push({id:sprintf('%04d-%02d', d.getFullYear(), d.getMonth()), label:this.egw().lang(date('F',d))+' '+d.getFullYear()}); + d.setMonth(d.getMonth()+1); + } + return labels; + }, + group: function(labels, rows,event) { + // Yearly planner does not show infologs + if(event && event.app && event.app == 'infolog') return; + + var start = new Date(event.start); + start = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000); + var key = sprintf('%04d-%02d', start.getFullYear(), start.getMonth()); + var label_index = false; + for(var i = 0; i < labels.length; i++) + { + if(labels[i].id == key) + { + label_index = i; + break; + } + } + if(typeof rows[label_index] === 'undefined') + { + rows[label_index] = []; + } + rows[label_index].push(event); + + // end in a different month? + var end = new Date(event.end); + end = new Date(end.valueOf() + end.getTimezoneOffset() * 60 * 1000); + var end_key = sprintf('%04d-%02d',end.getFullYear(),end.getMonth()); + var year = start.getFullYear(); + var month = start.getMonth(); + key = sprintf('%04d-%02d',year,month); + + do + { + var end_label_index = label_index; + + for(var i = end_label_index; i < labels.length; i++) + { + if(labels[i].id == key) + { + end_label_index = i; + if(typeof rows[end_label_index] === 'undefined') + { + rows[end_label_index] = []; + } + break; + } + } + if(end_label_index != label_index) + { + rows[label_index].push(event); + } + if (++month > 11) + { + ++year; + month = 0; + } + key = sprintf('%04d-%02d',year,month); + } while(key <= end_key) + }, + // Draw a single row, but split up the dates + draw_row: function(sort_key, label, events) + { + var key = sort_key.split('-'); + var start = new Date(key[0]+"-"+sprintf("%02d",parseInt(key[1])+1)+"-01T00:00:00Z"); + // Use some care to avoid issues with timezones and daylight savings + var end = new Date(start); + end.setUTCMonth(start.getUTCMonth() + 1); + end.setUTCDate(1); + end.setUTCHours(0); + end.setUTCMinutes(0); + end = new Date(end.valueOf() - 1000); + end.setUTCMonth(start.getUTCMonth()); + this._drawRow(sort_key, label, events, start, end); + } + }, + // Group by category has one row for each [sub]category + category: + { + title: function() { return this.egw().lang('Category');}, + headers: function() { + var start = new Date(this.options.start_date); + var end = new Date(this.options.end_date); + var start_date = new Date(start.getUTCFullYear(), start.getUTCMonth(),start.getUTCDate()); + var end_date = new Date(end.getUTCFullYear(), end.getUTCMonth(),end.getUTCDate()); + var day_count = Math.round((end_date - start_date) /(1000*3600*24))+1; + + if(day_count >= 6) + { + this.headers.append(this._header_months(start, day_count)); + } + if(day_count < 120) + { + var weeks = this._header_weeks(start, day_count); + this.headers.append(weeks); + this.grid.append(weeks); + } + if(day_count < 60) + { + var days = this._header_days(start, day_count); + this.headers.append(days); + this.grid.append(days); + } + if(day_count <= 7) + { + var hours = this._header_hours(start, day_count); + this.headers.append(hours); + this.grid.append(hours); + } + }, + row_labels: function() { + var im = this.getInstanceManager(); + var categories = et2_selectbox.cat_options({ + _type:'select-cat', + getInstanceManager: function() {return im;} + },{application: 'calendar'}); + + var labels = []; + if(!app.calendar.state.cat_id || + app.calendar.state.cat_id.toString() === '' || + app.calendar.state.cat_id.toString() == '0' + ) + { + app.calendar.state.cat_id = ''; + labels.push({id:'',value:'',label: egw.lang('none'), main: '', data: {}}); + labels = labels.concat(categories); + } + else + { + var cat_id = app.calendar.state.cat_id; + if(typeof cat_id == 'string') + { + cat_id = cat_id.split(','); + } + for(var i = 0; i < cat_id.length; i++) + { + // Find label for that category + for(var j = 0; j < categories.length; j++) + { + if(categories[j].value == cat_id[i]) + { + categories[j].id = categories[j].value; + labels.push(categories[j]); + break; + } + } + + // Get its children immediately + egw.json( + 'EGroupware\\Api\\Etemplate\\Widget\\Select::ajax_get_options', + ['select-cat',',,,calendar,'+cat_id[i]], + function(data) { + labels = labels.concat(data); + } + ).sendRequest(false); + } + } + + for(var i = labels.length -1; i >= 0; i--) + { + labels[i].id = labels[i].value; + labels[i].data = { + cat_id: labels[i].id, + main: labels[i].value==labels[i].main + }; + if(labels[i].children && labels[i].children.length) + { + labels[i].data.has_children = true; + } + } + return labels; + }, + group: function(labels, rows, event) { + var cats = event.category; + if(typeof event.category === 'string') + { + cats = cats.split(','); + } + for(var cat = 0; cat < cats.length; cat++) + { + var label_index = false; + var category = cats[cat] ? parseInt(cats[cat],10) : false; + if(category == 0 || !category) category = ''; + for(var i = 0; i < labels.length; i++) + { + if(labels[i].id == category) + { + // If there's no cat filter, only show the top level + if(!app.calendar.state.cat_id) + { + for(var j = 0; j < labels.length; j++) + { + if(labels[j].id == labels[i].main) + { + label_index = j; + break; + } + } + break; + } + label_index = i; + break; + } + } + if(label_index !== false && typeof rows[label_index] === 'undefined') + { + rows[label_index] = []; + } + if(label_index !== false && rows[label_index].indexOf(event) === -1) + { + rows[label_index].push(event); + } + } + }, + draw_row: function(sort_key, label, events) { + var row = this._drawRow(sort_key, label,events,this.options.start_date, this.options.end_date); + if(this.options.hide_empty && !events.length) + { + row.set_disabled(true); + } + return row; + } + } + }; + + /** + * Something changed, and the planner needs to be re-drawn. We wait a bit to + * avoid re-drawing twice if start and end date both changed, then recreate. + * + * @param {boolean} trigger =false Trigger an event once things are done. + * Waiting until invalidate completes prevents 2 updates when changing the date range. + * @returns {undefined} + */ + invalidate( trigger?) + { + + // Busy + if(!this.doInvalidate) return; + + // Not yet ready + if(!this.options.start_date || !this.options.end_date) return; + + // Wait a bit to see if anything else changes, then re-draw the days + if(this.update_timer !== null) + { + window.clearTimeout(this.update_timer); + } + this.update_timer = window.setTimeout(jQuery.proxy(function() { + this.widget.doInvalidate = false; + + // Show AJAX loader + this.widget.loader.show(); + + this.widget.cache = {}; + this._deferred_row_updates = {}; + + this.widget._fetch_data(); + + this.widget._drawGrid(); + + if(this.trigger) + { + this.widget.change(); + } + this.widget.update_timer = null; + this.widget.doInvalidate = true; + + window.setTimeout(jQuery.proxy(function() {if(this.loader) this.loader.hide();},this.widget),500); + },{widget:this,"trigger":trigger}),et2_dataview_grid.ET2_GRID_INVALIDATE_TIMEOUT); + } + + detachFromDOM() + { + // Remove the binding to the change handler + jQuery(this.div).off("change.et2_calendar_timegrid"); + + return super.detachFromDOM(); + } + + attachToDOM( ) + { + let result = super.attachToDOM(); + + // Add the binding for the event change handler + jQuery(this.div).on("change.et2_calendar_timegrid", '.calendar_calEvent', this, function(e) { + // Make sure function gets a reference to the widget + var args = Array.prototype.slice.call(arguments); + if(args.indexOf(this) == -1) args.push(this); + + return e.data.event_change.apply(e.data, args); + }); + + // Add the binding for the change handler + jQuery(this.div).on("change.et2_calendar_timegrid", '*:not(.calendar_calEvent)', this, function(e) { + return e.data.change.call(e.data, e, this); + }); + + return result; + } + + getDOMNode(_sender) + { + if(_sender === this || !_sender) + { + return this.div[0]; + } + if(_sender._parent === this) + { + return this.rows[0]; + } + } + + /** + * Creates all the DOM nodes for the planner grid + * + * Any existing nodes (& children) are removed, the headers & labels are + * determined according to the current group_by value, and then the rows + * are created. + * + * @method + * @private + * + */ + _drawGrid() + { + + this.div.css('height', this.options.height); + + // Clear old events + var delete_index = this._children.length - 1; + while(this._children.length > 0 && delete_index >= 0) + { + this._children[delete_index].destroy(); + this.removeChild(this._children[delete_index--]); + } + + // Clear old rows + this.rows.empty() + .append(this.grid); + this.grid.empty(); + + var grouper = this.grouper; + if(!grouper) return; + + // Headers + this.headers.empty(); + this.headerTitle.text(grouper.title.apply(this)); + grouper.headers.apply(this); + this.grid.find('*').contents().filter(function(){ + return this.nodeType === 3; + }).remove(); + + // Get the rows / labels + var labels = grouper.row_labels.call(this); + + // Group the events + var events = {}; + for(var i = 0; i < this.value.length; i++) + { + grouper.group.call(this, labels, events, this.value[i]); + } + + // Set height for rows + this.rows.height(this.div.height() - this.headers.outerHeight()); + + // Draw the rows + for(var key in labels) + { + if (!labels.hasOwnProperty(key)) continue; + + // Skip sub-categories (events are merged into top level) + if(this.options.group_by == 'category' && + (!app.calendar.state.cat_id || app.calendar.state.cat_id == '') && + labels[key].id != labels[key].main + ) + { + continue; + } + var row = grouper.draw_row.call(this,labels[key].id, labels[key].label, events[key] || []); + + // Add extra data for clicking on row + if(row) + { + for(var extra in labels[key].data) + { + row.getDOMNode().dataset[extra] = labels[key].data[extra]; + } + } + } + + // Adjust header if there's a scrollbar + if(this.rows.children().last().length) + { + this.gridHeader.css('margin-right', (this.rows.width() - this.rows.children().last().width()) + 'px'); + } + // Add actual events + for(var key in this._deferred_row_updates) + { + window.clearTimeout(key); + } + window.setTimeout(jQuery.proxy(function() { + this._deferred_row_update(); + }, this ),et2_calendar_planner.DEFERRED_ROW_TIME); + this.value = []; + } + + /** + * Draw a single row of the planner + * + * @param {string} key Index into the grouped labels & events + * @param {string} label + * @param {Array} events + * @param {Date} start + * @param {Date} end + */ + _drawRow(key, label, events, start, end) + { + let row = et2_createWidget('calendar-planner_row',{ + id: 'planner_row_'+key, + label: label, + start_date: start, + end_date: end, + value: events, + readonly: this.options.readonly + },this); + + + if(this.isInTree()) + { + row.doLoadingFinished(); + } + + return row; + } + + + _header_day_of_month() + { + let day_width = 3.23; // 100.0 / 31; + + // month scale with navigation + var content = '
'; + var start = new Date(this.options.start_date); + start = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000); + var end = new Date(this.options.end_date); + end = new Date(end.valueOf() + end.getTimezoneOffset() * 60 * 1000); + + var title = this.egw().lang(date('F',start))+' '+date('Y',start)+' - '+ + this.egw().lang(date('F',end))+' '+date('Y',end); + + content += '"; + content += "
"; // end of plannerScale + + // day of month scale + content +='
'; + + for(var left = 0, i = 0; i < 31; left += day_width,++i) + { + content += '
'+ + (1+i)+"
\n"; + } + content += "
\n"; + + return content; + } + + /** + * Make a header showing the months + * @param {Date} start + * @param {number} days + * @returns {string} HTML snippet + */ + _header_months(start, days) + { + var content = '
'; + var days_in_month = 0; + var day_width = 100 / days; + var end = new Date(start); + end.setUTCDate(end.getUTCDate()+days); + var t = new Date(start.valueOf()); + for(var left = 0,i = 0; i < days;t.setUTCDate(1),t.setUTCMonth(t.getUTCMonth()+1),left += days_in_month*day_width,i += days_in_month) + { + var u = new Date(t.getUTCFullYear(),t.getUTCMonth()+1,0,-t.getTimezoneOffset()/60); + days_in_month = 1+ ((u-t) / (24*3600*1000)); + + var first = new Date(t.getUTCFullYear(),t.getUTCMonth(),1,-t.getTimezoneOffset()/60); + if(days_in_month <= 0) break; + + if (i + days_in_month > days) + { + days_in_month = days - i; + } + var title = this.egw().lang(date('F',new Date(t.valueOf() + t.getTimezoneOffset() * 60 * 1000))); + if (days_in_month > 10) + { + title += ' '+t.getUTCFullYear(); + } + else if (days_in_month < 5) + { + title = ' '; + } + content += '
'+ + title+"
"; + } + content += "
"; // end of plannerScale + + return content; + } + + /** + * Make a header showing the week numbers + * + * @param {Date} start + * @param {number} days + * @returns {string} HTML snippet + */ + _header_weeks(start, days) + { + + var content = '
'; + var state = ''; + + // we're not using UTC so date() formatting function works + var t = new Date(start.valueOf()); + + // Make sure we're lining up on the week + var week_end = app.calendar.date.end_of_week(start); + var days_in_week = Math.floor(((week_end-start ) / (24*3600*1000))+1); + var week_width = 100 / days * (days <= 7 ? days : days_in_week); + for(var left = 0,i = 0; i < days; t.setUTCDate(t.getUTCDate() + 7),left += week_width) + { + // Avoid overflow at the end + if(days - i < 7) + { + days_in_week = days-i; + } + var usertime = new Date(t.valueOf()); + if(start.getTimezoneOffset() < 0) + { + // Gets the right week # east of GMT. West does not need it(?) + usertime.setUTCMinutes(usertime.getUTCMinutes() - start.getTimezoneOffset()); + } + + week_width = 100 / days * Math.min(days, days_in_week); + + var title = this.egw().lang('Week')+' '+app.calendar.date.week_number(usertime); + + if(start.getTimezoneOffset() > 0) + { + // Gets the right week start west of GMT + usertime.setUTCMinutes(usertime.getUTCMinutes() +start.getTimezoneOffset()); + } + state = app.calendar.date.start_of_week(usertime); + state.setUTCHours(0); + state.setUTCMinutes(0); + state = state.toJSON(); + + if(days_in_week > 1 || days == 1) + { + content += '"; + } + i+= days_in_week; + if(days_in_week != 7) + { + t.setUTCDate(t.getUTCDate() - (7 - days_in_week)); + days_in_week = 7; + } + } + content += "
"; // end of plannerScale + + return content; + } + + /** + * Make a header for some days + * + * @param {Date} start + * @param {number} days + * @returns {string} HTML snippet + */ + _header_days(start, days) + { + var day_width = 100 / days; + var content = '
'; + + // we're not using UTC so date() formatting function works + var t = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000); + for(var left = 0,i = 0; i < days; t.setDate(t.getDate()+1),left += day_width,++i) + { + if(!this.options.show_weekend && [0,6].indexOf(t.getDay()) !== -1 ) continue; + var holidays = []; + var tempDate = new Date(t); + tempDate.setMinutes(tempDate.getMinutes()-tempDate.getTimezoneOffset()); + var title = ''; + let state = new Date(t.valueOf() - t.getTimezoneOffset() * 60 * 1000); + var day_class = this.day_class_holiday(state,holidays, days); + + if (days <= 3) + { + title = this.egw().lang(date('l',t))+', '+date('j',t)+'. '+this.egw().lang(date('F',t)); + } + else if (days <= 7) + { + title = this.egw().lang(date('l',t))+' '+date('j',t); + } + else + { + title = this.egw().lang(date('D',t)).substr(0,2)+'
'+date('j',t); + } + + content += '\n"; + } + content += "
"; // end of plannerScale + + return content; + } + + /** + * Create a header with hours + * + * @param {Date} start + * @param {number} days + * @returns {string} HTML snippet for the header + */ + _header_hours(start,days) + { + var divisors = [1,2,3,4,6,8,12]; + var decr = 1; + for(var i = 0; i < divisors.length; i++) // numbers dividing 24 without rest + { + if (divisors[i] > days) break; + decr = divisors[i]; + } + var hours = days * 24; + if (days === 1) // for a single day we calculate the hours of a days, to take into account daylight saving changes (23 or 25 hours) + { + var t = new Date(start.getUTCFullYear(),start.getUTCMonth(),start.getUTCDate(),-start.getTimezoneOffset()/60); + var s = new Date(start); + s.setUTCHours(23); + s.setUTCMinutes(59); + s.setUTCSeconds(59); + hours = Math.ceil((s.getTime() - t.getTime()) / 3600000); + } + var cell_width = 100 / hours * decr; + + var content = '
'; + + // we're not using UTC so date() formatting function works + var t = new Date(start.valueOf() + start.getTimezoneOffset() * 60 * 1000); + for(var left = 0,i = 0; i < hours; left += cell_width,i += decr) + { + if(!this.options.show_weekend && [0,6].indexOf(t.getDay()) !== -1 ) continue; + var title = date(egw.preference('timeformat','calendar') == 12 ? 'ha' : 'H',t); + + content += '"; + t.setHours(t.getHours()+decr); + } + content += "
"; // end of plannerScale + + return content; + } + + /** + * Applies class for today, and any holidays for current day + * + * @param {Date} date + * @param {string[]} holiday_list Filled with a list of holidays for that day + * @param {integer} days Number of days shown in the day header + * + * @return {string} CSS Classes for the day. calendar_calBirthday, calendar_calHoliday, calendar_calToday and calendar_weekend as appropriate + */ + day_class_holiday( date,holiday_list, days?) + { + + if(!date) return ''; + + var day_class = ''; + + // Holidays and birthdays + var holidays = et2_calendar_view.get_holidays(this,date.getUTCFullYear()); + + // Pass a string rather than the date object, to make sure it doesn't get changed + this.date_helper.set_value(date.toJSON()); + var date_key = ''+this.date_helper.get_year() + sprintf('%02d',this.date_helper.get_month()) + sprintf('%02d',this.date_helper.get_date()); + if(holidays && holidays[date_key]) + { + holidays = holidays[date_key]; + for(var i = 0; i < holidays.length; i++) + { + if (typeof holidays[i]['birthyear'] !== 'undefined') + { + day_class += ' calendar_calBirthday '; + if(typeof days == 'undefined' || days <= 21) + { + day_class += ' calendar_calBirthdayIcon '; + } + + holiday_list.push(holidays[i]['name']); + } + else + { + day_class += 'calendar_calHoliday '; + + holiday_list.push(holidays[i]['name']); + } + } + } + holidays = holiday_list.join(','); + var today = new Date(); + if(date_key === ''+today.getFullYear()+ + sprintf("%02d",today.getMonth()+1)+ + sprintf("%02d",today.getDate()) + ) + { + day_class += "calendar_calToday "; + } + if(date.getUTCDay() == 0 || date.getUTCDay() == 6) + { + day_class += "calendar_weekend "; + } + return day_class; + } + + /** + * Link the actions to the DOM nodes / widget bits. + * + * @todo This currently does nothing + * @param {object} actions {ID: {attributes..}+} map of egw action information + */ + _link_actions(actions) + { + if(!this._actionObject) + { + // Get the parent? Might be a grid row, might not. Either way, it is + // just a container with no valid actions + var objectManager = egw_getObjectManager(this.getInstanceManager().app,true,1); + objectManager = objectManager.getObjectById(this.getInstanceManager().uniqueId,2) || objectManager; + var parent = objectManager.getObjectById(this.id,3) || objectManager.getObjectById(this._parent.id,3) || objectManager; + if(!parent) + { + debugger; + egw.debug('error','No parent objectManager found'); + return; + } + + for(var i = 0; i < parent.children.length; i++) + { + var parent_finder = jQuery('#'+this.div.id, parent.children[i].iface.doGetDOMNode()); + if(parent_finder.length > 0) + { + parent = parent.children[i]; + break; + } + } + } + + // This binds into the egw action system. Most user interactions (drag to move, resize) + // are handled internally using jQuery directly. + var widget_object = this._actionObject || parent.getObjectById(this.id); + var aoi = new et2_action_object_impl(this,this.getDOMNode(this)).getAOI(); + + /** + * Determine if we allow a dropped event to use the invite/change actions, + * and enable or disable them appropriately + * + * @param {egwAction} action + * @param {et2_calendar_event} event The event widget being dragged + * @param {egwActionObject} target Planner action object + */ + var _invite_enabled = function(action, event, target) + { + var event = event.iface.getWidget(); + var planner = target.iface.getWidget() || false; + //debugger; + if(event === planner || !event || !planner || + !event.options || !event.options.value.participants || !planner.options.owner + ) + { + return false; + } + var owner_match = false; + var own_row = false; + + for(var id in event.options.value.participants) + { + planner.iterateOver(function(row) { + // Check scroll section or header section + if(row.div.hasClass('drop-hover') || row.div.has(':hover')) + { + owner_match = owner_match || row.node.dataset[planner.options.group_by] === ''+id; + own_row = (row === event.getParent()); + } + }, this, et2_calendar_planner_row); + + } + var enabled = !owner_match && + // Not inside its own row + !own_row; + + widget_object.getActionLink('invite').enabled = enabled; + widget_object.getActionLink('change_participant').enabled = enabled; + + // If invite or change participant are enabled, drag is not + widget_object.getActionLink('egw_link_drop').enabled = !enabled; + }; + + aoi.doTriggerEvent = function(_event, _data) { + + // Determine target node + var event = _data.event || false; + if(!event) return; + if(_data.ui.draggable.hasClass('rowNoEdit')) return; + + /* + We have to handle the drop in the normal event stream instead of waiting + for the egwAction system so we can get the helper, and destination + */ + if(event.type === 'drop') + { + this.getWidget()._event_drop.call(jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0],this.getWidget(),event, _data.ui); + } + var drag_listener = function(event, ui) { + aoi.getWidget()._drag_helper(jQuery('.calendar_d-n-d_timeCounter',ui.helper)[0],{ + top:ui.position.top, + left: ui.position.left - jQuery(this).parent().offset().left + },0); + }; + var time = jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper); + switch(_event) + { + // Triggered once, when something is dragged into the timegrid's div + case EGW_AI_DRAG_OVER: + // Listen to the drag and update the helper with the time + // This part lets us drag between different timegrids + _data.ui.draggable.on('drag.et2_timegrid'+widget_object.id, drag_listener); + _data.ui.draggable.on('dragend.et2_timegrid'+widget_object.id, function() { + _data.ui.draggable.off('drag.et2_timegrid' + widget_object.id); + }); + if(time.length) + { + // The out will trigger after the over, so we count + time.data('count',time.data('count')+1); + } + else + { + _data.ui.helper.prepend('
'); + } + + break; + + // Triggered once, when something is dragged out of the timegrid + case EGW_AI_DRAG_OUT: + // Stop listening + _data.ui.draggable.off('drag.et2_timegrid'+widget_object.id); + // Remove any highlighted time squares + jQuery('[data-date]',this.doGetDOMNode()).removeClass("ui-state-active"); + + // Out triggers after the over, count to not accidentally remove + time.data('count',time.data('count')-1); + if(time.length && time.data('count') <= 0) + { + time.remove(); + } + break; + } + }; + + if (widget_object == null) { + // Add a new container to the object manager which will hold the widget + // objects + widget_object = parent.insertObject(false, new egwActionObject( + this.id, parent, aoi, + this._actionManager || parent.manager.getActionById(this.id) || parent.manager + ),EGW_AO_FLAG_IS_CONTAINER); + } + else + { + widget_object.setAOI(aoi); + } + // Go over the widget & add links - this is where we decide which actions are + // 'allowed' for this widget at this time + var action_links = this._get_action_links(actions); + + this._init_links_dnd(widget_object.manager, action_links); + + widget_object.updateActionLinks(action_links); + this._actionObject = widget_object; + } + + /** + * Automatically add dnd support for linking + * + * @param {type} mgr + * @param {type} actionLinks + */ + _init_links_dnd( mgr,actionLinks) + { + + if (this.options.readonly) return; + + var self = this; + + var drop_action = mgr.getActionById('egw_link_drop'); + var drop_change_participant = mgr.getActionById('change_participant'); + var drop_invite = mgr.getActionById('invite'); + var drag_action = mgr.getActionById('egw_link_drag'); + var paste_action = mgr.getActionById('egw_paste'); + + // Disable paste action + if(paste_action == null) + { + paste_action = mgr.addAction('popup', 'egw_paste', egw.lang('Paste'), egw.image('editpaste'), function(){},true); + } + paste_action.set_enabled(false); + + // Check if this app supports linking + if(!egw.link_get_registry(this.dataStorePrefix || 'calendar', 'query') || + egw.link_get_registry(this.dataStorePrefix || 'calendar', 'title')) + { + if(drop_action) + { + drop_action.remove(); + if(actionLinks.indexOf(drop_action.id) >= 0) + { + actionLinks.splice(actionLinks.indexOf(drop_action.id),1); + } + } + if(drag_action) + { + drag_action.remove(); + if(actionLinks.indexOf(drag_action.id) >= 0) + { + actionLinks.splice(actionLinks.indexOf(drag_action.id),1); + } + } + return; + } + + // Don't re-add + if(drop_action == null) + { + // Create the drop action that links entries + drop_action = mgr.addAction('drop', 'egw_link_drop', egw.lang('Create link'), egw.image('link'), function(action, source, dropped) { + // Extract link IDs + var links = []; + var id = ''; + for(var i = 0; i < source.length; i++) + { + if(!source[i].id) continue; + id = source[i].id.split('::'); + links.push({app: id[0] == 'filemanager' ? 'link' : id[0], id: id[1]}); + } + if(!links.length) + { + return; + } + if(links.length && dropped && dropped.iface.getWidget() && dropped.iface.getWidget().instanceOf(et2_calendar_event)) + { + // Link the entries + egw.json(self.egw().getAppName()+".etemplate_widget_link.ajax_link.etemplate", + dropped.id.split('::').concat([links]), + function(result) { + if(result) + { + this.egw().message('Linked'); + } + }, + self, + true, + self + ).sendRequest(); + } + },true); + + drop_action.acceptedTypes = ['default','link']; + drop_action.hideOnDisabled = true; + + // Create the drop action for moving events between planner rows + var invite_action = function(action, source, target) { + + // Extract link IDs + var links = []; + var id = ''; + for(var i = 0; i < source.length; i++) + { + // Check for no ID (invalid) or same manager (dragging an event) + if(!source[i].id) continue; + if(source[i].manager === target.manager) + { + + // Find the row, could have dropped on an event + var row = target.iface.getWidget(); + while(target.parent && row.instanceOf && !row.instanceOf(et2_calendar_planner_row)) + { + target = target.parent; + row = target.iface.getWidget(); + } + + // Leave the helper there until the update is done + var loading = action.ui.helper.clone(true).appendTo(jQuery('body')); + + // and add a loading icon so user knows something is happening + if(jQuery('.calendar_timeDemo',loading).length == 0) + { + jQuery('.calendar_calEventHeader',loading).addClass('loading'); + } + else + { + jQuery('.calendar_timeDemo',loading).after('
'); + } + + var event_data = egw.dataGetUIDdata(source[i].id).data; + et2_calendar_event.recur_prompt(event_data, function(button_id) { + if(button_id === 'cancel' || !button_id) + { + return; + } + var add_owner = [row.node.dataset.participants]; + + egw().json('calendar.calendar_uiforms.ajax_invite', [ + button_id==='series' ? event_data.id : event_data.app_id, + add_owner, + action.id === 'change_participant' ? + [source[i].iface.getWidget().getParent().node.dataset.participants] : + [] + ], + function() { loading.remove();} + ).sendRequest(true); + }); + // Ok, stop. + return false; + } + } + }; + + drop_change_participant = mgr.addAction('drop', 'change_participant', egw.lang('Move to'), egw.image('participant'), invite_action,true); + drop_change_participant.acceptedTypes = ['calendar']; + drop_change_participant.hideOnDisabled = true; + + drop_invite = mgr.addAction('drop', 'invite', egw.lang('Invite'), egw.image('participant'), invite_action,true); + drop_invite.acceptedTypes = ['calendar']; + drop_invite.hideOnDisabled = true; + } + if(actionLinks.indexOf(drop_action.id) < 0) + { + actionLinks.push(drop_action.id); + } + actionLinks.push(drop_invite.id); + actionLinks.push(drop_change_participant.id); + + // Accept other links, and files dragged from the filemanager + // This does not handle files dragged from the desktop. They are + // handled by et2_nextmatch, since it needs DOM stuff + if(drop_action.acceptedTypes.indexOf('link') == -1) + { + drop_action.acceptedTypes.push('link'); + } + + // Don't re-add + if(drag_action == null) + { + // Create drag action that allows linking + drag_action = mgr.addAction('drag', 'egw_link_drag', egw.lang('link'), 'link', function(action, selected) { + // As we wanted to have a general defaul helper interface, we return null here and not using customize helper for links + // TODO: Need to decide if we need to create a customized helper interface for links anyway + //return helper; + return null; + },true); + } + // The planner itself is not draggable, the action is there for the children + if(false && actionLinks.indexOf(drag_action.id) < 0) + { + actionLinks.push(drag_action.id); + } + drag_action.set_dragType(['link','calendar']); + } + + /** + * Get all action-links / id's of 1.-level actions from a given action object + * + * Here we are only interested in drop events. + * + * @param actions + * @returns {Array} + */ + _get_action_links(actions) + { + var action_links = []; + + // Only these actions are allowed without a selection (empty actions) + var empty_actions = ['add']; + + for(var i in actions) + { + var action = actions[i]; + if(empty_actions.indexOf(action.id) !== -1 || action.type === 'drop') + { + action_links.push(typeof action.id !== 'undefined' ? action.id : i); + } + } + // Disable automatic paste action, it doesn't have what is needed to work + action_links.push({ + "actionObj": 'egw_paste', + "enabled": false, + "visible": false + }); + return action_links; + } + + /** + * Show the current time while dragging + * Used for resizing as well as drag & drop + * + * @param {type} element + * @param {type} position + * @param {type} height + */ + _drag_helper(element, position ,height) + { + var time = this._get_time_from_position(position.left, position.top); + element.dropEnd = time; + var formatted_time = jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: time.getUTCHours(), + minute: time.getUTCMinutes(), + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") === "12")} + ); + + element.innerHTML = '
'+formatted_time+'
'; + + //jQuery(element).width(jQuery(helper).width()); + } + + /** + * Handler for dropping an event on the timegrid + * + * @param {type} planner + * @param {type} event + * @param {type} ui + */ + _event_drop( planner, event,ui) + { + var e = new jQuery.Event('change'); + e.originalEvent = event; + e.data = {start: 0}; + if (typeof this.dropEnd != 'undefined') + { + var drop_date = this.dropEnd.toJSON() ||false; + + var event_data = planner._get_event_info(ui.draggable); + var event_widget = planner.getWidgetById(event_data.widget_id); + if(event_widget) + { + event_widget._parent.date_helper.set_value(drop_date); + event_widget.options.value.start = new Date(event_widget._parent.date_helper.getValue()); + + // Leave the helper there until the update is done + var loading = ui.helper.clone().appendTo(ui.helper.parent()); + // and add a loading icon so user knows something is happening + jQuery('.calendar_timeDemo',loading).after('
'); + + event_widget.recur_prompt(function(button_id) { + if(button_id === 'cancel' || !button_id) return; + //Get infologID if in case if it's an integrated infolog event + if (event_data.app === 'infolog') + { + // If it is an integrated infolog event we need to edit infolog entry + egw().json('stylite_infolog_calendar_integration::ajax_moveInfologEvent', + [event_data.id, event_widget.options.value.start||false], + function() {loading.remove();} + ).sendRequest(true); + } + else + { + //Edit calendar event + egw().json('calendar.calendar_uiforms.ajax_moveEvent', [ + button_id==='series' ? event_data.id : event_data.app_id,event_data.owner, + event_widget.options.value.start, + planner.options.owner||egw.user('account_id') + ], + function() { loading.remove();} + ).sendRequest(true); + } + }); + } + } + } + + /** + * Use the egw.data system to get data from the calendar list for the + * selected time span. + * + */ + _fetch_data() + { + var value = []; + var fetch = false; + this.doInvalidate = false; + + for(var i = 0; i < this.registeredCallbacks.length; i++) + { + egw.dataUnregisterUID(this.registeredCallbacks[i],false,this); + } + this.registeredCallbacks.splice(0,this.registeredCallbacks.length); + + // Remember previous day to avoid multi-days duplicating + var last_data = []; + + var t = new Date(this.options.start_date); + var end = new Date(this.options.end_date); + do + { + value = value.concat(this._cache_register(t, this.options.owner, last_data)); + + t.setUTCDate(t.getUTCDate() + 1); + } + while(t < end); + + this.doInvalidate = true; + return value; + } + + /** + * Deal with registering for data cache + * + * @param Date t + * @param String owner Calendar owner + */ + _cache_register(t, owner, last_data) + { + // Cache is by date (and owner, if seperate) + var date = t.getUTCFullYear() + sprintf('%02d',t.getUTCMonth()+1) + sprintf('%02d',t.getUTCDate()); + var cache_id = CalendarApp._daywise_cache_id(date, owner); + var value = []; + + if(egw.dataHasUID(cache_id)) + { + var c = egw.dataGetUIDdata(cache_id); + if(c.data && c.data !== null) + { + // There is data, pass it along now + for(var j = 0; j < c.data.length; j++) + { + if(last_data.indexOf(c.data[j]) === -1 && egw.dataHasUID('calendar::'+c.data[j])) + { + value.push(egw.dataGetUIDdata('calendar::'+c.data[j]).data); + } + } + last_data = c.data; + } + } + else + { + fetch = true; + // Assume it's empty, if there is data it will be filled later + egw.dataStoreUID(cache_id, []); + } + this.registeredCallbacks.push(cache_id); + + egw.dataRegisterUID(cache_id, function(data) { + + if(data && data.length) + { + var invalidate = true; + + // Try to determine rows interested + var labels = []; + var events = {}; + if(this.grouper) + { + labels = this.grouper.row_labels.call(this); + invalidate = false; + } + + var im = this.getInstanceManager(); + for(var i = 0; i < data.length; i++) + { + var event = egw.dataGetUIDdata('calendar::'+data[i]); + + if(!event) continue; + events = {}; + + // Try to determine rows interested + if(event.data && this.grouper) + { + this.grouper.group.call(this, labels, events, event.data); + } + if(Object.keys(events).length > 0 ) + { + for(var label_id in events) + { + var id = ""+labels[label_id].id; + if(typeof this.cache[id] === 'undefined') + { + this.cache[id] = []; + } + if(this.cache[id].indexOf(event.data.row_id) === -1) + { + this.cache[id].push(event.data.row_id); + } + if (this._deferred_row_updates[id]) + { + window.clearTimeout(this._deferred_row_updates[id]); + } + this._deferred_row_updates[id] = window.setTimeout(jQuery.proxy(this._deferred_row_update,this,id),this.DEFERRED_ROW_TIME); + } + } + else + { + // Could be an event no row is interested in, could be a problem. + // Just redraw everything + invalidate = true; + continue; + } + + // If displaying by category, we need the infolog (or other app) categories too + if(event && event.data && event.data.app && this.options.group_by == 'category') + { + // Fake it to use the cache / call + et2_selectbox.cat_options({ + _type:'select-cat', + getInstanceManager: function() {return im;} + }, {application:event.data.app||'calendar'}); + } + } + + if(invalidate) + { + this.invalidate(false); + } + } + }, this, this.getInstanceManager().execId,this.id); + + return value; + } + + /** + * Because users may be participants in various events and the time it takes + * to create many events, we don't want to update a row too soon - we may have + * to re-draw it if we find the user / category in another event. Pagination + * makes this worse. We wait a bit before updating the row to avoid + * having to re-draw it multiple times. + * + * @param {type} id + * @returns {undefined} + */ + _deferred_row_update( id) + { + // Something's in progress, skip + if(!this.doInvalidate) return; + + this.grid.height(0); + + var id_list = typeof id === 'undefined' ? Object.keys(this.cache) : [id]; + for(var i = 0; i < id_list.length; i++) + { + var cache_id = id_list[i]; + var row = this.getWidgetById('planner_row_'+cache_id); + + window.clearTimeout(this._deferred_row_updates[cache_id]); + delete this._deferred_row_updates[cache_id]; + + if(row) + { + row._data_callback(this.cache[cache_id]); + row.set_disabled(this.options.hide_empty && this.cache[cache_id].length === 0); + } + else + { + break; + } + } + + // Updating the row may push things longer, update length + // Add 1 to keep the scrollbar, otherwise we need to recalculate the + // header widths too. + this.grid.height(this.rows[0].scrollHeight+1); + + // Adjust header if there's a scrollbar - Firefox needs this re-calculated, + // otherwise the header will be missing the margin space for the scrollbar + // in some cases + if(this.rows.children().last().length) + { + this.gridHeader.css('margin-right', (this.rows.width() - this.rows.children().last().width()) + 'px'); + } + } + + /** + * Provide specific data to be displayed. + * This is a way to set start and end dates, owner and event data in once call. + * + * @param {Object[]} events Array of events, indexed by date in Ymd format: + * { + * 20150501: [...], + * 20150502: [...] + * } + * Days should be in order. + * + */ + set_value(events) + { + if(typeof events !== 'object') return false; + + super.set_value(events); + + // Planner uses an array, not map + var val = this.value; + var array = []; + Object.keys(this.value).forEach(function (key) { + array.push(val[key]); + }); + this.value = array; + } + + /** + * Change the start date + * Planner view uses a date object internally + * + * @param {string|number|Date} new_date New starting date + * @returns {undefined} + */ + set_start_date(new_date) + { + super.set_start_date(new_date); + this.options.start_date = new Date(this.options.start_date); + } + + /** + * Change the end date + * Planner view uses a date object internally + * + * @param {string|number|Date} new_date New end date + * @returns {undefined} + */ + set_end_date(new_date) + { + super.set_end_date(new_date); + this.options.end_date = new Date(this.options.end_date); + } + + /** + * Change how the planner is grouped + * + * @param {string|number} group_by 'user', 'month', or an integer category ID + * @returns {undefined} + */ + set_group_by(group_by) + { + if(isNaN(group_by) && typeof this.groupers[group_by] === 'undefined') + { + throw new Error('Invalid group_by "'+group_by+'"'); + } + var old = this.options.group_by; + this.options.group_by = ''+group_by; + + this.grouper = this.groupers[isNaN(this.options.group_by) ? this.options.group_by : 'category']; + + if(old !== this.options.group_by && this.isAttached()) + { + this.invalidate(true); + } + } + + /** + * Turn on or off the visibility of weekends + * + * @param {boolean} weekends + */ + set_show_weekend(weekends) + { + weekends = weekends ? true : false; + if(this.options.show_weekend !== weekends) + { + this.options.show_weekend = weekends; + if(this.isAttached()) + { + this.invalidate(); + } + } + } + + /** + * Turn on or off the visibility of hidden (empty) rows + * + * @param {boolean} hidden + */ + set_hide_empty(hidden) + { + this.options.hide_empty = hidden; + } + + /** + * Call change handler, if set + * + * @param {type} event + */ + change( event) + { + if (this.onchange) + { + if(typeof this.onchange == 'function') + { + // Make sure function gets a reference to the widget + var args = Array.prototype.slice.call(arguments); + if(args.indexOf(this) == -1) args.push(this); + + return this.onchange.apply(this, args); + } else { + return (et2_compileLegacyJS(this.options.onchange, this, _node))(); + } + } + } + + /** + * Call event change handler, if set + * + * @param {type} event + * @param {type} dom_node + */ + event_change( event, dom_node) + { + if (this.onevent_change) + { + var event_data = this._get_event_info(dom_node); + var event_widget = this.getWidgetById(event_data.widget_id); + et2_calendar_event.recur_prompt(event_data, jQuery.proxy(function(button_id, event_data) { + // No need to continue + if(button_id === 'cancel') return false; + + if(typeof this.onevent_change == 'function') + { + // Make sure function gets a reference to the widget + var args = Array.prototype.slice.call(arguments); + + if(args.indexOf(event_widget) == -1) args.push(event_widget); + + // Put button ID in event + event.button_id = button_id; + + return this.onevent_change.apply(this, [event, event_widget, button_id]); + } else { + return (et2_compileLegacyJS(this.options.onevent_change, event_widget, dom_node))(); + } + },this)); + } + return false; + } + + /** + * Click handler calling custom handler set via onclick attribute to this.onclick + * + * This also handles all its own actions, including navigation. If there is + * an event associated with the click, it will be found and passed to the + * onclick function. + * + * @param {Event} _ev + * @returns {boolean} Continue processing event (true) or stop (false) + */ + click(_ev) + { + var result = true; + + // Drag to create in progress + if(this.drag_create.start !== null) return; + + // Is this click in the event stuff, or in the header? + if(!this.options.readonly && this.gridHeader.has(_ev.target).length === 0 && !jQuery(_ev.target).hasClass('calendar_plannerRowHeader')) + { + // Event came from inside, maybe a calendar event + var event = this._get_event_info(_ev.originalEvent.target); + if(typeof this.onclick == 'function') + { + // Make sure function gets a reference to the widget, splice it in as 2. argument if not + var args = Array.prototype.slice.call(arguments); + if(args.indexOf(this) == -1) args.splice(1, 0, this); + + result = this.onclick.apply(this, args); + } + + if(event.id && result && !this.options.disabled && !this.options.readonly) + { + et2_calendar_event.recur_prompt(event); + + return false; + } + else if (!event.id) + { + // Clicked in row, but not on an event + // Default handler to open a new event at the selected time + if(jQuery(event.target).closest('.calendar_eventRows').length == 0) + { + // "Invalid" times, from space after the last planner row, or header + var date = this._get_time_from_position(_ev.pageX - this.grid.offset().left, _ev.pageY - this.grid.offset().top); + } + else if(this.options.group_by == 'month') + { + var date = this._get_time_from_position(_ev.clientX, _ev.clientY); + } + else + { + var date = this._get_time_from_position(_ev.offsetX, _ev.offsetY); + } + var row = jQuery(_ev.target).closest('.calendar_plannerRowWidget'); + var data = row.length ? row[0].dataset : {}; + if(date) + { + app.calendar.add(jQuery.extend({ + start: date.toJSON(), + hour: date.getUTCHours(), + minute: date.getUTCMinutes() + }, data)); + return false; + } + } + return result; + } + else if (this.gridHeader.has(_ev.target).length > 0 && !jQuery.isEmptyObject(_ev.target.dataset) || + jQuery(_ev.target).hasClass('calendar_plannerRowHeader') && !jQuery.isEmptyObject(_ev.target.dataset)) + { + // Click on a header, we can go there + _ev.data = jQuery.extend({},_ev.target.parentNode.dataset, _ev.target.dataset); + for(var key in _ev.data) + { + if(!_ev.data[key]) + { + delete _ev.data[key]; + } + } + app.calendar.update_state(_ev.data); + } + else if (!this.options.readonly) + { + // Default handler to open a new event at the selected time + // TODO: Determine date / time more accurately from position + app.calendar.add({ + date: _ev.target.dataset.date || this.options.start_date.toJSON(), + hour: _ev.target.dataset.hour || this.options.day_start, + minute: _ev.target.dataset.minute || 0 + }); + return false; + } + } + + /** + * Get time from position + * + * @param {number} x + * @param {number} y + * @returns {Date|Boolean} A time for the given position, or false if one + * could not be determined. + */ + _get_time_from_position( x,y) + { + + x = Math.round(x); + y = Math.round(y); + + // Round to user's preferred event interval + var interval = egw.preference('interval','calendar') || 30; + + // Relative horizontal position, as a percentage + var width = 0; + jQuery('.calendar_eventRows',this.div).each(function() {width = Math.max(width,jQuery(this).width());}); + var rel_x = Math.min(x / width,1); + + // Relative time, in minutes from start + var rel_time = 0; + + var day_header = jQuery('.calendar_plannerScaleDay',this.headers); + + // Simple math, the x is offset from start date + if(this.options.group_by !== 'month' && ( + // Either all days are visible, or only 1 day (no day header) + this.options.show_weekend || day_header.length === 0 + )) + { + rel_time = (new Date(this.options.end_date) - new Date(this.options.start_date))*rel_x/1000; + this.date_helper.set_value(this.options.start_date.toJSON()); + } + // Not so simple math, need to account for missing days + else if(this.options.group_by !== 'month' && !this.options.show_weekend) + { + // Find which day + if(day_header.length === 0) return false; + var day = document.elementFromPoint( + day_header.offset().left + rel_x * this.headers.innerWidth(), + day_header.offset().top + ); + + // Use day, and find time in that day + if(day && day.dataset && day.dataset.date) + { + this.date_helper.set_value(day.dataset.date); + rel_time = ((x - jQuery(day).position().left) / jQuery(day).outerWidth(true)) * 24*60; + this.date_helper.set_minutes(Math.round(rel_time/interval) * interval); + return new Date(this.date_helper.getValue()); + } + return false; + } + else + { + // Find the correct row so we know which month, then get the offset + var hidden_nodes = []; + var row = null; + // Hide any drag or tooltips that may interfere + do + { + row = document.elementFromPoint(x, y); + if(this.div.has(row).length == 0) + { + hidden_nodes.push(jQuery(row).hide()); + } + else + { + break; + } + } while(row && row.nodeName !== 'BODY'); + if(!row) return false; + + // Restore hidden nodes + for(var i = 0; i < hidden_nodes.length; i++) + { + hidden_nodes[i].show(); + } + row = jQuery(row).closest('.calendar_plannerRowWidget'); + + + var row_widget = null; + for(var i = 0; i < this._children.length && row.length > 0; i++) + { + if(this._children[i].div[0] == row[0]) + { + row_widget = this._children[i]; + break; + } + } + if(row_widget) + { + // Not sure where the extra -1 and +2 are coming from, but it makes it work out + // in FF & Chrome + rel_x = Math.min((x-row_widget.rows.offset().left-1)/(row_widget.rows.width()+2),1); + + // 2678400 is the number of seconds in 31 days + rel_time = (2678400)*rel_x; + this.date_helper.set_value(row_widget.options.start_date.toJSON()); + } + else + { + return false; + } + } + if(rel_time < 0) return false; + + this.date_helper.set_minutes(Math.round(rel_time / (60 * interval))*interval); + + return new Date(this.date_helper.getValue()); + } + + /** + * Mousedown handler to support drag to create + * + * @param {jQuery.Event} event + */ + _mouse_down(event) + { + // Only left mouse button + if(event.which !== 1) return; + + // Ignore headers + if(this.headers.has(event.target).length !== 0) return false; + + // Get time at mouse + if(this.options.group_by === 'month') + { + var time = this._get_time_from_position(event.clientX, event.clientY); + } + else + { + var time = this._get_time_from_position(event.offsetX, event.offsetY); + } + if(!time) return false; + + // Find the correct row so we know the parent + var row = event.target.closest('.calendar_plannerRowWidget'); + for(var i = 0; i < this._children.length && row; i++) + { + if(this._children[i].div[0] === row) + { + this.drag_create.parent = this._children[i]; + // Clear cached events for re-layout + this._children[i]._cached_rows = []; + break; + } + } + if(!this.drag_create.parent) return false; + + this.div.css('cursor', 'ew-resize'); + + return this._drag_create_start(jQuery.extend({},this.drag_create.parent.node.dataset,{date: time.toJSON()})); + } + + /** + * Mouseup handler to support drag to create + * + * @param {jQuery.Event} event + */ + _mouse_up(event) + { + // Get time at mouse + if(this.options.group_by === 'month') + { + var time = this._get_time_from_position(event.clientX, event.clientY); + } + else + { + var time = this._get_time_from_position(event.offsetX, event.offsetY); + } + + return this._drag_create_end(time ? {date: time.toJSON()} : false); + } + + /** + * Code for implementing et2_IDetachedDOM + * + * @param {array} _attrs array to add further attributes to + */ + getDetachedAttributes( _attrs) + { + _attrs.push('start_date','end_date'); + } + + getDetachedNodes() + { + return [this.getDOMNode()]; + } + + setDetachedAttributes( _nodes, _values) + { + this.div = jQuery(_nodes[0]); + + if(_values.start_date) + { + this.set_start_date(_values.start_date); + } + if(_values.end_date) + { + this.set_end_date(_values.end_date); + } + } + + // Resizable interface + resize() + { + // Take the whole tab height + var height = Math.min(jQuery(this.getInstanceManager().DOMContainer).height(),jQuery(this.getInstanceManager().DOMContainer).parent().innerHeight()); + + // Allow for toolbar + height -= jQuery('#calendar-toolbar',this.div.parents('.egw_fw_ui_tab_content')).outerHeight(true); + + this.options.height = height; + this.div.css('height', this.options.height); + // Set height for rows + this.rows.height(this.div.height() - this.headers.outerHeight()); + + this.grid.height(this.rows[0].scrollHeight); + } + + /** + * Set up for printing + * + * @return {undefined|Deferred} Return a jQuery Deferred object if not done setting up + * (waiting for data) + */ + beforePrint( ) + { + + if(this.disabled || !this.div.is(':visible')) + { + return; + } + this.rows.css('overflow-y', 'visible'); + + var rows = jQuery('.calendar_eventRows'); + var width = rows.width(); + var events = jQuery('.calendar_calEvent', rows) + .each(function() { + var event = jQuery(this); + event.width((event.width() / width) * 100 + '%') + }); + + } + + /** + * Reset after printing + */ + afterPrint( ) + { + this.rows.css('overflow-y', 'auto'); + } +} +et2_register_widget(et2_calendar_planner, ["calendar-planner"]); diff --git a/calendar/js/et2_widget_planner_row.ts b/calendar/js/et2_widget_planner_row.ts new file mode 100644 index 0000000000..c747aded76 --- /dev/null +++ b/calendar/js/et2_widget_planner_row.ts @@ -0,0 +1,839 @@ +/* + * Egroupware + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package + * @subpackage + * @link http://www.egroupware.org + * @author Nathan Gray + * @version $Id$ + */ + +/*egw:uses + /calendar/js/et2_widget_view.js; + /calendar/js/et2_widget_daycol.js; + /calendar/js/et2_widget_event.js; +*/ + + +import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget"; +import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget"; +import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance"; +import {et2_date} from "../../api/js/etemplate/et2_widget_date"; +import {et2_action_object_impl} from "../../api/js/etemplate/et2_core_DOMWidget"; +import {et2_calendar_planner} from "./et2_widget_planner"; + +/** + * Class for one row of a planner + * + * This widget is responsible for the label on the side + * + */ +export class et2_calendar_planner_row extends et2_valueWidget implements et2_IResizeable +{ + static readonly _attributes: any = { + start_date: { + name: "Start date", + type: "any" + }, + end_date: { + name: "End date", + type: "any" + }, + value: { + type: "any" + } + }; + private div: JQuery; + private title: JQuery; + private rows: JQuery; + private _date_helper: et2_date; + private _cached_rows: any[]; + private _row_height = 20; + private _actionObject: egwActionObject; + + /** + * Constructor + */ + constructor(_parent, _attrs? : WidgetConfig, _child? : object) + { + // Call the inherited constructor + super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_planner_row._attributes, _child || {})); + + // Main container + this.div = jQuery(document.createElement("div")) + .addClass("calendar_plannerRowWidget") + .css('width',this.options.width); + this.title = jQuery(document.createElement('div')) + .addClass("calendar_plannerRowHeader") + .appendTo(this.div); + this.rows = jQuery(document.createElement('div')) + .addClass("calendar_eventRows") + .appendTo(this.div); + + this.setDOMNode(this.div[0]); + + // Used for its date calculations + this._date_helper = et2_createWidget('date-time', {}, null); + this._date_helper.loadingFinished(); + + this.set_start_date(this.options.start_date); + this.set_end_date(this.options.end_date); + + this._cached_rows = []; + } + + doLoadingFinished( ) + { + super.doLoadingFinished(); + + this.set_label(this.options.label); + this._draw(); + + // Actions are set on the parent, so we need to explicitly get in here + // and get ours + this._link_actions(this.getParent().options.actions || []); + return true; + } + + destroy( ) + { + super.destroy(); + + // date_helper has no parent, so we must explicitly remove it + this._date_helper.destroy(); + this._date_helper = null; + } + + getDOMNode(_sender) + { + if(_sender === this || !_sender) + { + return this.div[0]; + } + if(_sender._parent === this) + { + return this.rows[0]; + } + } + + /** + * Link the actions to the DOM nodes / widget bits. + * + * @param {object} actions {ID: {attributes..}+} map of egw action information + */ + _link_actions(actions) + { + // Get the parent? Might be a grid row, might not. Either way, it is + // just a container with no valid actions + let objectManager = egw_getObjectManager(this.getInstanceManager().app, true, 1); + objectManager = objectManager.getObjectById(this.getInstanceManager().uniqueId,2) || objectManager; + let parent = objectManager.getObjectById(this.id, 1) || objectManager.getObjectById(this.getParent().id, 1) || objectManager; + if(!parent) + { + egw.debug('error','No parent objectManager found'); + return; + } + + // This binds into the egw action system. Most user interactions (drag to move, resize) + // are handled internally using jQuery directly. + let widget_object = this._actionObject || parent.getObjectById(this.id); + const aoi = new et2_action_object_impl(this, this.getDOMNode(this)).getAOI(); + const planner = this.getParent(); + + for(let i = 0; i < parent.children.length; i++) + { + const parent_finder = jQuery(parent.children[i].iface.doGetDOMNode()).find(this.div); + if(parent_finder.length > 0) + { + parent = parent.children[i]; + break; + } + } + + // Determine if we allow a dropped event to use the invite/change actions + const _invite_enabled = function (action, event, target) + { + var event = event.iface.getWidget(); + const row = target.iface.getWidget() || false; + if(event === row || !event || !row || + !event.options || !event.options.value.participants + ) + { + return false; + } + + let owner_match = false; + const own_row = event.getParent() === row; + + for (let id in event.options.value.participants) + { + owner_match = owner_match || row.node.dataset.participants === '' + id; + } + + const enabled = !owner_match && + // Not inside its own timegrid + !own_row; + + widget_object.getActionLink('invite').enabled = enabled; + widget_object.getActionLink('change_participant').enabled = enabled; + + // If invite or change participant are enabled, drag is not + widget_object.getActionLink('egw_link_drop').enabled = !enabled; + }; + + aoi.doTriggerEvent = function(_event, _data) { + + // Determine target node + var event = _data.event || false; + if(!event) return; + if(_data.ui.draggable.hasClass('rowNoEdit')) return; + /* + We have to handle the drop in the normal event stream instead of waiting + for the egwAction system so we can get the helper, and destination + */ + if(event.type === 'drop' && widget_object.getActionLink('egw_link_drop').enabled) + { + this.getWidget().getParent()._event_drop.call( + jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0], + this.getWidget().getParent(), event, _data.ui, + this.getWidget() + ); + } + const drag_listener = function (_event, ui) + { + if(planner.options.group_by === 'month') + { + var position = {left: _event.clientX, top: _event.clientY}; + } + else + { + var position = {top: ui.position.top, left: ui.position.left - jQuery(this).parent().offset().left}; + } + aoi.getWidget().getParent()._drag_helper( + jQuery('.calendar_d-n-d_timeCounter', ui.helper)[0], + position, 0 + ); + + let event = _data.ui.draggable.data('selected')[0]; + if(!event || event.id && event.id.indexOf('calendar') !== 0) + { + event = false; + } + if(event) + { + _invite_enabled( + widget_object.getActionLink('invite').actionObj, + event, + widget_object + ); + } + }; + const time = jQuery('.calendar_d-n-d_timeCounter', _data.ui.helper); + switch(_event) + { + // Triggered once, when something is dragged into the timegrid's div + case EGW_AI_DRAG_OVER: + // Listen to the drag and update the helper with the time + // This part lets us drag between different timegrids + _data.ui.draggable.on('drag.et2_timegrid_row'+widget_object.id, drag_listener); + _data.ui.draggable.on('dragend.et2_timegrid_row'+widget_object.id, function() { + _data.ui.draggable.off('drag.et2_timegrid_row' + widget_object.id); + }); + widget_object.iface.getWidget().div.addClass('drop-hover'); + + // Disable invite / change actions for same calendar or already participant + var event = _data.ui.draggable.data('selected')[0]; + if(!event || event.id && event.id.indexOf('calendar') !== 0) + { + event = false; + } + if(event) + { + _invite_enabled( + widget_object.getActionLink('invite').actionObj, + event, + widget_object + ); + } + if(time.length) + { + // The out will trigger after the over, so we count + time.data('count',time.data('count')+1); + } + else + { + _data.ui.helper.prepend('
'); + } + + + break; + + // Triggered once, when something is dragged out of the timegrid + case EGW_AI_DRAG_OUT: + // Stop listening + _data.ui.draggable.off('drag.et2_timegrid_row'+widget_object.id); + // Remove highlight + widget_object.iface.getWidget().div.removeClass('drop-hover'); + + // Out triggers after the over, count to not accidentally remove + time.data('count',time.data('count')-1); + if(time.length && time.data('count') <= 0) + { + time.remove(); + } + break; + } + }; + + if (widget_object == null) { + // Add a new container to the object manager which will hold the widget + // objects + widget_object = parent.insertObject(false, new egwActionObject( + this.id, parent, aoi, + this._actionManager|| parent.manager.getActionById(this.id) || parent.manager + )); + } + else + { + widget_object.setAOI(aoi); + } + this._actionObject = widget_object; + + // Delete all old objects + widget_object.clear(); + widget_object.unregisterActions(); + + // Go over the widget & add links - this is where we decide which actions are + // 'allowed' for this widget at this time + const action_links = this._get_action_links(actions); + + this.getParent()._init_links_dnd(widget_object.manager, action_links); + + widget_object.updateActionLinks(action_links); + } + + /** + * Get all action-links / id's of 1.-level actions from a given action object + * + * Here we are only interested in drop events. + * + * @param actions + * @returns {Array} + */ + _get_action_links(actions) + { + const action_links = []; + + // Only these actions are allowed without a selection (empty actions) + const empty_actions = ['add']; + + for(let i in actions) + { + const action = actions[i]; + if(empty_actions.indexOf(action.id) !== -1 || action.type == 'drop') + { + action_links.push(typeof action.id != 'undefined' ? action.id : i); + } + } + return action_links; + } + + /** + * Draw the individual divs for weekends and events + */ + _draw( ) + { + // Remove any existing + this.rows.remove('.calendar_eventRowsMarkedDay,.calendar_eventRowsFiller').nextAll().remove(); + + let days = 31; + let width = '100'; + if (this.getParent().options.group_by === 'month') + { + days = this.options.end_date.getUTCDate(); + + if(days < 31) + { + const diff = 31 - days; + width = 'calc('+(diff * 3.23) + '% - ' + (diff * 7) + 'px)'; + } + } + + // mark weekends and other special days in yearly planner + if (this.getParent().options.group_by == 'month') + { + this.rows.append(this._yearlyPlannerMarkDays(this.options.start_date, days)); + } + + if (this.getParent().options.group_by === 'month' && days < 31) + { + // add a filler for non existing days in that month + this.rows.after('
'); + } + } + + set_label(label) + { + this.options.label = label; + this.title.text(label); + if(this.getParent().options.group_by === 'month') + { + this.title.attr('data-date', this.options.start_date.toJSON()); + this.title.attr('data-sortby', 'user'); + this.title.addClass('et2_clickable et2_link'); + } + else + { + this.title.attr('data-date',''); + this.title.removeClass('et2_clickable'); + } + } + + /** + * Change the start date + * + * @param {Date} new_date New end date + * @returns {undefined} + */ + set_start_date(new_date) + { + if(!new_date || new_date === null) + { + throw new TypeError('Invalid end date. ' + new_date.toString()); + } + + this.options.start_date = new Date(typeof new_date == 'string' ? new_date : new_date.toJSON()); + this.options.start_date.setUTCHours(0); + this.options.start_date.setUTCMinutes(0); + this.options.start_date.setUTCSeconds(0); + } + /** + * Change the end date + * + * @param {string|number|Date} new_date New end date + * @returns {undefined} + */ + set_end_date(new_date) + { + if(!new_date || new_date === null) + { + throw new TypeError('Invalid end date. ' + new_date.toString()); + } + + this.options.end_date = new Date(typeof new_date == 'string' ? new_date : new_date.toJSON()); + this.options.end_date.setUTCHours(23); + this.options.end_date.setUTCMinutes(59); + this.options.end_date.setUTCSeconds(59); + } + + /** + * Mark special days (birthdays, holidays) on the planner + * + * @param {Date} start Start of the month + * @param {number} days How many days in the month + */ + _yearlyPlannerMarkDays(start,days) + { + const day_width = 3.23; + const t = new Date(start); + let content = ''; + for(let i = 0; i < days; i++) + { + const holidays = []; + // TODO: implement this, pull / copy data from et2_widget_timegrid + const day_class = (this.getParent()).day_class_holiday(t, holidays); + + if (day_class) // no regular weekday + { + content += '
'; + } + t.setUTCDate(t.getUTCDate()+1); + } + return content; + } + + /** + * Callback used when the daywise data changes + * + * Events should update themselves when their data changes, here we are + * dealing with a change in which events are displayed on this row. + * + * @param {String[]} event_ids + * @returns {undefined} + */ + _data_callback( event_ids) + { + const events = []; + if(event_ids == null || typeof event_ids.length == 'undefined') event_ids = []; + for(let i = 0; i < event_ids.length; i++) + { + let event = egw.dataGetUIDdata('calendar::' + event_ids[i]); + event = event && event.data || false; + if(event && event.date) + { + events.push(event); + } + else if (event) + { + // Got an ID that doesn't belong + event_ids.splice(i--,1); + } + } + if(!this.getParent().disabled && event_ids.length > 0) + { + this.resize(); + this._update_events(events); + } + } + + get date_helper(): et2_date + { + return this._date_helper; + } + + /** + * Load the event data for this day and create event widgets for each. + * + * If event information is not provided, it will be pulled from the content array. + * + * @param {Object[]} [events] Array of event information, one per event. + */ + _update_events(events) + { + // Remove all events + while(this._children.length > 0) + { + const node = this._children[this._children.length - 1]; + this.removeChild(node); + node.destroy(); + } + this._cached_rows = []; + + for(var c = 0; c < events.length; c++) + { + // Create event + var event = et2_createWidget('calendar-event',{ + id:'event_'+events[c].row_id, + value: events[c] + },this); + } + + // Seperate loop so column sorting finds all children in the right place + for(var c = 0; c < events.length; c++) + { + let event = this.getWidgetById('event_'+events[c].row_id); + if(!event) continue; + if(this.isInTree()) + { + event.doLoadingFinished(); + } + } + } + + /** + * Position the event according to it's time and how this widget is laid + * out. + * + * @param {undefined|Object|et2_calendar_event} event + */ + position_event(event?) + { + const rows = this._spread_events(); + const height = rows.length * this._row_height; + let row_width = this.rows.width(); + if(row_width == 0) + { + // Not rendered yet or something + row_width = this.getParent().gridHeader.width() - this.title.width() + } + row_width -= 15; + + for(let c = 0; c < rows.length; c++) + { + // Calculate vertical positioning + const top = c * (100.0 / rows.length); + + for(let i = 0; (rows[c].indexOf(event) >=0 || !event) && i < rows[c].length; i++) + { + // Calculate horizontal positioning + const left = this._time_to_position(rows[c][i].options.value.start); + const width = this._time_to_position(rows[c][i].options.value.end) - left; + + // Position the event + rows[c][i].div.css('top', top+'%'); + rows[c][i].div.css('height', (100/rows.length)+'%'); + rows[c][i].div.css('left', left.toFixed(1)+'%'); + rows[c][i].div.outerWidth((width/100 * row_width) +'px'); + } + } + if(height) + { + this.div.height(height+'px'); + } + } + + /** + * Sort a day's events into non-overlapping rows + * + * @returns {Array[]} Events sorted into rows + */ + _spread_events() + { + // Keep it so we don't have to re-do it when the next event asks + let cached_length = 0; + this._cached_rows.map(function(row) {cached_length+=row.length;}); + if(cached_length === this._children.length) + { + return this._cached_rows; + } + + // sorting the events in non-overlapping rows + const rows = []; + const row_end = [0]; + + // Sort in chronological order, so earliest ones are at the top + this._children.sort(function(a,b) { + const start = new Date(a.options.value.start) - new Date(b.options.value.start); + const end = new Date(a.options.value.end) - new Date(b.options.value.end); + // Whole day events sorted by ID, normal events by start / end time + if(a.options.value.whole_day && b.options.value.whole_day) + { + // Longer duration comes first so we have nicer bars across the top + const duration = + (new Date(b.options.value.end) - new Date(b.options.value.start)) - + (new Date(a.options.value.end) - new Date(a.options.value.start)); + + return duration ? duration : (a.options.value.app_id - b.options.value.app_id); + } + else if (a.options.value.whole_day || b.options.value.whole_day) + { + return a.options.value.whole_day ? -1 : 1; + } + return start ? start : end; + }); + + for(let n = 0; n < this._children.length; n++) + { + const event = this._children[n].options.value || false; + if(typeof event.start !== 'object') + { + this._date_helper.set_value(event.start); + event.start = new Date(this._date_helper.getValue()); + } + if(typeof event.end !== 'object') + { + this._date_helper.set_value(event.end); + event.end = new Date(this._date_helper.getValue()); + } + if(typeof event['start_m'] === 'undefined') + { + let day_start = event.start.valueOf() / 1000; + const dst_check = new Date(event.start); + dst_check.setUTCHours(12); + + // if daylight saving is switched on or off, correct $day_start + // gives correct times after 2am, times between 0am and 2am are wrong + const daylight_diff = day_start + 12 * 60 * 60 - (dst_check.valueOf() / 1000); + if(daylight_diff) + { + day_start -= daylight_diff; + } + + event['start_m'] = event.start.getUTCHours() * 60 + event.start.getUTCMinutes(); + if (event['start_m'] < 0) + { + event['start_m'] = 0; + event['multiday'] = true; + } + event['end_m'] = event.end.getUTCHours() * 60 + event.end.getUTCMinutes(); + if (event['end_m'] >= 24*60) + { + event['end_m'] = 24*60-1; + event['multiday'] = true; + } + if(!event.start.getUTCHours() && !event.start.getUTCMinutes() && event.end.getUTCHours() == 23 && event.end.getUTCMinutes() == 59) + { + event.whole_day_on_top = (event.non_blocking && event.non_blocking != '0'); + } + } + + // Skip events entirely on hidden weekends + if(this._hidden_weekend_event(event)) + { + const node = this._children[n]; + this.removeChild(n--); + node.destroy(); + continue; + } + + const event_start = new Date(event.start).valueOf(); + for(var row = 0; row_end[row] > event_start; ++row); // find a "free" row (no other event) + if(typeof rows[row] === 'undefined') rows[row] = []; + rows[row].push(this._children[n]); + row_end[row] = new Date(event['end']).valueOf(); + } + this._cached_rows = rows; + return rows; + } + + /** + * Check to see if the event is entirely on a hidden weekend + * + * @param values Array of event values, not an et2_widget_event + */ + _hidden_weekend_event(values) + { + if(!this.getParent() || this.getParent().options.group_by == 'month' || this.getParent().options.show_weekend) + { + return false; + } + // Starts on Saturday or Sunday, ends Sat or Sun, less than 2 days long + else if([0,6].indexOf(values.start.getUTCDay()) !== -1 && [0,6].indexOf(values.end.getUTCDay()) !== -1 + && values.end - values.start < 2 * 24 * 3600 * 1000) + { + return true; + } + return false; + } + + /** + * Calculates the horizontal position based on the time given, as a percentage + * between the start and end times + * + * @param {int|Date|string} time in minutes from midnight, or a Date in string or object form + * @param {int|Date|string} start Earliest possible time (0%) + * @param {int|Date|string} end Latest possible time (100%) + * @return {float} position in percent + */ + _time_to_position(time, start?, end?) + { + let pos = 0.0; + + // Handle the different value types + start = this.options.start_date; + end = this.options.end_date; + + if(typeof start === 'string') + { + start = new Date(start); + end = new Date(end); + } + const wd_start = 60 * (parseInt(''+egw.preference('workdaystarts', 'calendar')) || 9); + const wd_end = 60 * (parseInt(''+egw.preference('workdayends', 'calendar')) || 17); + + let t = time; + if(typeof time === 'number' && time < 3600) + { + t = new Date(start.valueOf() + wd_start * 3600*1000); + } + else + { + t = new Date(time); + } + + // Limits + if(t <= start) return 0; // We are left of our scale + if(t >= end) return 100; // We are right of our scale + + // Remove space for weekends, if hidden + let weekend_count = 0; + let weekend_before = 0; + let partial_weekend = 0; + if(this.getParent().options.group_by !== 'month' && this.getParent() && !this.getParent().options.show_weekend) + { + + const counter_date = new Date(start); + do + { + if([0,6].indexOf(counter_date.getUTCDay()) !== -1) + { + if(counter_date.getUTCDate() === t.getUTCDate() && counter_date.getUTCMonth() === t.getUTCMonth()) + { + // Event is partially on a weekend + partial_weekend += (t.getUTCHours() *60 + t.getUTCMinutes())*60*1000; + } + else if(counter_date < t) + { + weekend_before++; + } + weekend_count++; + } + counter_date.setUTCDate(counter_date.getUTCDate() + 1); + } while(counter_date < end); + // Put it in ms + weekend_before *= 24 * 3600 * 1000; + weekend_count *= 24 * 3600 * 1000; + } + + // Basic scaling, doesn't consider working times + pos = (t - start - weekend_before-partial_weekend) / (end - start - weekend_count); + + // Month view + if(this.getParent().options.group_by !== 'month') + { + // Daywise scaling + /* Needs hourly scales that consider working hours + var start_date = new Date(start.getUTCFullYear(), start.getUTCMonth(),start.getUTCDate()); + var end_date = new Date(end.getUTCFullYear(), end.getUTCMonth(),end.getUTCDate()); + var t_date = new Date(t.getUTCFullYear(), t.getUTCMonth(),t.getUTCDate()); + + var days = Math.round((end_date - start_date) / (24 * 3600 * 1000))+1; + pos = 1 / days * Math.round((t_date - start_date) / (24*3600 * 1000)); + + var time_of_day = typeof t === 'object' ? 60 * t.getUTCHours() + t.getUTCMinutes() : t; + + if (time_of_day >= wd_start) + { + var day_percentage = 0.1; + if (time_of_day > wd_end) + { + day_percentage = 1; + } + else + { + var wd_length = wd_end - wd_start; + if (wd_length <= 0) wd_length = 24*60; + day_percentage = (time_of_day-wd_start) / wd_length; // between 0 and 1 + } + pos += day_percentage / days; + } + */ + } + else + { + // 2678400 is the number of seconds in 31 days + pos = (t - start) / 2678400000; + } + pos = 100 * pos; + + return pos; + } + + // Resizable interface + /** + * Resize + * + * Parent takes care of setting proper width & height for the containing div + * here we just need to adjust the events to fit the new size. + */ + resize () + { + if(this.disabled || !this.div.is(':visible') || this.getParent().disabled) + { + return; + } + + const row = jQuery('
').appendTo(this.rows); + this._row_height = (parseInt(window.getComputedStyle(row[0]).getPropertyValue("height")) || 20); + row.remove(); + + // Resize & position all events + this.position_event(); + } + +} +et2_register_widget(et2_calendar_planner_row, ["calendar-planner_row"]); \ No newline at end of file diff --git a/calendar/js/et2_widget_timegrid.ts b/calendar/js/et2_widget_timegrid.ts new file mode 100644 index 0000000000..de5bb08933 --- /dev/null +++ b/calendar/js/et2_widget_timegrid.ts @@ -0,0 +1,2389 @@ +/* + * Egroupware Calendar timegrid + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package etemplate + * @subpackage api + * @link http://www.egroupware.org + * @author Nathan Gray + * @version $Id$ + */ + + +/*egw:uses + /calendar/js/et2_widget_view.js; +*/ + +import {et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget"; +import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance"; +import {et2_calendar_view} from "./et2_widget_view"; +import {et2_action_object_impl} from "../../api/js/etemplate/et2_core_DOMWidget"; +import {et2_dataview_grid} from "../../api/js/etemplate/et2_dataview_view_grid"; + +/** + * Class which implements the "calendar-timegrid" XET-Tag for displaying a span of days + * + * This widget is responsible for the times on the side, and it is also the + * controller for both positioning and setting the day columns. Day columns are + * recycled rather than removed and re-created to reduce reloading. Similarly, + * the horizontal time grid (when used - see granularity attribute) is only + * redrawn or resized when needed. Unfortunately resizing is needed every time + * the all day section has an event added or removed so the full work day from + * start time to end time is properly displayed. + * + * + * @augments et2_calendar_view + */ +export class et2_calendar_timegrid extends et2_calendar_view implements et2_IDetachedDOM, et2_IResizeable,et2_IPrint +{ + static readonly _attributes : any = { + value: { + type: "any", + description: "An array of events, indexed by date (Ymd format)." + }, + day_start: { + name: "Day start time", + type: "string", + default: parseInt(''+egw.preference('workdaystarts','calendar')) || 9, + description: "Work day start time. If unset, this will default to the current user's preference" + }, + day_end: { + name: "Day end time", + type: "string", + default: parseInt(''+egw.preference('workdayends','calendar')) || 17, + description: "Work day end time. If unset, this will default to the current user's preference" + }, + show_weekend: { + name: "Weekends", + type: "boolean", + // @ts-ignore + default: egw.preference('days_in_weekview','calendar') != 5, + description: "Display weekends. The date range should still include them for proper scrolling, but they just won't be shown." + }, + granularity: { + name: "Granularity", + type: "integer", + default: parseInt(''+egw.preference('interval','calendar')) || 30, + description: "How many minutes per row, or 0 to display events as a list" + }, + "onchange": { + "name": "onchange", + "type": "js", + "default": et2_no_init, + "description": "JS code which is executed when the date range changes." + }, + "onevent_change": { + "name": "onevent_change", + "type": "js", + "default": et2_no_init, + "description": "JS code which is executed when an event changes." + }, + height: { + "default": '100%' + } + }; + private gridHeader: JQuery; + private dayHeader: JQuery; + private scrolling: JQuery; + private days: JQuery; + private owner: any; + private gridHover: JQuery; + + private day_list: any[]; + private day_widgets: any[]; + private resize_timer: number; + private _top_time: number; + private rowHeight: number; + private daily_owner: boolean = false; + private _drop_data: any; + private day_start: any; + private day_end: any; + + /** + * Constructor + * + * @memberOf et2_calendar_timegrid + */ + constructor(_parent, _attrs? : WidgetConfig, _child? : object) + { + // Call the inherited constructor + super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_timegrid._attributes, _child || {})); + + + // Main container + this.div = jQuery(document.createElement("div")) + .addClass("calendar_calTimeGrid") + .addClass("calendar_TimeGridNoLabel"); + + // Headers + this.gridHeader = jQuery(document.createElement("div")) + .addClass("calendar_calGridHeader") + .appendTo(this.div); + this.dayHeader = jQuery(document.createElement("div")) + .appendTo(this.gridHeader); + + // Contains times / rows + this.scrolling = jQuery(document.createElement('div')) + .addClass("calendar_calTimeGridScroll") + .appendTo(this.div) + .append('
'); + + // Contains days / columns + this.days = jQuery(document.createElement("div")) + .addClass("calendar_calDayCols") + .appendTo(this.scrolling); + + // Used for owners + this.owner = et2_createWidget('description',{},this); + + this._labelContainer = jQuery(document.createElement("label")) + .addClass("et2_label et2_link") + .appendTo(this.gridHeader); + + this.gridHover = jQuery('
'); + + // List of dates in Ymd + // The first one should be start_date, last should be end_date + this.day_list = []; + this.day_widgets = []; + + // Timer to re-scale time to fit + this.resize_timer = null; + + this.setDOMNode(this.div[0]); + } + + destroy( ) + { + // Stop listening to tab changes + if(typeof framework !== 'undefined' && framework.getApplicationByName('calendar').tab) + { + jQuery(framework.getApplicationByName('calendar').tab.contentDiv).off('show.' + this.id); + } + + super.destroy(); + + // Delete all old objects + this._actionObject.clear(); + this._actionObject.unregisterActions(); + this._actionObject.remove(); + this._actionObject = null; + + this.div.off(); + this.div = null; + this.gridHeader = null; + this.dayHeader = null; + this.days = null; + this.scrolling = null; + this._labelContainer = null; + + // Stop the resize timer + if(this.resize_timer) + { + window.clearTimeout(this.resize_timer); + } + } + + doLoadingFinished( ) + { + super.doLoadingFinished(); + + // Listen to tab show to make sure we scroll to the day start, not top + if(typeof framework !== 'undefined' && framework.getApplicationByName('calendar').tab) + { + jQuery(framework.getApplicationByName('calendar').tab.contentDiv) + .on('show.' + this.id, jQuery.proxy( + function() + { + if(this.scrolling) + { + this.scrolling.scrollTop(this._top_time); + } + },this) + ); + } + + // Need to get the correct internal sizing + this.resize(); + + this._drawGrid(); + + // Actions may be set on a parent, so we need to explicitly get in here + // and get ours + this._link_actions(this.options.actions || this.getParent().options.actions || []); + + // Automatically bind drag and resize for every event using jQuery directly + // - no action system - + var timegrid = this; + + /** + * If user puts the mouse over an event, then we'll set up resizing so + * they can adjust the length. Should be a little better on resources + * than binding it for every calendar event, and we won't need exceptions + * for planner view to resize horizontally. + */ + this.div.on('mouseover', '.calendar_calEvent:not(.ui-resizable):not(.rowNoEdit)', function() { + // Only resize in timegrid + if(timegrid.options.granularity === 0) return; + + // Load the event + timegrid._get_event_info(this); + var that = this; + + //Resizable event handler + jQuery(this).resizable + ({ + distance: 10, + // Grid matching preference + grid: [10000,timegrid.rowHeight], + autoHide: false, + handles: 's,se', + containment:'parent', + + /** + * Triggered when the resizable is created. + * + * @param {event} event + * @param {Object} ui + */ + create:function(event, ui) + { + var resizeHelper = event.target.getAttribute('data-resize'); + if (resizeHelper == 'WD' || resizeHelper == 'WDS') + { + jQuery(this).resizable('destroy'); + } + }, + + /** + * If dragging to resize an event, abort drag to create + * + * @param {jQuery.Event} event + * @param {Object} ui + */ + start: function(event, ui) + { + if(timegrid.drag_create.start) + { + // Abort drag to create, we're dragging to resize + timegrid._drag_create_end({}); + } + }, + + /** + * Triggered at the end of resizing the calEvent. + * + * @param {event} event + * @param {Object} ui + */ + stop:function(event, ui) + { + var e = new jQuery.Event('change'); + e.originalEvent = event; + e.data = {duration: 0}; + var event_data = timegrid._get_event_info(this); + var event_widget = timegrid.getWidgetById(event_data.widget_id); + var sT = event_widget.options.value.start_m; + if (typeof this.dropEnd != 'undefined' && this.dropEnd.length == 1) + { + var eT = (parseInt(timegrid._drop_data.hour) * 60) + parseInt(timegrid._drop_data.minute); + e.data.duration = ((eT - sT)/60) * 3600; + + if(event_widget) + { + event_widget.options.value.end_m = eT; + event_widget.options.value.duration = e.data.duration; + } + jQuery(this).trigger(e); + event_widget._update(event_widget.options.value); + + // That cleared the resize handles, so remove for re-creation... + if(jQuery(this).resizable('instance')) + { + jQuery(this).resizable('destroy'); + } + } + // Clear the helper, re-draw + if(event_widget && event_widget._parent) + { + event_widget._parent.position_event(event_widget); + } + timegrid.div.children('.drop-hover').removeClass('.drop-hover'); + }, + + /** + * Triggered during the resize, on the drag of the resize handler + * + * @param {event} event + * @param {Object} ui + */ + resize:function(event, ui) + { + // Add a bit for better understanding - it will show _to_ the start, + // covering the 'actual' target + timegrid._get_time_from_position(ui.helper[0].getBoundingClientRect().left, ui.helper[0].getBoundingClientRect().bottom+5); + timegrid.gridHover.hide(); + var drop = timegrid._drag_helper(this,ui.element[0]); + if(drop && !drop.is(':visible')) + { + drop.get(0).scrollIntoView(false); + } + } + }); + }); + + // Customize and override some draggable settings + this.div + .on('dragcreate','.calendar_calEvent', function(event, ui) { + jQuery(this).draggable('option','cancel','.rowNoEdit'); + // Act like you clicked the header, makes it easier to position + // but put it to the side (-5) so we can still do the hover + jQuery(this).draggable('option','cursorAt', {top: 5, left: -5}); + }) + .on('dragstart', '.calendar_calEvent', function(event,ui) { + jQuery('.calendar_calEvent',ui.helper).width(jQuery(this).width()) + .height(jQuery(this).outerHeight()) + .css('top', '').css('left','') + .appendTo(ui.helper); + ui.helper.width(jQuery(this).width()); + + // Cancel drag to create, we're dragging an existing event + timegrid.drag_create.start = null; + timegrid._drag_create_end(); + }) + .on('mousemove', function(event) { + timegrid._get_time_from_position(event.clientX, event.clientY); + }) + .on('mouseout', function(event) { + if(timegrid.div.has(event.relatedTarget).length === 0) + { + timegrid.gridHover.hide(); + } + }) + .on('mousedown', jQuery.proxy(this._mouse_down, this)) + .on('mouseup', jQuery.proxy(this._mouse_up, this)); + + return true; + } + + _createNamespace() { + return true; + } + + /** + * Show the current time while dragging + * Used for resizing as well as drag & drop + * + * @param {type} element + * @param {type} helper + * @param {type} height + */ + _drag_helper(element, helper,height) + { + if(!element) return; + + element.dropEnd = this.gridHover; + + if(element.dropEnd.length) + { + this._drop_data = jQuery.extend({},element.dropEnd[0].dataset || {}); + } + + if (typeof element.dropEnd != 'undefined' && element.dropEnd.length) + { + // Make sure the target is visible in the scrollable day + if(this.gridHover.is(':visible')) + { + if(this.scrolling.scrollTop() > 0 && this.scrolling.scrollTop() >= this.gridHover.position().top - this.rowHeight) + { + this.scrolling.scrollTop(this.gridHover.position().top-this.rowHeight); + } + else if (this.scrolling.scrollTop() + this.scrolling.height() <= this.gridHover.position().top + (2*this.rowHeight)) + { + this.scrolling.scrollTop(this.scrolling.scrollTop() + this.rowHeight); + } + } + var time = ''; + if(this._drop_data.whole_day) + { + time = this.egw().lang('Whole day'); + } + else if (this.options.granularity === 0) + { + // No times, keep what's in the event + // Add class to helper to keep formatting + jQuery(helper).addClass('calendar_calTimeGridList'); + } + else + { + // @ts-ignore + time = jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: element.dropEnd.attr('data-hour'), + minute: element.dropEnd.attr('data-minute'), + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") == "12")} + ); + } + element.innerHTML = '
'+time+'
'; + } + else + { + element.innerHTML = '
'; + } + jQuery(element).width(jQuery(helper).width()); + return element.dropEnd; + } + + /** + * Handler for dropping an event on the timegrid + * + * @param {type} timegrid + * @param {type} event + * @param {type} ui + * @param {type} dropEnd + */ + _event_drop( timegrid, event,ui, dropEnd) + { + var e = new jQuery.Event('change'); + e.originalEvent = event; + e.data = {start: 0}; + + if(typeof dropEnd != 'undefined' && dropEnd) + { + var drop_date = dropEnd.date||false; + + var event_data = timegrid._get_event_info(ui.draggable); + var event_widget = timegrid.getWidgetById(event_data.widget_id); + if(!event_widget) + { + // Widget was moved across weeks / owners + event_widget = timegrid.getParent().getWidgetById(event_data.widget_id); + } + if(event_widget) + { + // Send full string to avoid rollover between months using set_month() + event_widget._parent.date_helper.set_value( + drop_date.substring(0,4)+'-'+drop_date.substring(4,6)+'-'+drop_date.substring(6,8)+ + 'T00:00:00Z' + ); + + + // Make sure whole day events stay as whole day events by ignoring drop time + if(event_data.app == 'calendar' && event_widget.options.value.whole_day) + { + event_widget._parent.date_helper.set_hours(0); + event_widget._parent.date_helper.set_minutes(0); + } + else if (timegrid.options.granularity === 0) + { + // List, not time grid - keep time + event_widget._parent.date_helper.set_hours(event_widget.options.value.start.getUTCHours()); + event_widget._parent.date_helper.set_minutes(event_widget.options.value.start.getUTCMinutes()); + } + else + { + // Non-whole day events, and integrated apps, can change + event_widget._parent.date_helper.set_hours(dropEnd.whole_day ? 0 : dropEnd.hour||0); + event_widget._parent.date_helper.set_minutes(dropEnd.whole_day ? 0 : dropEnd.minute||0); + } + + // Leave the helper there until the update is done + var loading = ui.helper.clone(true).appendTo(jQuery('body')); + // and add a loading icon so user knows something is happening + if(jQuery('.calendar_timeDemo',loading).length == 0) + { + jQuery('.calendar_calEventHeader',loading).addClass('loading'); + } + else + { + jQuery('.calendar_timeDemo',loading).after('
'); + } + + event_widget.recur_prompt(function(button_id) { + if(button_id === 'cancel' || !button_id) + { + // Need to refresh the event with original info to clean up + var app_id = event_widget.options.value.app_id ? event_widget.options.value.app_id : event_widget.options.value.id + (event_widget.options.value.recur_type ? ':'+event_widget.options.value.recur_date : ''); + egw().dataStoreUID('calendar::'+app_id,egw.dataGetUIDdata('calendar::'+app_id).data); + loading.remove(); + return; + } + let duration : string | number | boolean; + //Get infologID if in case if it's an integrated infolog event + if (event_data.app === 'infolog') + { + // Duration - infologs are always non-blocking + duration = dropEnd.whole_day ? 86400-1 : ( + event_widget.options.value.whole_day ? (egw().preference('defaultlength','calendar')*60) : false); + + // If it is an integrated infolog event we need to edit infolog entry + egw().json('stylite_infolog_calendar_integration::ajax_moveInfologEvent', + [event_data.app_id, event_widget._parent.date_helper.getValue()||false,duration], + function() {loading.remove();} + ).sendRequest(true); + } + else + { + //Edit calendar event + + // Duration - check for whole day dropped on a time, change it to full days + duration = event_widget.options.value.whole_day && dropEnd.hour ? + // Make duration whole days, less 1 second + (Math.round((event_widget.options.value.end - event_widget.options.value.start) / (1000 * 86400)) * 86400) -1 : + false; + // Event (whole day or not) dropped on whole day section, change to whole day non blocking + if(dropEnd.whole_day) duration = 'whole_day'; + + // Send the update + var _send = function(series_instance) + { + var start = new Date(event_widget._parent.date_helper.getValue()); + + egw().json('calendar.calendar_uiforms.ajax_moveEvent', [ + button_id==='series' ? event_data.id : event_data.app_id,event_data.owner, + start, + timegrid.options.owner||egw.user('account_id'), + duration, + series_instance + ], + function() { loading.remove();} + ).sendRequest(true); + }; + + // Check for modifying a series that started before today + if (event_widget.options.value.recur_type && button_id === 'series') + { + event_widget.series_split_prompt(function(_button_id) { + if (_button_id === et2_dialog.OK_BUTTON) + { + _send(event_widget.options.value.recur_date); + } + else + { + loading.remove(); + } + }); + } + else + { + _send(event_widget.options.value.recur_date); + } + } + }); + } + } + } + + /** + * Something changed, and the days need to be re-drawn. We wait a bit to + * avoid re-drawing twice if start and end date both changed, then recreate + * the days. + * The whole grid is not regenerated because times aren't expected to change, + * just the days. + * + * @param {boolean} [trigger=false] Trigger an event once things are done. + * Waiting until invalidate completes prevents 2 updates when changing the date range. + * @returns {undefined} + */ + invalidate( trigger?) + { + + // Reset the list of days + this.day_list = []; + + // Wait a bit to see if anything else changes, then re-draw the days + if(this.update_timer) + { + window.clearTimeout(this.update_timer); + } + this.update_timer = window.setTimeout(jQuery.proxy(function() { + this.widget.update_timer = null; + window.clearTimeout(this.resize_timer); + this.widget.loader.hide().show(); + + // Update actions + if(this.widget._actionManager) + { + this.widget._link_actions(this.widget._actionManager.children); + } + + this.widget._drawDays(); + // We have to completely re-do times, as they may have changed in + // scale to the point where more labels are needed / need to be removed + this.widget._drawTimes(); + if(this.trigger) + { + this.widget.change(); + } + // Hide loader + window.setTimeout(jQuery.proxy(function() {this.loader.hide();},this.widget),200); + },{widget:this,"trigger":trigger}),et2_dataview_grid.ET2_GRID_INVALIDATE_TIMEOUT); + } + + detachFromDOM( ) + { + // Remove the binding to the change handler + jQuery(this.div).off(".et2_calendar_timegrid"); + + return super.detachFromDOM(); + } + + attachToDOM( ) + { + let result = super.attachToDOM(); + + // Add the binding for the event change handler + jQuery(this.div).on("change.et2_calendar_timegrid", '.calendar_calEvent', this, function(e) { + // Make sure function gets a reference to the widget + var args = Array.prototype.slice.call(arguments); + if(args.indexOf(this) == -1) args.push(this); + + return e.data.event_change.apply(e.data, args); + }); + + // Add the binding for the change handler + jQuery(this.div).on("change.et2_calendar_timegrid", '*:not(.calendar_calEvent)', this, function(e) { + return e.data.change.call(e.data, e, this); + }); + + // Catch resize and prevent it from bubbling further, triggering + // etemplate's resize + this.div.on('resize', this, function(e) { + e.stopPropagation(); + }); + + return result; + } + + getDOMNode( _sender) + { + if(_sender === this || !_sender) + { + return this.div ? this.div[0] : null; + } + else if (_sender.instanceOf(et2_calendar_daycol)) + { + return this.days ? this.days[0] : null; + } + else if (_sender) + { + return this.gridHeader ? this.gridHeader[0] : null; + } + } + + set_disabled( disabled) + { + var old_value = this.options.disabled; + super.set_disabled(disabled); + if(disabled) + { + this.loader.show(); + } + else if (old_value !== disabled) + { + // Scroll to start of day - stops jumping in FF + // For some reason on Chrome & FF this doesn't quite get the day start + // to the top, so add 2px; + this.scrolling.scrollTop(this._top_time+2); + } + + } + + /** + * Clear everything, and redraw the whole grid + */ + _drawGrid( ) + { + + this.div.css('height', this.options.height) + .empty(); + this.loader.prependTo(this.div).show(); + + // Draw in the horizontal - the times + this._drawTimes(); + + // Draw in the vertical - the days + this.invalidate(); + } + + /** + * Creates the DOM nodes for the times in the left column, and the horizontal + * lines (mostly via CSS) that span the whole date range. + */ + _drawTimes( ) + { + jQuery('.calendar_calTimeRow',this.div).remove(); + + this.div.toggleClass('calendar_calTimeGridList', this.options.granularity === 0); + + this.gridHeader + .attr('data-date', this.options.start_date) + .attr('data-owner', this.options.owner) + .append(this._labelContainer) + .append(this.owner.getDOMNode()) + .append(this.dayHeader) + .appendTo(this.div); + + // Max with 18 avoids problems when it's not shown + var header_height = Math.max(this.gridHeader.outerHeight(true), 18); + + this.scrolling + .appendTo(this.div) + .off(); + + // No time grid - list + if(this.options.granularity === 0) + { + this.scrolling.css('height','100%'); + this.days.css('height', '100%'); + this.iterateOver(function(day) { + day.resize(); + },this,et2_calendar_daycol); + return; + } + + var wd_start = 60*this.options.day_start; + var wd_end = 60*this.options.day_end; + var granularity = this.options.granularity; + var totalDisplayMinutes = wd_end - wd_start; + var rowsToDisplay = Math.ceil((totalDisplayMinutes+60)/granularity); + var row_count = (1440 / this.options.granularity); + + + this.scrolling + .on('scroll', jQuery.proxy(this._scroll, this)); + + // Percent + var rowHeight = (100/rowsToDisplay).toFixed(1); + // Pixels + this.rowHeight = this.scrolling.height() / rowsToDisplay; + + // We need a reasonable bottom limit here, but resize will handle it + // if we get too small + if(this.rowHeight < 5 && this.div.is(':visible')) + { + if(this.rowHeight === 0) + { + // Something is not right... + this.rowHeight = 5; + } + } + + // the hour rows + var show = { + 5 : [0,15,30,45], + 10 : [0,30], + 15 : [0,30], + 45 : [0,15,30,45] + }; + var html = ''; + var line_height = parseInt(this.div.css('line-height')); + this._top_time = 0; + for(var t = 0,i = 0; t < 1440; t += granularity,++i) + { + if(t <= wd_start && t + granularity > wd_start) + { + this._top_time = this.rowHeight * (i+1+(wd_start - (t+granularity))/granularity); + } + var working_hours = (t >= wd_start && t < wd_end) ? ' calendar_calWorkHours' : ''; + html += '
'; + // show time for full hours, always for 45min interval and at least on every 3 row + // @ts-ignore + var time = jQuery.datepicker.formatTime( + egw.preference("timeformat") === "12" ? "h:mmtt" : "HH:mm", + { + hour: t / 60, + minute: t % 60, + seconds: 0, + timezone: 0 + }, + {"ampm": (egw.preference("timeformat") === "12")} + ); + + var time_label = (typeof show[granularity] === 'undefined' ? t % 60 === 0 : show[granularity].indexOf(t % 60) !== -1) ? time : ''; + if(time_label && egw.preference("timeformat") == "12" && time_label.split(':')[0] < 10) + { + time_label ='  ' + time_label; + } + html += '
'+time_label+"
\n"; + } + + // Set heights in pixels for scrolling + jQuery('.calendar_calTimeLabels',this.scrolling) + .empty() + .height(this.rowHeight*i) + .append(html); + this.days.css('height', (this.rowHeight*i)+'px'); + this.gridHover.css('height', this.rowHeight); + + // Scroll to start of day + this.scrolling.scrollTop(this._top_time); + } + + /** + * As window size and number of all day non-blocking events change, we need + * to re-scale the time grid to make sure the full working day is shown. + * + * We use a timeout to avoid doing it multiple times if redrawing or resizing. + */ + resizeTimes( ) + { + + // Hide resizing from user + this.loader.show(); + + // Wait a bit to see if anything else changes, then re-draw the times + if(this.resize_timer) + { + window.clearTimeout(this.resize_timer); + } + // No point if it is just going to be redone completely + if(this.update_timer) return; + + this.resize_timer = window.setTimeout(jQuery.proxy(function() { + if(this._resizeTimes) + { + this.resize_timer = null; + + this._resizeTimes(); + } + },this),1); + } + + /** + * Re-scale the time grid to make sure the full working day is shown. + * This is the timeout callback that does the actual re-size immediately. + */ + _resizeTimes( ) + { + + if(!this.div.is(':visible')) + { + return; + } + var wd_start = 60*this.options.day_start; + var wd_end = 60*this.options.day_end; + var totalDisplayMinutes = wd_end - wd_start; + var rowsToDisplay = Math.ceil((totalDisplayMinutes+60)/this.options.granularity); + var row_count = (1440 / this.options.granularity); + + var new_height = this.scrolling.height() / rowsToDisplay; + var old_height = this.rowHeight; + this.rowHeight = new_height; + + jQuery('.calendar_calTimeLabels', this.scrolling).height(this.rowHeight*row_count); + this.days.css('height', this.options.granularity === 0 ? + '100%' : + (this.rowHeight*row_count)+'px' + ); + + // Scroll to start of day + this._top_time = (wd_start * this.rowHeight) / this.options.granularity; + // For some reason on Chrome & FF this doesn't quite get the day start + // to the top, so add 2px; + this.scrolling.scrollTop(this._top_time+2); + + if(this.rowHeight != old_height) + { + this.iterateOver(function(child) { + if(child === this) return; + child.resize(); + },this, et2_IResizeable); + } + + this.loader.hide(); + } + + /** + * Set up the needed day widgets to correctly display the selected date + * range. First we calculate the needed dates, then we create any needed + * widgets. Existing widgets are recycled rather than discarded. + */ + _drawDays( ) + { + this.scrolling.append(this.days); + + // If day list is still empty, recalculate it from start & end date + if(this.day_list.length === 0 && this.options.start_date && this.options.end_date) + { + this.day_list = this._calculate_day_list(this.options.start_date, this.options.end_date, this.options.show_weekend); + } + // For a single day, we show each owner in their own daycol + this.daily_owner = this.day_list.length === 1 && + this.options.owner.length > 1 && + this.options.owner.length < (parseInt(''+egw.preference('day_consolidate','calendar')) || 6); + var daycols_needed = this.daily_owner ? this.options.owner.length : this.day_list.length; + var day_width = ( Math.min( jQuery(this.getInstanceManager().DOMContainer).width(),this.days.width())/daycols_needed); + if(!day_width || !this.day_list) + { + // Hidden on another tab, or no days for some reason + var dim = egw.getHiddenDimensions(this.days, false); + day_width = ( dim.w /Math.max(daycols_needed,1)); + } + + // Create any needed widgets - otherwise, we'll just recycle + // Add any needed day widgets (now showing more days) + var add_index = 0; + var before = true; + + while(daycols_needed > this.day_widgets.length) + { + var existing_index = this.day_widgets[add_index] && !this.daily_owner ? + this.day_list.indexOf(this.day_widgets[add_index].options.date) : + -1; + before = existing_index > add_index; + + var day = et2_createWidget('calendar-daycol',{ + owner: this.options.owner, + width: (before ? 0 : day_width) + "px" + },this); + if(this.isInTree()) + { + day.doLoadingFinished(); + } + if(existing_index != -1 && parseInt(this.day_list[add_index]) < parseInt(this.day_list[existing_index])) + { + this.day_widgets.unshift(day); + jQuery(this.getDOMNode(day)).prepend(day.getDOMNode(day)); + } + else + { + this.day_widgets.push(day); + } + add_index++; + } + // Remove any extra day widgets (now showing less) + var delete_index = this.day_widgets.length - 1; + before = false; + while(this.day_widgets.length > daycols_needed) + { + // If we're going down to an existing one, just keep it for cool CSS animation + while(delete_index > 1 && this.day_list.indexOf(this.day_widgets[delete_index].options.date) > -1) + { + delete_index--; + before = true; + } + if(delete_index < 0) delete_index = 0; + + // Widgets that are before our date shrink, after just get pushed out + if(before) + { + this.day_widgets[delete_index].set_width('0px'); + } + this.day_widgets[delete_index].div.hide(); + this.day_widgets[delete_index].header.hide(); + this.day_widgets[delete_index].destroy(); + this.day_widgets.splice(delete_index--,1); + } + + this.set_header_classes(); + + // Create / update day widgets with dates and data + for(var i = 0; i < this.day_widgets.length; i++) + { + day = this.day_widgets[i]; + + // Position + day.set_left((day_width * i) + 'px'); + day.title.removeClass('blue_title'); + if(this.daily_owner) + { + // Each 'day' is the same date, different user + day.set_id(this.day_list[0]+'-'+this.options.owner[i]); + day.set_date(this.day_list[0], false); + day.set_owner(this.options.owner[i]); + day.set_label(this._get_owner_name(this.options.owner[i])); + day.title.addClass('blue_title'); + } + else + { + // Show user name in day header even if only one + if(this.day_list.length === 1) + { + day.set_label(this._get_owner_name(this.options.owner)); + day.title.addClass('blue_title'); + } + else + { + // Go back to self-calculated date by clearing the label + day.set_label(''); + } + day.set_id(this.day_list[i]); + day.set_date(this.day_list[i], this.value[this.day_list[i]] || false); + day.set_owner(this.options.owner); + } + day.set_width(day_width + 'px'); + } + + // Adjust and scroll to start of day + this.resizeTimes(); + + // Don't hold on to value any longer, use the data cache for best info + this.value = {}; + + if(this.daily_owner) + { + this.set_label(''); + } + + // Handle not fully visible elements + this._scroll(); + + // TODO: Figure out how to do this with detached nodes + /* + var nodes = this.day_col.getDetachedNodes(); + var supportedAttrs = []; + this.day_col.getDetachedAttributes(supportedAttrs); + supportedAttrs.push("id"); + + for(var i = 0; i < day_count; i++) + { + this.day_col.setDetachedAttributes(nodes.clone(),) + } + */ + } + + /** + * Set header classes + * + */ + set_header_classes() + { + var day; + for(var i = 0; i < this.day_widgets.length; i++) + { + day = this.day_widgets[i]; + + // Classes + if(app.calendar && app.calendar.state && + this.day_list[i] && parseInt(this.day_list[i].substr(4,2)) !== new Date(app.calendar.state.date).getUTCMonth()+1) + { + day.set_class('calendar_differentMonth'); + } + else + { + day.set_class(''); + } + } + } + + /** + * Update UI while scrolling within the selected time + * + * Toggles out of view indicators and adjusts not visible headers + * @param {Event} event Scroll event + */ + private _scroll(event?) + { + if(!this.day_widgets) return; + + // Loop through days, let them deal with it + for(var day = 0; day < this.day_widgets.length; day++) + { + this.day_widgets[day]._out_of_view(); + } + } + + /** + * Calculate a list of days between start and end date, skipping weekends if + * desired. + * + * @param {Date|string} start_date Date that et2_date widget can understand + * @param {Date|string} end_date Date that et2_date widget can understand + * @param {boolean} show_weekend If not showing weekend, Saturday and Sunday + * will not be in the returned list. + * + * @returns {string[]} List of days in Ymd format + */ + _calculate_day_list( start_date, end_date, show_weekend) + { + + var day_list = []; + + this.date_helper.set_value(end_date); + var end = this.date_helper.date.getTime(); + var i = 1; + this.date_helper.set_value(new Date(start_date)); + + do + { + if(show_weekend || !show_weekend && [0,6].indexOf(this.date_helper.date.getUTCDay()) === -1 || end_date === start_date) + { + day_list.push(''+this.date_helper.get_year() + sprintf('%02d',this.date_helper.get_month()) + sprintf('%02d',this.date_helper.get_date())); + } + this.date_helper.set_date(this.date_helper.get_date()+1); + } + // Limit it to 14 days to avoid infinite loops in case something is mis-set, + // though the limit is more based on how wide the screen is + while(end >= this.date_helper.date.getTime() && i++ <= 14); + + return day_list; + } + + /** + * Link the actions to the DOM nodes / widget bits. + * + * @param {object} actions {ID: {attributes..}+} map of egw action information + */ + _link_actions(actions) + { + // Get the parent? Might be a grid row, might not. Either way, it is + // just a container with no valid actions + var objectManager = egw_getObjectManager(this.getInstanceManager().app,true,1); + objectManager = objectManager.getObjectById(this.getInstanceManager().uniqueId,2) || objectManager; + var parent = objectManager.getObjectById(this.id,1) || objectManager.getObjectById(this.getParent().id,1) || objectManager; + if(!parent) + { + debugger; + egw.debug('error','No parent objectManager found'); + return; + } + + // This binds into the egw action system. Most user interactions (drag to move, resize) + // are handled internally using jQuery directly. + var widget_object = this._actionObject || parent.getObjectById(this.id); + var aoi = new et2_action_object_impl(this,this.getDOMNode(this)).getAOI(); + + for(var i = 0; i < parent.children.length; i++) + { + var parent_finder = jQuery(parent.children[i].iface.doGetDOMNode()).find(this.div); + if(parent_finder.length > 0) + { + parent = parent.children[i]; + break; + } + } + // Determine if we allow a dropped event to use the invite/change actions + let _invite_enabled = function (action, event, target) + { + var event = event.iface.getWidget(); + var timegrid = target.iface.getWidget() || false; + if(event === timegrid || !event || !timegrid || + !event.options || !event.options.value.participants || !timegrid.options.owner + ) + { + return false; + } + var owner_match = false; + var own_timegrid = event.getParent().getParent() === timegrid && !timegrid.daily_owner; + + for (var id in event.options.value.participants) + { + if(!timegrid.daily_owner) + { + if(timegrid.options.owner === id || + timegrid.options.owner.indexOf && + timegrid.options.owner.indexOf(id) >= 0) + { + owner_match = true; + } + } + else + { + timegrid.iterateOver(function (col) + { + // Check scroll section or header section + if(col.div.has(timegrid.gridHover).length || col.header.has(timegrid.gridHover).length) + { + owner_match = owner_match || col.options.owner.indexOf(id) !== -1; + own_timegrid = (col === event.getParent()); + } + }, this, et2_calendar_daycol); + } + } + var enabled = !owner_match && + // Not inside its own timegrid + !own_timegrid; + + widget_object.getActionLink('invite').enabled = enabled; + widget_object.getActionLink('change_participant').enabled = enabled; + + // If invite or change participant are enabled, drag is not + widget_object.getActionLink('egw_link_drop').enabled = !enabled; + }; + + aoi.doTriggerEvent = function(_event, _data) { + // Determine target node + var event = _data.event || false; + if(!event) return; + if(_data.ui.draggable.hasClass('rowNoEdit')) return; + + /* + We have to handle the drop in the normal event stream instead of waiting + for the egwAction system so we can get the helper, and destination + */ + if(event.type === 'drop') + { + var dropEnd = false; + var helper = jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0]; + if(helper && helper.dropEnd && helper.dropEnd.length >= 1) + if (typeof this.dropEnd !== 'undefined' && this.dropEnd.length >= 1) + { + dropEnd = helper.dropEnd[0].dataset || false; + } + this.getWidget()._event_drop.call(jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper)[0],this.getWidget(),event, _data.ui, dropEnd); + } + var drag_listener = function(_event, ui) { + aoi.getWidget()._drag_helper(jQuery('.calendar_d-n-d_timeCounter',ui.helper)[0],ui.helper[0],0); + if(aoi.getWidget().daily_owner) + { + _invite_enabled( + widget_object.getActionLink('invite').actionObj, + event, + widget_object + ); + } + }; + var time = jQuery('.calendar_d-n-d_timeCounter',_data.ui.helper); + switch(_event) + { + // Triggered once, when something is dragged into the timegrid's div + case EGW_AI_DRAG_OVER: + // Listen to the drag and update the helper with the time + // This part lets us drag between different timegrids + _data.ui.draggable.on('drag.et2_timegrid'+widget_object.id, drag_listener); + _data.ui.draggable.on('dragend.et2_timegrid'+widget_object.id, function() { + _data.ui.draggable.off('drag.et2_timegrid' + widget_object.id); + }); + + // Remove formatting for out-of-view events (full day non-blocking) + jQuery('.calendar_calEventHeader',_data.ui.helper).css('top',''); + jQuery('.calendar_calEventBody',_data.ui.helper).css('padding-top',''); + + // Disable invite / change actions for same calendar or already participant + var event = _data.ui.draggable.data('selected')[0]; + if(!event || event.id && event.id.indexOf('calendar') !== 0) + { + event = false; + } + if(event) + { + _invite_enabled( + widget_object.getActionLink('invite').actionObj, + event, + widget_object + ); + } + + if(time.length) + { + // The out will trigger after the over, so we count + time.data('count',time.data('count')+1); + } + else + { + _data.ui.helper.prepend('
'); + } + + break; + + // Triggered once, when something is dragged out of the timegrid + case EGW_AI_DRAG_OUT: + // Stop listening + _data.ui.draggable.off('drag.et2_timegrid'+widget_object.id); + // Remove highlighted time square + var timegrid = aoi.getWidget(); + timegrid.gridHover.hide(); + timegrid.scrolling.scrollTop(timegrid._top_time); + + // Out triggers after the over, count to not accidentally remove + time.data('count',time.data('count')-1); + if(time.length && time.data('count') <= 0) + { + time.remove(); + } + break; + } + }; + + if (widget_object == null) { + // Add a new container to the object manager which will hold the widget + // objects + widget_object = parent.insertObject(false, new egwActionObject( + this.id, parent, aoi, + this._actionManager|| parent.manager.getActionById(this.id) || parent.manager + )); + } + else + { + widget_object.setAOI(aoi); + } + this._actionObject = widget_object; + + // Delete all old objects + widget_object.clear(); + widget_object.unregisterActions(); + + // Go over the widget & add links - this is where we decide which actions are + // 'allowed' for this widget at this time + var action_links = this._get_action_links(actions); + + this._init_links_dnd(widget_object.manager, action_links); + + widget_object.updateActionLinks(action_links); + } + + /** + * Automatically add dnd support for linking + * + * @param {type} mgr + * @param {type} actionLinks + */ + _init_links_dnd( mgr,actionLinks) + { + + if (this.options.readonly) return; + + var self = this; + + var drop_link = mgr.getActionById('egw_link_drop'); + var drop_change_participant = mgr.getActionById('change_participant'); + var drop_invite = mgr.getActionById('invite'); + var drag_action = mgr.getActionById('egw_link_drag'); + + // Check if this app supports linking + if(!egw.link_get_registry(this.dataStorePrefix, 'query') || + egw.link_get_registry(this.dataStorePrefix, 'title')) + { + if(drop_link) + { + drop_link.remove(); + if(actionLinks.indexOf(drop_link.id) >= 0) + { + actionLinks.splice(actionLinks.indexOf(drop_link.id),1); + } + } + if(drag_action) + { + drag_action.remove(); + if(actionLinks.indexOf(drag_action.id) >= 0) + { + actionLinks.splice(actionLinks.indexOf(drag_action.id),1); + } + } + return; + } + // Don't re-add + if(drop_link == null) + { + // Create the drop action that links entries + drop_link = mgr.addAction('drop', 'egw_link_drop', egw.lang('Create link'), egw.image('link'), function(action, source, target) { + + // Extract link IDs + var links = []; + var id = ''; + for(var i = 0; i < source.length; i++) + { + // Check for no ID (invalid) or same manager (dragging an event) + if(!source[i].id) continue; + if(source[i].manager === target.manager) + { + // Find the timegrid, could have dropped on an event + var timegrid = target.iface.getWidget(); + while(target.parent && timegrid.instanceOf && !timegrid.instanceOf(et2_calendar_timegrid)) + { + target = target.parent; + timegrid = target.iface.getWidget(); + } + + + if (timegrid && timegrid._drop_data) + { + timegrid._event_drop.call(source[i].iface.getDOMNode(),timegrid,null, action.ui,timegrid._drop_data); + } + timegrid._drop_data = false; + // Ok, stop. + return false; + } + + id = source[i].id.split('::'); + links.push({app: id[0] == 'filemanager' ? 'link' : id[0], id: id[1]}); + } + if(links.length && target && target.iface.getWidget() && target.iface.getWidget().instanceOf(et2_calendar_event)) + { + // Link the entries + egw.json(self.egw().getAppName()+".etemplate_widget_link.ajax_link.etemplate", + target.id.split('::').concat([links]), + function(result) { + if(result) + { + this.egw().message('Linked'); + } + }, + self, + true, + self + ).sendRequest(); + } + else if (links.length) + { + // Get date and time + var params = jQuery.extend({},jQuery('.drop-hover[data-date]',target.iface.getDOMNode())[0].dataset || {}); + + // Add link IDs + var app_registry = egw.link_get_registry('calendar'); + params[app_registry.add_app] = []; + params[app_registry.add_id] = []; + for(var n in links) + { + params[app_registry.add_app].push( links[n].app); + params[app_registry.add_id].push( links[n].id); + } + app.calendar.add(params); + } + + },true); + + drop_link.acceptedTypes = ['default','link']; + drop_link.hideOnDisabled = true; + + // Create the drop action for moving events between calendars + var invite_action = function(action, source, target) { + + // Extract link IDs + var links = []; + var id = ''; + for(var i = 0; i < source.length; i++) + { + // Check for no ID (invalid) or same manager (dragging an event) + if(!source[i].id) continue; + if(source[i].manager === target.manager) + { + + // Find the timegrid, could have dropped on an event + var timegrid = target.iface.getWidget(); + while(target.parent && timegrid.instanceOf && !timegrid.instanceOf(et2_calendar_timegrid)) + { + target = target.parent; + timegrid = target.iface.getWidget(); + } + + // Leave the helper there until the update is done + var loading = action.ui.helper.clone(true).appendTo(jQuery('body')); + + // and add a loading icon so user knows something is happening + if(jQuery('.calendar_timeDemo',loading).length == 0) + { + jQuery('.calendar_calEventHeader',loading).addClass('loading'); + } + else + { + jQuery('.calendar_timeDemo',loading).after('
'); + } + + var event_data = egw.dataGetUIDdata(source[i].id).data; + et2_calendar_event.recur_prompt(event_data, function(button_id) { + if(button_id === 'cancel' || !button_id) + { + return; + } + var add_owner = jQuery.extend([],timegrid.options.owner); + if(timegrid.daily_owner) + { + timegrid.iterateOver(function(col) { + if(col.div.has(timegrid.gridHover).length || col.header.has(timegrid.gridHover).length) + { + add_owner = col.options.owner; + } + }, this, et2_calendar_daycol); + } + egw().json('calendar.calendar_uiforms.ajax_invite', [ + button_id==='series' ? event_data.id : event_data.app_id, + add_owner, + action.id === 'change_participant' ? + jQuery.extend([],source[i].iface.getWidget().getParent().options.owner) : + [] + ], + function() { loading.remove();} + ).sendRequest(true); + }); + // Ok, stop. + return false; + } + } + }; + + drop_change_participant = mgr.addAction('drop', 'change_participant', egw.lang('Move to'), egw.image('participant'), invite_action,true); + drop_change_participant.acceptedTypes = ['calendar']; + drop_change_participant.hideOnDisabled = true; + + drop_invite = mgr.addAction('drop', 'invite', egw.lang('Invite'), egw.image('participant'), invite_action,true); + drop_invite.acceptedTypes = ['calendar']; + drop_invite.hideOnDisabled = true; + } + if(actionLinks.indexOf(drop_link.id) < 0) + { + actionLinks.push(drop_link.id); + } + + actionLinks.push(drop_invite.id); + actionLinks.push(drop_change_participant.id); + + // Don't re-add + if(drag_action == null) + { + // Create drag action that allows linking + drag_action = mgr.addAction('drag', 'egw_link_drag', egw.lang('link'), 'link', function(action, selected) { + // Drag helper - list titles. + // As we wanted to have a general defaul helper interface, we return null here and not using customize helper for links + // TODO: Need to decide if we need to create a customized helper interface for links anyway + //return helper; + return null; + },true); + } + // The timegrid itself is not draggable, so don't add a link. + // The action is there for the children (events) to use + if(false && actionLinks.indexOf(drag_action.id) < 0) + { + actionLinks.push(drag_action.id); + } + drag_action.set_dragType(['link','calendar']); + } + + /** + * Get all action-links / id's of 1.-level actions from a given action object + * + * Here we are only interested in drop events. + * + * @param actions + * @returns {Array} + */ + _get_action_links(actions) + { + var action_links = []; + // TODO: determine which actions are allowed without an action (empty actions) + for(var i in actions) + { + var action = actions[i]; + if(action.type == 'drop') + { + action_links.push(typeof action.id != 'undefined' ? action.id : i); + } + } + return action_links; + } + + /** + * Provide specific data to be displayed. + * This is a way to set start and end dates, owner and event data in one call. + * + * Events will be retrieved automatically from the egw.data cache, so there + * is no great need to provide them. + * + * @param {Object[]} events Array of events, indexed by date in Ymd format: + * { + * 20150501: [...], + * 20150502: [...] + * } + * Days should be in order. + * {string|number|Date} events.start_date - New start date + * {string|number|Date} events.end_date - New end date + * {number|number[]|string|string[]} event.owner - Owner ID, which can + * be an account ID, a resource ID (as defined in calendar_bo, not + * necessarily an entry from the resource app), or a list containing a + * combination of both. + */ + set_value(events) + { + if(typeof events !== 'object') return false; + + var use_days_sent = true; + + if(events.start_date) + { + use_days_sent = false; + } + if(events.end_date) + { + use_days_sent = false; + } + + super.set_value(events); + + if(use_days_sent) + { + var day_list = Object.keys(events); + if(day_list.length) + { + this.set_start_date(day_list[0]); + this.set_end_date(day_list[day_list.length-1]); + } + + + // Sub widgets actually get their own data from egw.data, so we'll + // stick it there + var consolidated = et2_calendar_view.is_consolidated(this.options.owner, this.day_list.length == 1 ? 'day' : 'week'); + for(var day in events) + { + let day_list = []; + for(var i = 0; i < events[day].length; i++) + { + day_list.push(events[day][i].row_id); + egw.dataStoreUID('calendar::'+events[day][i].row_id, events[day][i]); + } + // Might be split by user, so we have to check that too + for(var i = 0; i < this.options.owner.length; i++) + { + var owner = consolidated ? this.options.owner : this.options.owner[i]; + var day_id = CalendarApp._daywise_cache_id(day,owner); + egw.dataStoreUID(day_id, day_list); + if(consolidated) break; + } + } + } + + // Reset and calculate instead of just use the keys so we can get the weekend preference + this.day_list = []; + + // None of the above changed anything, hide the loader + if(!this.update_timer) + { + window.setTimeout(jQuery.proxy(function() {this.loader.hide();},this),200); + } + } + + /** + * Set which user owns this. Owner is passed along to the individual + * days. + * + * @param {number|number[]} _owner Account ID + * @returns {undefined} + */ + set_owner(_owner) + { + var old = this.options.owner || 0; + super.set_owner(_owner); + + this.owner.set_label(''); + this.div.removeClass('calendar_TimeGridNoLabel'); + + // Check to see if it's our own calendar, with just us showing + if(typeof _owner == 'object' && _owner.length == 1) + { + var rowCount = 0; + this.getParent().iterateOver(function(widget) { + if(!widget.disabled) rowCount++; + },this, et2_calendar_timegrid); + // Just us, show week number + if(rowCount == 1 && _owner.length == 1 && _owner[0] == egw.user('account_id') || rowCount != 1) _owner = false; + } + + var day_count = this.day_list.length ? this.day_list.length : + this._calculate_day_list(this.options.start_date, this.options.end_date, this.options.show_weekend).length; + // @ts-ignore + if(typeof _owner == 'string' && isNaN(_owner)) + { + this.set_label(''); + this.owner.set_value(this._get_owner_name(_owner)); + + // Label is empty, but give extra space for the owner name + this.div.removeClass('calendar_TimeGridNoLabel'); + } + else if (!_owner || typeof _owner == 'object' && _owner.length > 1 || + // Single owner, single day + _owner.length === 1 && day_count === 1 + ) + { + // Don't show owners if more than one, show week number + this.owner.set_value(''); + if(this.options.start_date) + { + this.set_label(egw.lang('wk') + ' ' + + (app.calendar ? app.calendar.date.week_number(this.options.start_date) : '') + ); + } + } + else + { + this.owner.options.application = 'api-accounts'; + this.owner.set_value(this._get_owner_name(_owner)); + this.set_label(''); + jQuery(this.getDOMNode(this.owner)).prepend(this.owner.getDOMNode()); + } + + if(this.isAttached() && ( + typeof old === "number" && typeof _owner === "number" && old !== this.options.owner || + // Array of ids will not compare as equal + ((typeof old === 'object' || typeof _owner === 'object') && old.toString() !== _owner.toString()) || + // Strings + typeof old === 'string' && ''+old !== ''+this.options.owner + )) + { + this.invalidate(true); + } + } + + /** + * Set a label for this week + * + * May conflict with owner, which is displayed when there's only one owner. + * + * @param {string} label + */ + set_label(label) + { + this.options.label = label; + this._labelContainer.html(label); + this.gridHeader.prepend(this._labelContainer); + + // If it's a short label (eg week number), don't give it an extra line + // but is empty, but give extra space for a single owner name + this.div.toggleClass( + 'calendar_TimeGridNoLabel', + label.trim().length > 0 && label.trim().length <= 6 || + this.options.owner.length > 1 + ); + } + + /** + * Set how big the time divisions are + * + * Setting granularity to 0 will remove the time divisions and display + * each days events in a list style. This 'gridlist' is not to be confused + * with the list view, which uses a nextmatch. + * + * @param {number} minutes + */ + set_granularity(minutes) + { + // Avoid < 0 + minutes = Math.max(0,minutes); + + if(this.options.granularity !== minutes) + { + if(this.options.granularity === 0 || minutes === 0) + { + this.options.granularity = minutes; + // Need to re-do a bunch to make sure this is propagated + this.invalidate(); + } + else + { + this.options.granularity = minutes; + this._drawTimes(); + } + } + else if (!this.update_timer) + { + this.resizeTimes(); + } + } + + /** + * Turn on or off the visibility of weekends + * + * @param {boolean} weekends + */ + set_show_weekend(weekends) + { + weekends = weekends ? true : false; + if(this.options.show_weekend !== weekends) + { + this.options.show_weekend = weekends; + if(this.isAttached()) + { + this.invalidate(); + } + } + } + + /** + * Call change handler, if set + */ + change( ) + { + if (this.onchange) + { + if(typeof this.onchange == 'function') + { + // Make sure function gets a reference to the widget + var args = Array.prototype.slice.call(arguments); + if(args.indexOf(this) == -1) args.push(this); + + return this.onchange.apply(this, args); + } else { + return (et2_compileLegacyJS(this.options.onchange, this, _node))(); + } + } + } + + /** + * Call event change handler, if set + * + * @param {type} event + * @param {type} dom_node + */ + event_change( event, dom_node) + { + if (this.onevent_change) + { + var event_data = this._get_event_info(dom_node); + var event_widget = this.getWidgetById(event_data.widget_id); + et2_calendar_event.recur_prompt(event_data, jQuery.proxy(function(button_id, event_data) { + // No need to continue + if(button_id === 'cancel') return false; + + if(typeof this.onevent_change == 'function') + { + // Make sure function gets a reference to the widget + var args = Array.prototype.slice.call(arguments); + + if(args.indexOf(event_widget) == -1) args.push(event_widget); + + // Put button ID in event + event.button_id = button_id; + + return this.onevent_change.apply(this, [event, event_widget, button_id]); + } else { + return (et2_compileLegacyJS(this.options.onevent_change, event_widget, dom_node))(); + } + },this)); + } + return false; + } + + get_granularity() + { + // get option, or user's preference + if(typeof this.options.granularity === 'undefined') + { + this.options.granularity = egw.preference('interval','calendar') || 30; + } + return parseInt(this.options.granularity); + } + + /** + * Click handler calling custom handler set via onclick attribute to this.onclick + * + * This also handles all its own actions, including navigation. If there is + * an event associated with the click, it will be found and passed to the + * onclick function. + * + * @param {Event} _ev + * @returns {boolean} Continue processing event (true) or stop (false) + */ + click(_ev) + { + var result = true; + if(this.options.readonly ) return; + + // Drag to create in progress + if(this.drag_create.start !== null) return; + + // Is this click in the event stuff, or in the header? + if(_ev.target.dataset.id || jQuery(_ev.target).parents('.calendar_calEvent').length) + { + // Event came from inside, maybe a calendar event + var event = this._get_event_info(_ev.originalEvent.target); + if(typeof this.onclick == 'function') + { + // Make sure function gets a reference to the widget, splice it in as 2. argument if not + var args = Array.prototype.slice.call(arguments); + if(args.indexOf(this) == -1) args.splice(1, 0, this); + + result = this.onclick.apply(this, args); + } + + var event_node = jQuery(event.event_node); + if(event.id && result && !this.disabled && !this.options.readonly && + // Permissions - opening will fail if we try + event_node && !(event_node.hasClass('rowNoView')) + ) + { + if(event.widget_id && this.getWidgetById(event.widget_id)) + { + this.getWidgetById(event.widget_id).recur_prompt(); + } + else + { + et2_calendar_event.recur_prompt(event); + } + + return false; + } + return result; + } + else if (this.gridHeader.is(_ev.target) && _ev.target.dataset || + this._labelContainer.is(_ev.target) && this.gridHeader[0].dataset) + { + app.calendar.update_state(jQuery.extend( + {view: 'week'}, + this._labelContainer.is(_ev.target) ? + this.gridHeader[0].dataset : + _ev.target.dataset + )); + } + else if (this.options.owner.length === 1 && jQuery(this.owner.getDOMNode()).is(_ev.target)) + { + // Click on the owner in header, show just that owner + app.calendar.update_state({owner: this.options.owner}); + } + else if (this.dayHeader.has(_ev.target).length) + { + // Click on a day header - let day deal with it + // First child is a selectAccount + for(var i = 1; i < this._children.length; i++) + { + if(this._children[i].header && ( + this._children[i].header.has(_ev.target).length || this._children[i].header.is(_ev.target)) + ) + { + return this._children[i].click(_ev); + } + } + } + // No time grid, click on a day + else if (this.options.granularity === 0 && + (jQuery(_ev.target).hasClass('event_wrapper') || jQuery(_ev.target).hasClass('.calendar_calDayCol')) + ) + { + // Default handler to open a new event at the selected time + var target = jQuery(_ev.target).hasClass('event_wrapper') ? _ev.target.parentNode : _ev.target; + var options = { + date: target.dataset.date || this.options.date, + hour: target.dataset.hour || this._parent.options.day_start, + minute: target.dataset.minute || 0, + owner: this.options.owner + }; + app.calendar.add(options); + return false; + } + } + + /** + * Mousedown handler to support drag to create + * + * @param {jQuery.Event} event + */ + _mouse_down(event) + { + if(event.which !== 1) return; + + if (this.options.readonly) return; + + var start = jQuery.extend({},this.gridHover[0].dataset); + if(start.date) + { + // Set parent for event + if(this.daily_owner) + { + // Each 'day' is the same date, different user + // Find the correct row so we know the parent + var col = event.target.closest('.calendar_calDayCol'); + for(var i = 0; i < this._children.length && col; i++) + { + if(this._children[i].node === col) + { + this.drag_create.parent = this._children[i]; + break; + } + } + } + else + { + this.drag_create.parent = this.getWidgetById(start.date); + } + + // Format date + this.date_helper.set_year(start.date.substring(0,4)); + this.date_helper.set_month(start.date.substring(4,6)); + this.date_helper.set_date(start.date.substring(6,8)); + if(start.hour) + { + this.date_helper.set_hours(start.hour); + } + if(start.minute) + { + this.date_helper.set_minutes(start.minute); + } + start.date = this.date_helper.get_value(); + + this.gridHover.css('cursor', 'ns-resize'); + + // Start update + var timegrid = this; + this.div.on('mousemove.dragcreate', function() + { + if(timegrid.drag_create.event && timegrid.drag_create.parent && timegrid.drag_create.end) + { + var end = jQuery.extend({}, timegrid.gridHover[0].dataset); + if(end.date) + { + timegrid.date_helper.set_year(end.date.substring(0,4)); + timegrid.date_helper.set_month(end.date.substring(4,6)); + timegrid.date_helper.set_date(end.date.substring(6,8)); + if(end.hour) + { + timegrid.date_helper.set_hours(end.hour); + } + if(end.minute) + { + timegrid.date_helper.set_minutes(end.minute); + } + timegrid.drag_create.end.date = timegrid.date_helper.get_value(); + } + try + { + timegrid._drag_update_event(); + } + catch (e) + { + timegrid._drag_create_end(); + } + } + }); + } + return this._drag_create_start(start); + } + + /** + * Mouseup handler to support drag to create + * + * @param {jQuery.Event} event + */ + _mouse_up(event) + { + if (this.options.readonly) return; + var end = jQuery.extend({}, this.gridHover[0].dataset); + if(end.date) + { + this.date_helper.set_year(end.date.substring(0,4)); + this.date_helper.set_month(end.date.substring(4,6)); + this.date_helper.set_date(end.date.substring(6,8)); + if(end.hour) + { + this.date_helper.set_hours(end.hour); + } + if(end.minute) + { + this.date_helper.set_minutes(end.minute); + } + end.date = this.date_helper.get_value(); + } + this.div.off('mousemove.dragcreate'); + this.gridHover.css('cursor', ''); + + return this._drag_create_end(end); + } + + /** + * Get time from position for drag and drop + * + * This does not return an actual time on a clock, but finds the closest + * time node (.calendar_calAddEvent or day column) to the given position. + * + * @param {number} x + * @param {number} y + * @returns {DOMNode[]} time node(s) for the given position + */ + _get_time_from_position( x,y) + { + + x = Math.round(x); + y = Math.round(y); + + var path = []; + var day = null; + var time = null; + + var node = document.elementFromPoint(x,y); + var $node = jQuery(node); + + // Ignore high level & non-time (grid itself, header parent & week label) + if([this.node, this.gridHeader[0], this._labelContainer[0]].indexOf(node) !== -1 || + // Day labels + this.gridHeader.has(node).length && !$node.hasClass("calendar_calDayColAllDay") && !$node.hasClass('calendar_calDayColHeader')) + { + return []; + } + for(var id in this.gridHover[0].dataset) { + delete this.gridHover[0].dataset[id]; + } + if(this.options.granularity == 0) + { + this.gridHover.css('height',''); + } + while(node && node != this.node && node.tagName != 'BODY' && path.length < 10) + { + path.push(node); + node.style.display = 'none'; + $node = jQuery(node); + if($node.hasClass('calendar_calDayColHeader')) { + for(var id in node.dataset) { + this.gridHover[0].dataset[id] = node.dataset[id]; + } + this.gridHover.css({ + position: 'absolute', + top: '', + bottom: '0px', + // Use 100% height if we're hiding the day labels to avoid + // any remaining space from the hidden labels + height: $node.height() > parseInt($node.css('line-height')) ? + $node.css('padding-bottom') : '100%' + }); + day = node; + this.gridHover + .attr('data-non_blocking','true'); + break; + } + if($node.hasClass('calendar_calDayCol')) + { + day = node; + this.gridHover + .attr('data-date',day.dataset.date); + } + if($node.hasClass('calendar_calTimeRowTime')) + { + time = node; + this.gridHover + .attr('data-hour',time.dataset.hour) + .attr('data-minute',time.dataset.minute); + break; + } + node = document.elementFromPoint(x,y); + } + for(var i = 0; i < path.length; i++) + { + path[i].style.display = ''; + } + + if(!day) + { + return []; + } + this.gridHover + .show() + .appendTo(day); + if(time) + { + this.gridHover + .height(this.rowHeight) + .position({my:'left top', at: 'left top', of: time}); + } + this.gridHover.css('left',''); + return this.gridHover; + } + + /** + * Code for implementing et2_IDetachedDOM + * + * @param {array} _attrs array to add further attributes to + */ + getDetachedAttributes( _attrs) + { + _attrs.push('start_date','end_date'); + } + + getDetachedNodes( ) + { + return [this.getDOMNode(this)]; + } + + setDetachedAttributes( _nodes, _values) + { + this.div = jQuery(_nodes[0]); + + if(_values.start_date) + { + this.set_start_date(_values.start_date); + } + if(_values.end_date) + { + this.set_end_date(_values.end_date); + } + } + + // Resizable interface + /** + * @param {boolean} [_too_small=null] Force the widget to act as if it was too small + */ + resize (_too_small?) + { + if(this.disabled || !this.div.is(':visible')) + { + return; + } + + /* + We expect the timegrid to be in a table with 0 or more other timegrids, + 1 per row. We want each timegrid to be as large as possible, but space + shared equally. Height can't be set to a percentage on the rows, because + that doesn't work. However, if any timegrid is too small (1/2 hour < 1 line + height), we change to showing only the working hours with no vertical + scrollbar. Each week gets as much space as it needs, and all scroll together. + */ + // How many rows? + var rowCount = 0; + this.getParent().iterateOver(function(widget) { + if(!widget.disabled) rowCount++; + },this, et2_calendar_timegrid); + + // Take the whole tab height, or home portlet + if(this.getInstanceManager().app === 'home') + { + var height = jQuery(this.getParent().getDOMNode(this)).parentsUntil('.et2_portlet').last().parent().innerHeight(); + + // Allow for portlet header + height -= jQuery('.ui-widget-header',this.div.parents('.egw_fw_ui_tab_content')).outerHeight(true); + } + else + { + var height = jQuery(this.getInstanceManager().DOMContainer).parent().innerHeight(); + + // Allow for toolbar + height -= jQuery('#calendar-toolbar',this.div.parents('.egw_fw_ui_tab_content')).outerHeight(true); + } + + this.options.height = Math.floor(height / rowCount); + + // Allow for borders & padding + this.options.height -= 2*((this.div.outerWidth(true) - this.div.innerWidth()) + parseInt(this.div.parent().css('padding-top'))); + + // Calculate how much space is needed, and + // if too small be bigger + var needed = ((this.day_end - this.day_start) / + (this.options.granularity / 60) * parseInt(this.div.css('line-height'))) + + this.gridHeader.outerHeight(); + var too_small = needed > this.options.height && this.options.granularity != 0; + + + if(this.getInstanceManager().app === 'home') + { + var modify_node = jQuery(this.getParent().getDOMNode(this)).parentsUntil('.et2_portlet').last(); + } + else + { + var modify_node = jQuery(this.getInstanceManager().DOMContainer); + } + modify_node + .css({ + 'overflow-y': too_small || _too_small ? 'auto' : 'hidden', + 'overflow-x': 'hidden', + 'height': too_small || _too_small ? height : '100%' + }); + if(too_small || _too_small) + { + this.options.height = Math.max(this.options.height, needed); + // Set all others to match + if(!_too_small && rowCount > 1 && this.getParent()) + { + window.setTimeout(jQuery.proxy(function() { + if(!this._parent) return; + this._parent.iterateOver(function(widget) { + if(!widget.disabled) widget.resize(true); + },this, et2_calendar_timegrid); + },this),1); + return; + } + this.div.addClass('calendar_calTimeGridFixed'); + } + else + { + this.div.removeClass('calendar_calTimeGridFixed'); + } + this.div.css('height', this.options.height); + + // Re-do time grid + if(!this.update_timer) + { + this.resizeTimes(); + } + + // Try to resize width, though animations cause problems + var total_width = modify_node.parent().innerWidth() - this.days.position().left; + // Space for todos, if there + total_width -= jQuery(this.getInstanceManager().DOMContainer).siblings().has(':visible').not('#calendar-toolbar').outerWidth(); + + var day_width = (total_width > 0 ? total_width : modify_node.width())/this.day_widgets.length; + // update day widgets + for(var i = 0; i < this.day_widgets.length; i++) + { + var day = this.day_widgets[i]; + + // Position + day.set_left((day_width * i) + 'px'); + day.set_width(day_width + 'px'); + } + } + + /** + * Set up for printing + * + * @return {undefined|Deferred} Return a jQuery Deferred object if not done setting up + * (waiting for data) + */ + beforePrint( ) + { + + if(this.disabled || !this.div.is(':visible')) + { + return; + } + + var height_check = this.div.height(); + this.div.css('max-height','17cm'); + if(this.div.height() != height_check) + { + this.div.height('17cm'); + this._resizeTimes(); + } + + // update day widgets, if not on single day view + // + // TODO: Find out why don't we update single day view + // Let the single day view participate in print calculation. + if(this.day_widgets.length > 0) + { + var day_width = (100 / this.day_widgets.length); + for(var i = 0; i < this.day_widgets.length; i++) + { + var day = this.day_widgets[i]; + + // Position + day.set_left((i*day_width) + '%'); + day.set_width(day_width + '%'); + // For some reason the column's method does not set it correctly in Chrome + day.header[0].style.width = day_width + '%'; + } + } + + // Stop Firefox from scrolling the day to the top - this would break printing in Chrome + if (navigator.userAgent.match(/(firefox|safari|iceweasel)/i) && !navigator.userAgent.match(/chrome/i)) + { + var height = this.scrolling.scrollTop() + this.scrolling.height(); + this.scrolling + // Disable scroll event, or it will recalculate out of view events + .off('scroll') + // Explicitly transform to the correct place + .css({ + 'transform': 'translateY(-'+this.scrolling.scrollTop()+'px)', + 'margin-bottom': '-'+this.scrolling.scrollTop()+'px', + 'height': height+'px' + }); + this.div.css({'height':'','max-height':''}); + } + } + + /** + * Reset after printing + */ + afterPrint( ) + { + this.div.css('maxHeight',''); + this.scrolling.children().css({'transform':'', 'overflow':''}); + this.div.height(this.options.height); + if (navigator.userAgent.match(/(firefox|safari|iceweasel)/i) && !navigator.userAgent.match(/chrome/i)) + { + this._resizeTimes(); + this.scrolling + // Re-enable out-of-view formatting on scroll + .on('scroll', jQuery.proxy(this._scroll, this)) + // Remove translation + .css({'transform':'', 'margin-bottom':''}); + } + } +} +et2_register_widget(et2_calendar_timegrid, ["calendar-timegrid"]); \ No newline at end of file diff --git a/calendar/js/et2_widget_view.ts b/calendar/js/et2_widget_view.ts new file mode 100644 index 0000000000..edb31ea42d --- /dev/null +++ b/calendar/js/et2_widget_view.ts @@ -0,0 +1,700 @@ +/* + * Egroupware + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package + * @subpackage + * @link http://www.egroupware.org + * @author Nathan Gray + * @version $Id$ + */ + +/*egw:uses + /etemplate/js/et2_core_valueWidget; +*/ + +import {et2_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget"; +import {et2_valueWidget} from "../../api/js/etemplate/et2_core_valueWidget"; +import {ClassWithAttributes} from "../../api/js/etemplate/et2_core_inheritance"; +import {et2_date} from "../../api/js/etemplate/et2_widget_date"; + +/** + * Parent class for the various calendar views to reduce copied code + * + * + * et2_calendar_view is responsible for its own loader div, which is displayed while + * the times & days are redrawn. + * + * @augments et2_valueWidget + */ +export class et2_calendar_view extends et2_valueWidget +{ + static readonly _attributes : any = { + owner: { + name: "Owner", + type: "any", // Integer, or array of integers, or string like r13 (resources, addressbook) + default: [egw.user('account_id')], + description: "Account ID number of the calendar owner, if not the current user" + }, + start_date: { + name: "Start date", + type: "any" + }, + end_date: { + name: "End date", + type: "any" + } + }; + protected dataStorePrefix: string = 'calendar'; + protected _date_helper: et2_date; + protected loader: JQuery; + protected div: JQuery; + + protected update_timer: number = null; + protected drag_create: { parent: et2_widget; start: any; end: any; event: et2_calendar_event }; + protected value: any; + + protected _actionObject: egwActionObject; + + /** + * Constructor + * + */ + constructor(_parent, _attrs? : WidgetConfig, _child? : object) + { + // Call the inherited constructor + super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_calendar_view._attributes, _child || {})); + + + // Used for its date calculations + this._date_helper = et2_createWidget('date-time', {}, null); + this._date_helper.loadingFinished(); + + this.loader = jQuery('
'); + this.update_timer = null; + + // Used to support dragging on empty space to create an event + this.drag_create = { + start: null, + end: null, + parent: null, + event: null + }; + } + + destroy() { + super.destroy(); + + // date_helper has no parent, so we must explicitly remove it + this._date_helper.destroy(); + this._date_helper = null; + + // Stop the invalidate timer + if(this.update_timer) + { + window.clearTimeout(this.update_timer); + } + } + + doLoadingFinished( ) + { + super.doLoadingFinished(); + this.loader.hide(0).prependTo(this.div); + if(this.options.owner) this.set_owner(this.options.owner); + + return true; + } + + /** + * Something changed, and the view need to be re-drawn. We wait a bit to + * avoid re-drawing twice if start and end date both changed, then recreate + * as needed. + * + * @param {boolean} [trigger_event=false] Trigger an event once things are done. + * Waiting until invalidate completes prevents 2 updates when changing the date range. + * @returns {undefined} + * + * @memberOf et2_calendar_view + */ + invalidate(trigger_event) { + // If this wasn't a stub, we'd set this.update_timer + } + + /** + * Returns the current start date + * + * @returns {Date} + * + * @memberOf et2_calendar_view + */ + get_start_date() { + return new Date(this.options.start_date); + } + + /** + * Returns the current start date + * + * @returns {Date} + * + * @memberOf et2_calendar_view + */ + get_end_date() { + return new Date(this.options.end_date); + } + + /** + * Change the start date + * + * Changing the start date will invalidate the display, and it will be redrawn + * after a timeout. + * + * @param {string|number|Date} new_date New starting date. Strings can be in + * any format understood by et2_widget_date, or Ymd (eg: 20160101). + * @returns {undefined} + * + * @memberOf et2_calendar_view + */ + set_start_date(new_date) + { + if(!new_date || new_date === null) + { + new_date = new Date(); + } + + // Use date widget's existing functions to deal + if(typeof new_date === "object" || typeof new_date === "string" && new_date.length > 8) + { + this._date_helper.set_value(new_date); + } + else if(typeof new_date === "string") + { + this._date_helper.set_year(new_date.substring(0, 4)); + // Avoid overflow into next month, since we re-use date_helper + this._date_helper.set_date(1); + this._date_helper.set_month(new_date.substring(4, 6)); + this._date_helper.set_date(new_date.substring(6, 8)); + } + + var old_date = this.options.start_date; + this.options.start_date = new Date(this._date_helper.getValue()); + + if(old_date !== this.options.start_date && this.isAttached()) + { + this.invalidate(true); + } + } + + /** + * Change the end date + * + * Changing the end date will invalidate the display, and it will be redrawn + * after a timeout. + * + * @param {string|number|Date} new_date - New end date. Strings can be in + * any format understood by et2_widget_date, or Ymd (eg: 20160101). + * @returns {undefined} + * + * @memberOf et2_calendar_view + */ + set_end_date(new_date) + { + if(!new_date || new_date === null) + { + new_date = new Date(); + } + // Use date widget's existing functions to deal + if(typeof new_date === "object" || typeof new_date === "string" && new_date.length > 8) + { + this._date_helper.set_value(new_date); + } + else if(typeof new_date === "string") + { + this._date_helper.set_year(new_date.substring(0, 4)); + // Avoid overflow into next month, since we re-use date_helper + this._date_helper.set_date(1); + this._date_helper.set_month(new_date.substring(4, 6)); + this._date_helper.set_date(new_date.substring(6, 8)); + } + + var old_date = this.options.end_date; + this.options.end_date = new Date(this._date_helper.getValue()); + + if(old_date !== this.options.end_date && this.isAttached()) + { + this.invalidate(true); + } + } + + /** + * Set which users to display + * + * Changing the owner will invalidate the display, and it will be redrawn + * after a timeout. + * + * @param {number|number[]|string|string[]} _owner - Owner ID, which can + * be an account ID, a resource ID (as defined in calendar_bo, not + * necessarily an entry from the resource app), or a list containing a + * combination of both. + * + * @memberOf et2_calendar_view + */ + set_owner(_owner) + { + var old = this.options.owner; + + // 0 means current user, but that causes problems for comparison, + // so we'll just switch to the actual ID + if(_owner == '0') + { + _owner = [egw.user('account_id')]; + } + if(!jQuery.isArray(_owner)) + { + if(typeof _owner === "string") + { + _owner = _owner.split(','); + } + else + { + _owner = [_owner]; + } + } + else + { + _owner = jQuery.extend([],_owner); + } + this.options.owner = _owner; + if(this.isAttached() && ( + typeof old === "number" && typeof _owner === "number" && old !== this.options.owner || + // Array of ids will not compare as equal + ((typeof old === 'object' || typeof _owner === 'object') && old.toString() !== _owner.toString()) || + // Strings + typeof old === 'string' && ''+old !== ''+this.options.owner + )) + { + this.invalidate(true); + } + } + + /** + * Provide specific data to be displayed. + * This is a way to set start and end dates, owner and event data in one call. + * + * If events are not provided in the array, + * @param {Object[]} events Array of events, indexed by date in Ymd format: + * { + * 20150501: [...], + * 20150502: [...] + * } + * Days should be in order. + * {string|number|Date} events.start_date - New start date + * {string|number|Date} events.end_date - New end date + * {number|number[]|string|string[]} event.owner - Owner ID, which can + * be an account ID, a resource ID (as defined in calendar_bo, not + * necessarily an entry from the resource app), or a list containing a + * combination of both. + */ + set_value(events) + { + if(typeof events !== 'object') return false; + + if(events.length && events.length > 0 || !jQuery.isEmptyObject(events)) + { + this.set_disabled(false); + } + if(events.id) + { + this.set_id(events.id); + delete events.id; + } + if(events.start_date) + { + this.set_start_date(events.start_date); + delete events.start_date; + } + if(events.end_date) + { + this.set_end_date(events.end_date); + delete events.end_date; + } + // set_owner() wants start_date set to get the correct week number + // for the corner label + if(events.owner) + { + this.set_owner(events.owner); + delete events.owner; + } + + this.value = events || {}; + + // None of the above changed anything, hide the loader + if(!this.update_timer) + { + window.setTimeout(jQuery.proxy(function() {this.loader.hide();},this),200); + } + } + + get date_helper(): et2_date + { + return this._date_helper; + } + _createNamespace() { + return true; + } + + /** + * Calendar supports many different owner types, including users & resources. + * This translates an ID to a user-friendly name. + * + * @param {string} user + * @returns {string} + * + * @memberOf et2_calendar_view + */ + _get_owner_name(user) { + var label = undefined; + if(parseInt(user) === 0) + { + // 0 means current user + user = egw.user('account_id'); + } + if(et2_calendar_view.owner_name_cache[user]) + { + return et2_calendar_view.owner_name_cache[user]; + } + if (!isNaN(user)) + { + user = parseInt(user); + var accounts = egw.accounts('both'); + for(var j = 0; j < accounts.length; j++) + { + if(accounts[j].value === user) + { + label = accounts[j].label; + break; + } + } + } + if(typeof label === 'undefined') + { + // Not found? Ask the sidebox owner widget (it gets updated) or the original arrayMgr + let options = false; + if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner')) + { + options = app.calendar.sidebox_et2.getWidgetById('owner').taglist.getSelection(); + } + else + { + options = this.getArrayMgr("sel_options").getRoot().getEntry('owner'); + } + if(options && options.find) + { + var found = options.find(function(element) {return element.id == user;}) || {}; + if(found && found.label && found.label !== user) + { + label = found.label; + } + } + if(!label) + { + // No sidebox? Must be in home or sitemgr (no caching) - ask directly + label = '?'; + egw.jsonq('calendar_owner_etemplate_widget::ajax_owner',user,function(data) { + et2_calendar_view.owner_name_cache[user] = data; + this.invalidate(true); + + // Set owner to make sure labels get set + if(this.owner && typeof this.owner.set_value === 'function') + { + this.owner.set_value(data); + } + }.bind(this), this); + } + } + if(label) + { + et2_calendar_view.owner_name_cache[user] = label; + } + return label; + } + + /** + * Find the event information linked to a given DOM node + * + * @param {HTMLElement} dom_node - It should have something to do with an event + * @returns {Object} + */ + _get_event_info(dom_node) + { + // Determine as much relevant info as can be found + var event_node = jQuery(dom_node).closest('[data-id]',this.div)[0]; + var day_node = jQuery(event_node).closest('[data-date]',this.div)[0]; + + var result = jQuery.extend({ + event_node: event_node, + day_node: day_node + }, + event_node ? event_node.dataset : {}, + day_node ? day_node.dataset : {} + ); + + // Widget ID should be the DOM node ID without the event_ prefix + if(event_node && event_node.id) + { + var widget_id = event_node.id || ''; + widget_id = widget_id.split('event_'); + widget_id.shift(); + result.widget_id = 'event_' + widget_id.join(''); + } + return result; + } + + /** + * Starting (mousedown) handler to support drag to create + * + * Extending classes need to set this.drag_create.parent, which is the + * parent container (child of extending class) that will directly hold the + * event. + * + * @param {String} start Date string (JSON format) + */ + _drag_create_start(start) + { + this.drag_create.start = jQuery.extend({},start); + if(!this.drag_create.start.date) + { + this.drag_create.start = null; + } + this.drag_create.end = start; + + // Clear some stuff, if last time did not complete + if(this.drag_create.event) + { + if(this.drag_create.event.destroy) + { + this.drag_create.event.destroy(); + } + this.drag_create.event = null; + } + // Wait a bit before adding an "event", it may be just a click + window.setTimeout(jQuery.proxy(function() { + // Create event + this._drag_create_event(); + }, this), 250); + } + + /** + * Create or update an event used for feedback while dragging on empty space, + * so user can see something is happening + */ + _drag_create_event() + { + if(!this.drag_create.parent || !this.drag_create.start) + { + return; + } + if(!this.drag_create.event) + { + this._date_helper.set_value(this.drag_create.start.date); + var value = jQuery.extend({}, + this.drag_create.start, + this.drag_create.end, + { + start: this.drag_create.start.date, + end: this.drag_create.end && this.drag_create.end.date || this.drag_create.start.date, + date: ""+this._date_helper.get_year()+ + sprintf("%02d",this._date_helper.get_month())+ + sprintf("%02d",this._date_helper.get_date()), + title: '', + description: '', + owner: this.options.owner, + participants: this.options.owner, + app: 'calendar', + whole_day_on_top: this.drag_create.start.whole_day + } + ); + this.drag_create.event = et2_createWidget('calendar-event',{ + id:'event_drag', + value: value + },this.drag_create.parent); + this.drag_create.event._values_check(value); + this.drag_create.event.doLoadingFinished(); + } + + } + + _drag_update_event() + { + if(!this.drag_create.event || !this.drag_create.start || !this.drag_create.end + || !this.drag_create.parent || !this.drag_create.event._type) + { + return; + } + else if (this.drag_create.end) + { + this.drag_create.event.options.value.end = this.drag_create.end.date; + this.drag_create.event._values_check(this.drag_create.event.options.value); + } + this.drag_create.event._update(); + this.drag_create.parent.position_event(this.drag_create.event); + } + + /** + * Ending (mouseup) handler to support drag to create + * + * @param {String} end Date string (JSON format) + */ + _drag_create_end(end?) + { + this.div.css('cursor',''); + if(typeof end === 'undefined') + { + end = {}; + } + + if(this.drag_create.start && end.date && + JSON.stringify(this.drag_create.start.date) !== JSON.stringify(end.date)) + { + // Drag from start to end, open dialog + var options = { + start: this.drag_create.start.date < end.date ? this.drag_create.start.date : end.date, + end: this.drag_create.start.date < end.date ? end.date : this.drag_create.start.date + }; + + // Whole day needs to go from 00:00 to 23:59 + if(end.whole_day || this.drag_create.start.whole_day) + { + var start = new Date(options.start); + start.setUTCHours(0); + start.setUTCMinutes(0); + options.start = start.toJSON(); + + var end = new Date(options.end); + end.setUTCHours(23); + end.setUTCMinutes(59); + options.end = end.toJSON(); + } + + // Add anything else that was set, but not date + jQuery.extend(options,this.drag_create.start, end); + delete(options.date); + + // Make sure parent is set, if needed + if (this.drag_create.parent && this.drag_create.parent.options.owner !== app.calendar.state.owner && !options.owner) + { + options.owner = this.drag_create.parent.options.owner; + } + + // Remove empties + for(var key in options) + { + if(!options[key]) delete options[key]; + } + app.calendar.add(options, this.drag_create.event); + + // Wait a bit, having these stops the click + window.setTimeout(jQuery.proxy(function() { + this.drag_create.start = null; + this.drag_create.end = null; + this.drag_create.parent = null; + if(this.drag_create.event) + { + this.drag_create.event = null; + } + },this),100); + + return false; + } + + this.drag_create.start = null; + this.drag_create.end = null; + this.drag_create.parent = null; + if(this.drag_create.event) + { + try + { + if(this.drag_create.event.destroy) + { + this.drag_create.event.destroy(); + } + } catch(e) {} + this.drag_create.event = null; + } + return true; + } + + /** + * Check if the view should be consolidated into one, or listed seperately + * based on the user's preferences + * + * @param {string[]} owners List of owners + * @param {string} view Name of current view (day, week) + * @returns {boolean} True of only one is needed, false if each owner needs + * to be listed seperately. + */ + static is_consolidated(owners, view) + { + // Seperate owners, or consolidated? + return !( + owners.length > 1 && + (view === 'day' && owners.length < parseInt(''+egw.preference('day_consolidate','calendar')) || + view === 'week' && owners.length < parseInt(''+egw.preference('week_consolidate','calendar'))) + ); + } + + /** + * Cache to map owner & resource IDs to names, helps cut down on server requests + */ + static owner_name_cache = {}; + + public static holiday_cache = {}; + /** + * Fetch and cache a list of the year's holidays + * + * @param {et2_calendar_timegrid} widget + * @param {string|numeric} year + * @returns {Array} + */ + static get_holidays(widget,year) + { + // Loaded in an iframe or something + var view = egw.window.et2_calendar_view ? egw.window.et2_calendar_view : this; + + // No country selected causes error, so skip if it's missing + if(!view || !egw.preference('country','common')) return {}; + + var cache = view.holiday_cache[year]; + if (typeof cache == 'undefined') + { + // Fetch with json instead of jsonq because there may be more than + // one widget listening for the response by the time it gets back, + // and we can't do that when it's queued. + view.holiday_cache[year] = jQuery.getJSON( + egw.link('/calendar/holidays.php', {year: year}) + ); + } + cache = view.holiday_cache[year]; + if(typeof cache.done == 'function') + { + // pending, wait for it + cache.done(jQuery.proxy(function(response) { + view.holiday_cache[this.year] = response||undefined; + + egw.window.setTimeout(jQuery.proxy(function() { + // Make sure widget hasn't been destroyed while we wait + if(typeof this.widget.free == 'undefined') + { + this.widget.day_class_holiday(); + } + },this),1); + },{widget:widget,year:year})) + .fail(jQuery.proxy(function() { + view.holiday_cache[this.year] = undefined; + }, {widget: widget, year: year})); + return {}; + } + else + { + return cache; + } + } +}