/* * Egroupware Calendar timegrid * * @license https://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package calendar * @subpackage etemplate * @link https://www.egroupware.org * @author Nathan Gray */ /*egw:uses /calendar/js/et2_widget_view.js; /calendar/js/et2_widget_planner_row.js; /calendar/js/et2_widget_event.js; */ import {et2_createWidget, 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"; import {egw} from "../../api/js/jsapi/egw_global"; import {egw_getObjectManager, egwActionObject} from "../../api/js/egw_action/egw_action"; import { EGW_AI_DRAG_ENTER, EGW_AI_DRAG_OUT, EGW_AO_FLAG_IS_CONTAINER } from "../../api/js/egw_action/egw_action_constants"; import {et2_IDetachedDOM, et2_IPrint, et2_IResizeable} from "../../api/js/etemplate/et2_core_interfaces"; import {et2_compileLegacyJS} from "../../api/js/etemplate/et2_core_legacyJSFunctions"; import {et2_no_init} from "../../api/js/etemplate/et2_core_common"; import {CalendarApp} from "./app"; import {sprintf} from "../../api/js/egw_action/egw_action_common"; import {et2_dataview_grid} from "../../api/js/etemplate/et2_dataview_view_grid"; import {formatDate, formatTime} from "../../api/js/etemplate/Et2Date/Et2Date"; import interact from "@interactjs/interactjs/index"; import type {InteractEvent} from "@interactjs/core/InteractEvent"; import {StaticOptions} from "../../api/js/etemplate/Et2Select/StaticOptions"; import {SelectOption} from "../../api/js/etemplate/Et2Select/FindSelectOptions"; /** * 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 interact(this).resizable ({ invert: "reposition", edges: {right: true}, startAxis: "x", lockAxis: "x", containment: 'parent', /** * If dragging to resize an event, abort drag to create * * @param {InteractEvent} event */ onstart: function(event : InteractEvent) { if(planner.drag_create.start) { // Abort drag to create, we're dragging to resize planner._drag_create_end({}); } event.target.classList.add("resizing"); }, /** * Triggered at the end of resizing the calEvent. * * @param {InteractEvent} event */ onend: function(event : InteractEvent) { interact(this).unset(); 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 = event_data.event_node; // and add a loading icon so user knows something is happening jQuery('.calendar_timeDemo', loading).after('
'); jQuery(this).trigger(e); // Remove loading, done or not loading.remove(); } // Clear the helper, re-draw if(event_widget) { (event_widget.getParent()).position_event(event_widget); } }.bind(this), /** * Triggered during the resize, on the drag of the resize handler * * @param {InteractEvent} event */ onmove: function(event : InteractEvent) { event.target.style.width = event.rect.width + "px"; let position; if(planner.options.group_by == 'month') { position = {left: event.clientX, top: event.clientY}; } else { let offset = parseInt(getComputedStyle(event.target).left) - event.rect.left; position = {top: event.rect.top, left: event.rect.right + offset}; } planner._drag_helper(this, position, event.rect.height); }.bind(this) }); }); this.div .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.css("left", (event.clientX - planner.grid.offset().left + 120) + "px"); 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.start && time.toJSON() != planner.drag_create.start.date.toJSON()) { planner._drag_create_start(planner.drag_create.start); // Create the event immediately planner._drag_create_event(); } if(planner.drag_create.event && planner.drag_create.parent && planner.drag_create.end) { if(planner.drag_create.start?.date?.toJSON() == time.toJSON()) { // Minimum drag size is time granularity or default time.setUTCMinutes(time.getUTCMinutes() + parseInt(planner.egw().preference("defaultlength", "calendar"))); } planner.drag_create.end.date = time; 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('dragstart', '.calendar_calEvent', function(event) { // 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: async 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 = await 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() { let labels = []; let already_added = []; let options = []; let resource = null; let owner = null; if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner')) { owner = app.calendar.sidebox_et2.getWidgetById('owner') } else { owner = this.getArrayMgr("sel_options").getRoot().getEntry('owner'); } options = owner.select_options; 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.value == 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 (user < 0) { // Group, but no users found. Need those. egw.accountData(parseInt(user),'account_fullname',true,function(result) { this.invalidate(); },this); } else if(already_added.indexOf(''+user) < 0 && (isNaN(user) || parseInt(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(let user in participants) { var participant = participants[user]; if(parseInt(user) < 0) // groups { let owner = null; let options = []; if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner')) { owner = app.calendar.sidebox_et2.getWidgetById('owner') } else { owner = this.getArrayMgr("sel_options").getRoot().getEntry('owner'); } options = owner.select_options.find((o) => o.value == user).resources || []; for(let i = 0; i < options.length; i++) { if(!participants[options[i]]) { add_row.call(this, options[i], participant); } } 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') } // Set account_id so event.owner_check can use it row.options.owner = sort_key; // 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 : number|boolean = false; for(var i = 0; i < labels.length; i++) { if(labels[i].id == key) { label_index = i; break; } } if(label_index) { 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 = typeof label_index == "boolean" ? 0 : label_index; for(let 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[end_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: async 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 = await 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 labels = []; var categories = StaticOptions.cached_server_side(this, "cat", ',,,calendar', false); if(!categories || categories.length == 0) { // No categories at all? Probably loading before sidebox is done. Ask directly and wait for them, rather than firing // 50 different requests. egw.json( 'EGroupware\\Api\\Etemplate\\Widget\\Select::ajax_get_options', ['select-cat', ',,,calendar'], function(data) { categories = data; } ).sendRequest(false); } let app_calendar = this.getInstanceManager().app_obj.calendar || app.calendar; 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 let cat = null; for(let j = 0; j < categories.length; j++) { if(categories[j].value == cat_id[i]) { cat = categories[j]; categories[j].id = categories[j].value; labels.push(categories[j]); break; } } // Get its children immediately if(cat && cat.children === "") { continue; } else if(!cat || !cat.children || categories.filter(o => cat.children.find(c => o.value == c)).length != cat.children.length) { 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; let app_calendar = this.getInstanceManager().app_obj.calendar || app.calendar; 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; this.widget._updateNow(); 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 * */ async _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)); await 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 let app_calendar = this.getInstanceManager().app_obj.calendar || app.calendar; 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().first().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; } /** * Update the 'now' line * @private */ public _updateNow() { let now = super._updateNow(); if(now === false || this.grouper == this.groupers.month) { this.now_div.hide(); return false; } let row = null; for(let i = 0; i < this._children.length && row == null; i++) { if(this._children[i].instanceOf(et2_calendar_planner_row)) { row = this._children[i]; } } if(!row) { this.now_div.hide(); return false; } this.now_div.appendTo(this.grid) .show() .css('left', row._time_to_position(now) + '%'); } /** * 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 let app_calendar = this.getInstanceManager().app_obj.calendar || app.calendar; 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 */ async _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 = await 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 */ async day_class_holiday(date, holiday_list, days?) { if(!date) { return ''; } // Holidays and birthdays const fetched = await this.egw().holidays(date.getUTCFullYear()); var day_class = ''; // Pass a string rather than the date object, to make sure it doesn't get changed let date_key = formatDate(this.date_helper(date.toJSON()), {dateFormat: "Ymd"}); if(fetched && fetched[date_key]) { const dates = fetched[date_key]; for(var i = 0; i < dates.length; i++) { if(typeof dates[i]['birthyear'] !== 'undefined') { day_class += ' calendar_calBirthday '; if(typeof days == 'undefined' || days <= 21) { day_class += ' calendar_calBirthdayIcon '; } holiday_list.push(dates[i]['name']); } else { day_class += 'calendar_calHoliday '; holiday_list.push(dates[i]['name']); } } } 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.classList.contains('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.draggable)[0], this.getWidget(), event, _data.ui); } var drag_listener = function(event) { let style = getComputedStyle(_data.ui.helper); aoi.getWidget()._drag_helper(jQuery('.calendar_d-n-d_timeCounter', _data.ui.draggable)[0], { top: parseInt(style.top), left: event.clientX - jQuery(this).parent().offset().left }, 0); }; var time = jQuery('.calendar_d-n-d_timeCounter', _data.ui.draggable); switch(_event) { // Triggered once, when something is dragged into the timegrid's div case EGW_AI_DRAG_ENTER: // Listen to the drag and update the helper with the time // This part lets us drag between different timegrids jQuery(_data.ui.draggable).on('drag.et2_timegrid' + widget_object.id, drag_listener); jQuery(_data.ui.draggable).on('dragend.et2_timegrid' + widget_object.id, function() { jQuery(_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 { jQuery(_data.ui.draggable).prepend('
'); } break; // Triggered once, when something is dragged out of the timegrid case EGW_AI_DRAG_OUT: // Stop listening jQuery(_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.draggable; // 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) { let time = this._get_time_from_position(position.left, position.top); element.dropEnd = time; let formatted_time = formatTime(time); 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' && this.dropEnd) { 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.options.value.start = event_widget._parent.date_helper(drop_date); // Leave the helper there until the update is done var loading = event_data.event_node; // and add a loading icon so user knows something is happening jQuery('.calendar_calEventHeader', event_widget.div).addClass('loading'); 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 { // 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) { const waitForGroups = []; if(data && data.length) { for(var i = 0; i < data.length; i++) { let event = egw.dataGetUIDdata('calendar::' + data[i]); if(!event || !event.data) { continue; } let wait = (app.calendar)._fetch_group_members(event.data); if(wait !== null) { waitForGroups.push(wait); } } } if(!data || data.length == 0) { return; } Promise.all(waitForGroups).then(() => { 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), et2_calendar_planner.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 this.nodeName = "ET2-SELECT-CAT_RO" let categories = StaticOptions.cached_server_side(this, "cat", ",,," + (event.data.app || 'calendar'), false); } } if(invalidate) { this.invalidate(false); } }) .then(() => { // Update the "now" line _after_ rows are done this._updateNow(); }); }, 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().first().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); } } /** * Set which users to display * * Changing the owner will invalidate the display, and it will be redrawn * after a timeout. Overwriting here to check for groups without members. * * @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) { super.set_owner(_owner); // If we're grouping by user, we need group members if(this.update_timer !== null && this.options.group_by == 'user') { let options = []; let resource = {}; let missing_resources = []; if(app.calendar && app.calendar.sidebox_et2 && app.calendar.sidebox_et2.getWidgetById('owner')) { options = app.calendar.sidebox_et2.getWidgetById('owner').select_options; } 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]; if(isNaN(user) || user >= 0 || !options) continue; // Owner is a group, see if we have its members if(options.find && ((resource = options.find(function (element) { return element.value == user; })))) { // Members found continue; } // Group, but no users found. Need those. missing_resources.push(user); // Maybe api already has them? egw.accountData(parseInt(user),'account_fullname',true,function(result) { missing_resources.splice(missing_resources.indexOf(this),1); }.bind(user),user); } if(missing_resources.length > 0) { // Ask server, and WAIT or we have to redraw egw.json('calendar_owner_etemplate_widget::ajax_owner',[missing_resources],function(data) { for(let owner in data) { if(!owner || typeof owner == "undefined") continue; options.push(data[owner]); } }, this,false,this).sendRequest(false); } } } /** * 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) { if(!this.options.start_date || !this.options.end_date) { return false; } 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); let date; // 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; date = this.date_helper(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) { date = this.date_helper(day.dataset.date); rel_time = ((x - jQuery(day).position().left) / jQuery(day).outerWidth(true)) * 24 * 60; date.setUTCMinutes(Math.round(rel_time / interval) * interval); return date; } 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({element: row, display: row.style.display}); row.style.display = "none"; } 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].element.style.display = hidden_nodes[i].display; } 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; date = this.date_helper(row_widget.options.start_date.toJSON()); } else { return false; } } if(rel_time < 0) return false; date.setUTCMinutes(Math.round(rel_time / (60 * interval)) * interval); return date; } /** * Mousedown handler to support drag to create * * @param {jQuery.Event} event */ _mouse_down(event) { // Only left mouse button if(event.which !== 1) { return; } // Skip for events if(event.target.closest(".calendar_calEvent")) { 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.drag_create.start = {date: time}; this.div.css('cursor', 'ew-resize'); } /** * 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); } if(this.drag_create.end) { return this._drag_create_end(this.drag_create.end); } else if(this.drag_create.start) { // Not dragged enough to count, fake a click event.stopImmediatePropagation(); } this._drag_create_end(); } /** * 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"]);