/** * EGroupware - Home - Javascript UI * * @link http://www.egroupware.org * @package home * @author Nathan Gray * @copyright (c) 2013 Nathan Gray * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License */ /*egw:uses /vendor/bower-asset/jquery/dist/jquery.js; /vendor/bower-asset/jquery-ui/jquery-ui.js; /vendor/npm-asset/gridster/dist/jquery.gridster.js; */ import {AppJS} from "../../api/js/jsapi/app_base.js"; import {et2_createWidget} from "../../api/js/etemplate/et2_core_widget"; import {et2_dialog} from "../../api/js/etemplate/et2_widget_dialog"; import {et2_button} from "../../api/js/etemplate/et2_widget_button"; // need legacy loading (uses this instead of window): import "../../vendor/npm-asset/gridster/dist/jquery.gridster.js"; import "../../api/js/jsapi/egw_inheritance.js"; // Class /** * JS for home application * * Home is a collection of little bits of content (portlets) from the other applications. * * Uses Gridster for the grid layout * @see http://gridster.net * @augments AppJS */ app.classes.home = (function(){ "use strict"; return AppJS.extend( { /** * AppJS requires overwriting this with the actual application name */ appname: "home", /** * Grid resolution. Must match et2_portlet GRID */ GRID: 50, /** * Default size for new portlets */ DEFAULT: { WIDTH: 4, HEIGHT: 1 }, // List of portlets portlets: {}, /** * Constructor * * @memberOf app.home */ init: function() { // call parent this._super.apply(this, arguments); }, /** * Destructor * @memberOf app.home */ destroy: function() { delete this.et2; delete this.portlet_container; this.portlets = {}; // call parent this._super.apply(this, arguments); // Make sure all other sub-etemplates in portlets are done var others = etemplate2.getByApplication(this.appname); for(var i = 0; i < others.length; i++) { others[i].clear(); } }, /** * This function is called when the etemplate2 object is loaded * and ready. If you must store a reference to the et2 object, * make sure to clean it up in destroy(). * * @param {etemplate2} et2 Newly ready object * @param {string} Template name */ et2_ready: function(et2, name) { // Top level if(name == 'home.index') { // call parent this._super.apply(this, arguments); this.et2.set_id('home.index'); this.et2.set_actions(this.et2.getArrayMgr('modifications').getEntry('home.index')['actions']); this.portlet_container = this.et2.getWidgetById("portlets"); // Set up sorting of portlets this._do_ordering(); // Accept drops of favorites, which aren't part of action system jQuery(this.et2.getDOMNode().parentNode).droppable({ hoverClass: 'drop-hover', accept: function(draggable) { // Check for direct support for that application if(draggable[0].dataset && draggable[0].dataset.appname) { return egw_getActionManager('home',false,1).getActionById('drop_'+draggable[0].dataset.appname +'_favorite_portlet') != null; } return false; }, drop: function(event, ui) { // Favorite dropped on home - fake an action and divert to normal handler var action = { data: { class: 'add_home_favorite_portlet' } } // Check for direct support for that application if(ui.helper.context.dataset && ui.helper.context.dataset.appname) { var action = egw_getActionManager('home',false,1).getActionById('drop_'+ui.helper.context.dataset.appname +'_favorite_portlet') || {} } action.ui = ui; app.home.add_from_drop(action, [{data: ui.helper.context.dataset}]) } }) // Bind to unload to remove it from our list .on('clear','.et2_container[id]', jQuery.proxy(function(e) { if(e.target && e.target.id && this.portlets[e.target.id]) { this.portlets[e.target.id].destroy(); delete this.portlets[e.target.id]; } },this)); } else if (et2.uniqueId) { let portlet_container = this.portlet_container || window.app.home?.portlet_container; // Handle bad timing - a sub-template was finished first if(!portlet_container) { window.setTimeout(jQuery.proxy(function() {this.et2_ready(et2, name);},this),200); return; } var portlet = portlet_container.getWidgetById(et2.uniqueId); // Check for existing etemplate, this one loaded over it // NOTE: Moving them around like this can cause problems with event handlers var existing = etemplate2.getById(et2.uniqueId); if(portlet && existing) { for(var i = 0; i < portlet._children.length; i++) { if(typeof portlet._children[i]._init == 'undefined') { portlet.removeChild(portlet._children[i]) } } } // It's in the right place for original load, but move it into portlet var misplaced = jQuery(etemplate2.getById('home-index').DOMContainer).siblings('#'+et2.DOMContainer.id); if(portlet) { portlet.content = jQuery(et2.DOMContainer).appendTo(portlet.content); portlet.addChild(et2.widgetContainer); et2.resize(); } if(portlet && misplaced.length) { et2.DOMContainer.id = et2.uniqueId; } // Instanciate custom code for this portlet this._get_portlet_code(portlet); } // Special handling to deal with legacy (non-et2) calendar links if(name == 'home.legacy') { jQuery('.calendar_calDayColHeader a, .calendar_plannerDayScale a, .calendar_plannerWeekScale a, .calendar_plannerMonthScale a, .calendar_calGridHeader a', et2.DOMContainer) .on('click', function(e) { egw.link_handler(this.href,'calendar'); return false; }); } }, /** * Observer method receives update notifications from all applications * * Home passes the notification off to specific code for each portlet, which * decide if they should be updated or not. * * @param {string} _msg message (already translated) to show, eg. 'Entry deleted' * @param {string} _app application name * @param {(string|number)} _id id of entry to refresh or null * @param {string} _type either 'update', 'edit', 'delete', 'add' or null * - update: request just modified data from given rows. Sorting is not considered, * so if the sort field is changed, the row will not be moved. * - edit: rows changed, but sorting may be affected. Requires full reload. * - delete: just delete the given rows clientside (no server interaction neccessary) * - add: requires full reload for proper sorting * @param {string} _msg_type 'error', 'warning' or 'success' (default) * @param {string} _targetapp which app's window should be refreshed, default current * @return {false|*} false to stop regular refresh, thought all observers are run */ observer: function(_msg, _app, _id, _type, _msg_type, _targetapp) { for(var id in this.portlets) { // App is home, refresh all portlets if(_app == 'home') { this.refresh(id); continue; } // Ask the portlets if they're interested try { var code = this.portlets[id]; if(code) { code.observer(_msg,_app,_id,_type,_msg_type,_targetapp); } } catch(e) { this.egw.debug("error", "Error trying to update portlet " + id,e); } } return false; }, /** * Add a new portlet from the context menu */ add: function(action, source) { // Basic portlet attributes var attrs = { id: this._create_id(), class: action.data.class, width: this.DEFAULT.WIDTH, height: this.DEFAULT.HEIGHT }; // Try to put it about where the menu was opened if(action.menu_context) { var $portlet_container = jQuery(this.portlet_container.getDOMNode()); attrs.row = Math.max(1,Math.round((action.menu_context.posy - $portlet_container.offset().top )/ this.GRID)+1); attrs.col = Math.max(1,Math.round((action.menu_context.posx - $portlet_container.offset().left) / this.GRID)+1); } // Don't pass default width & height so class can set it delete attrs.width; delete attrs.height; var portlet = et2_createWidget('portlet',jQuery.extend({},attrs), this.portlet_container); portlet.loadingFinished(); // Immediately add content ID so etemplate loads into the right place portlet.content.append('
'); // Get actual attributes & settings, since they're not available client side yet portlet._process_edit(et2_dialog.OK_BUTTON, attrs); // Set up sorting/grid of new portlet var $portlet_container = jQuery(this.portlet_container.getDOMNode()); $portlet_container.data("gridster").add_widget( portlet.getDOMNode(), this.DEFAULT.WIDTH, this.DEFAULT.HEIGHT, attrs.col, attrs.row ); // Instanciate custom code for this portlet this._get_portlet_code(portlet); }, /** * User dropped something on home. Add a new portlet */ add_from_drop: function(action,source) { // Actions got confused drop vs popup if(source[0].id == 'portlets') { return this.add(action); } var $portlet_container = jQuery(this.portlet_container.getDOMNode()); // Basic portlet attributes var attrs = { id: this._create_id(), class: action.data.class || action.id.substr(5), width: this.DEFAULT.WIDTH, height: this.DEFAULT.HEIGHT }; // Try to find where the drop was if(action != null && action.ui && action.ui.position) { attrs.row = Math.max(1,Math.round((action.ui.position.top - $portlet_container.offset().top )/ this.GRID)); attrs.col = Math.max(1,Math.round((action.ui.position.left - $portlet_container.offset().left) / this.GRID)); } var portlet = et2_createWidget('portlet',jQuery.extend({},attrs), this.portlet_container); portlet.loadingFinished(); // Immediately add content ID so etemplate loads into the right place portlet.content.append(''); // Get actual attributes & settings, since they're not available client side yet var drop_data = []; for(var i = 0; i < source.length; i++) { if(source[i].id) { drop_data.push(source[i].id); } else { drop_data.push(source[i].data); } } // Don't pass default width & height so class can set it delete attrs.width; delete attrs.height; portlet._process_edit(et2_dialog.OK_BUTTON, jQuery.extend({dropped_data: drop_data},attrs)); // Set up sorting/grid of new portlet $portlet_container.data("gridster").add_widget( portlet.getDOMNode(), this.DEFAULT.WIDTH, this.DEFAULT.HEIGHT, attrs.col, attrs.row ); // Instanciate custom code for this portlet this._get_portlet_code(portlet); }, /** * Set the current selection as default for other users * * Only works (and available) for admins, this shows a dialog to select * the group, and then sets the default for that group. * * @param {egwAction} action * @param {egwActionObject[]} selected */ set_default: function(action, selected) { // Gather just IDs, server will handle the details var portlet_ids = []; var group = action.data.portlet_group || false; if(selected[0].id == 'home.index') { // Set all this.portlet_container.iterateOver(function(portlet) { portlet_ids.push(portlet.id); },this,et2_portlet); } else { for(var i = 0; i < selected.length; i++) { portlet_ids.push(selected[i].id); // Read the associated group so we can properly remove it var portlet = egw.preference(selected[i].id,'home'); if(!group && portlet && portlet.group) { group = portlet.group; } } } if(action.id.indexOf("remove_default") == 0) { // Disable action for feedback action.set_enabled(false); // Pass them to server egw.json('home_ui::ajax_set_default', ['delete', portlet_ids, group]).sendRequest(true); return; } var dialog = et2_createWidget("dialog",{ // If you use a template, the second parameter will be the value of the template, as if it were submitted. callback: function(button_id, value) { if(button_id != et2_dialog.OK_BUTTON) return; // Pass them to server egw.json('home_ui::ajax_set_default', ['add', portlet_ids, value.group||false]).sendRequest(true); }, buttons: et2_dialog.BUTTONS_OK_CANCEL, title: action.caption, template:"home.set_default", value: {content:{}, sel_options: {group:{default: egw.lang('All'), forced: egw.lang('Forced')}}} }); }, /** * Allow a refresh from anywhere by triggering an update with no changes * * @param {string} id */ refresh: function(id) { var p = this.portlet_container.getWidgetById(id); if(p) { p._process_edit(et2_dialog.OK_BUTTON, '~reload~'); } }, /** * Determine the best fitting code to use for the given portlet, instanciate * it and add it to the list. * * @param {et2_portlet} portlet * @returns {home_portlet} */ _get_portlet_code: function(portlet) { var classname = portlet.class; // Freshly added portlets can have 'add_' prefix if(classname.indexOf('add_') == 0) { classname = classname.replace('add_',''); } // Prefer a specific match var _class = app.classes.home[classname] || // If it has a nextmatch, use favorite base class (portlet.getWidgetById('nm') ? app.classes.home.home_favorite_portlet : false) || // Fall back to base class app.classes.home.home_portlet; this.portlets[portlet.id] = new _class(portlet); return this.portlets[portlet.id]; }, /** * For link_portlet - opens the configured record when the user * double-clicks or chooses view from the context menu */ open_link: function(action) { // Get widget var widget = null; while(action.parent != null) { if(action.data && action.data.widget) { widget = action.data.widget; break; } action = action.parent; } if(widget == null) { egw().log("warning", "Could not find widget"); return; } egw().open(widget.options.settings.entry, "", 'view',null,widget.options.settings.entry.app); }, /** * Set up the drag / drop / re-order of portlets */ _do_ordering: function() { var $portlet_container = jQuery(this.portlet_container.getDOMNode()); $portlet_container .addClass("home ui-helper-clearfix") .disableSelection() /* Gridster */ .gridster({ widget_selector: 'div.et2_portlet', // Dimensions + margins = grid spacing widget_base_dimensions: [this.GRID-5, this.GRID-5], widget_margins: [5,5], extra_rows: 1, extra_cols: 1, min_cols: 3, min_rows: 3, /** * Set which parameters we want when calling serialize(). * @param $w jQuery jQuery-wrapped element * @param grid Object Grid settings * @return Object - will be returned by gridster.serialize() */ serialize_params: function($w, grid) { return { id: $w.attr('id').replace(app.home.portlet_container.getInstanceManager().uniqueId+'_',''), row: grid.row, col: grid.col, width: grid.size_x, height: grid.size_y }; }, /** * Gridster's internal drag settings */ draggable: { handle: '.ui-widget-header', stop: function(event,ui) { // Update widget(s) var changed = this.serialize_changed(); // Reset changed, or they keep accumulating this.$changed = jQuery([]); for (var key in changed) { if(!changed[key].id) continue; // Changed ID is the ID var widget = window.app.home.portlet_container.getWidgetById(changed[key].id); if(!widget || widget == window.app.home.portlet_container) continue; egw().jsonq("home.home_ui.ajax_set_properties",[changed[key].id, {},{ row: changed[key].row, col: changed[key].col },widget.settings?widget.settings.group:false], null, widget, true, widget ); } } } }); // Rescue selectboxes from Firefox $portlet_container.on('mousedown touchstart', 'select', function(e) { e.stopPropagation(); }); // Bind window resize to re-layout gridster jQuery(window).one("resize."+this.et2._inst.uniqueId, function() { // Note this doesn't change the positions, just makes them invalid $portlet_container.data('gridster').recalculate_faux_grid(); }); // Bind resize to update gridster - this may happen _before_ the widget gets a // chance to update itself, so we can't use the widget $portlet_container .on("resizestop", function(event, ui) { $portlet_container.data("gridster").resize_widget( ui.element, Math.round(ui.size.width / app.home.GRID), Math.round(ui.size.height / app.home.GRID) ); }); }, /** * Create an ID that should be unique, at least amoung a single user's portlets */ _create_id: function() { var id = ''; do { id = Math.floor((1 + Math.random()) * 0x10000) .toString(16) .substring(1); } while(this.portlet_container.getWidgetById('portlet_'+id)); return 'portlet_'+id; }, /** * Functions for the list portlet */ /** * For list_portlet - opens a dialog to add a new entry to the list * * @param {egwAction} action Drop or add action * @param {egwActionObject[]} Selected entries * @param {egwActionObject} target_action Drop target */ add_link: function(action, source, target_action) { // Actions got confused drop vs popup if(source[0].id == 'portlets') { return this.add_link(action); } // Get widget var widget = null; while(action.parent != null) { if(action.data && action.data.widget) { widget = action.data.widget; break; } action = action.parent; } if(target_action == null) { // use template base url from initial template, to continue using webdav, if that was loaded via webdav var splitted = 'home.edit'.split('.'); var path = app.home.portlet_container.getRoot()._inst.template_base_url + splitted.shift() + "/templates/default/" + splitted.join('.')+ ".xet"; var dialog = et2_createWidget("dialog",{ callback: function(button_id, value) { if(button_id == et2_dialog.CANCEL_BUTTON) return; var new_list = widget.options.settings.list || []; for(var i = 0; i < new_list.length; i++) { if(new_list[i].app == value.add.app && new_list[i].id == value.add.id) { // Duplicate - skip it return; } } value.add.link_id = value.add.app + ':' + value.add.id; // Update server side new_list.push(value.add); widget._process_edit(button_id,{list: new_list}); // Update client side var list = widget.getWidgetById('list'); if(list) { list.set_value(new_list); } }, buttons: et2_dialog.BUTTONS_OK_CANCEL, title: app.home.egw.lang('add'), template:path, value: { content: [{label: app.home.egw.lang('add'),type: 'link-entry',name: 'add',size:''}]} }); } else { // Drag'n'dropped something on the list - just send action IDs var new_list = widget.options.settings.list || []; var changed = false; for(var i = 0; i < new_list.length; i++) { // Avoid duplicates for(var j = 0; j < source.length; j++) { if(!source[j].id || new_list[i].app+"::"+new_list[i].id == source[j].id) { // Duplicate - skip it source.splice(j,1); } } } for(var i = 0; i < source.length; i++) { var explode = source[i].id.split('::'); new_list.push({app: explode[0],id: explode[1], link_id: explode.join(':')}); changed = true; } if(changed) { widget._process_edit(et2_dialog.OK_BUTTON,{ list: new_list || {} }); } // Filemanager support - links need app = 'file' and type set for(var i = 0; i < new_list.length; i++) { if(new_list[i]['app'] == 'filemanager') { new_list[i]['app'] = 'file'; new_list[i]['path'] = new_list[i]['title'] = new_list[i]['icon'] = new_list[i]['id']; } } widget.getWidgetById('list').set_value(new_list); } }, /** * Remove a link from the list */ link_change: function(list, link_id, row) { // Quick response client side row.slideUp(row.remove); // Actual removal var portlet = list._parent._parent; portlet.options.settings.list.splice(row.index(), 1); portlet._process_edit(et2_dialog.OK_BUTTON,{list: portlet.options.settings.list || {}}); }, /** * Functions for the note portlet */ /** * Set up for editing a note * CKEditor has CSP issues, so we need a popup * * @param {egwAction} action * @param {egwActionObject[]} Selected */ note_edit: function(action, selected) { if(!selected && typeof action == 'string') { var id = action; } else { var id = selected[0].id; } // Aim to match the size var portlet_dom = jQuery('[id$='+id+'][data-sizex]',this.portlet_container.getDOMNode()); var width = portlet_dom.attr('data-sizex') * this.GRID; var height = portlet_dom.attr('data-sizey') * this.GRID; // CKEditor is impossible to use below a certain size // Add 35px for the toolbar, 35px for the buttons var window_width = Math.max(580, width+20); var window_height = Math.max(350, height+70); // Open popup, but add 70 to the height for the toolbar egw.open_link(egw.link('/index.php',{ menuaction: 'home.home_note_portlet.edit', id: id, height: window_height - 70 }),'home_'+id, window_width+'x'+window_height,'home'); }, /** * Favorites / nextmatch */ /** * Toggle the nextmatch header shown / hidden * * @param {Event} event * @param {et2_button} widget */ nextmatch_toggle_header: function(event, widget) { widget.set_class(widget.class == 'opened' ? 'closed' : 'opened'); // We operate on the DOM here, nm should be unaware of our fiddling var nm = widget.getParent().getWidgetById('nm'); if(!nm) return; // Hide header nm.div.toggleClass('header_hidden'); nm.set_hide_header(nm.div.hasClass('header_hidden')); nm.resize(); } })}).call(window); /// Base class code /** * Base class for portlet specific javascript * * Should this maybe extend et2_portlet? It would complicate instantiation. * * @type @exp;Class@call;extend */ app.classes.home.home_portlet = Class.extend({ portlet: null, init: function(portlet) { this.portlet = portlet; }, destroy: function() { this.portlet = null; }, /** * Handle framework refresh messages to determine if the portlet needs to * refresh too. * * App is responsible for only reacting to "messages" it is interested in! * */ observer: function(_msg, _app, _id, _type, _msg_type, _targetapp) { // Not interested } }); app.classes.home.home_link_portlet = app.classes.home.home_portlet.extend({ init: function(portlet) { // call parent this._super.apply(this, arguments); // Check for tooltip if(this.portlet) { var content = jQuery('.tooltip',this.portlet.content); if(content.length && content.children().length) { //Check if the tooltip is already initialized this.portlet.content.tooltip({ items: this.portlet.content, content: content.html(), tooltipClass: 'portlet_' + this.portlet.id, show: {effect: 'slideDown', delay:500}, hide: {effect: 'slideUp', delay: 500}, position: {my: "left top", at:"left bottom", collision: "flipfit"}, open: jQuery.proxy(function(event, ui) { // Calendar specific formatting if(ui.tooltip.has('.calendar_calEventTooltip').length) { ui.tooltip.removeClass("ui-tooltip"); ui.tooltip.addClass("calendar_uitooltip"); } },this), close: function(event,ui) { ui.tooltip.hover( function() { jQuery(this).stop(true).fadeTo(100,1); }, function() { jQuery(this).slideUp("400",function() {jQuery(this).remove();}); } ); } }); } } }, observer: function(_msg, _app, _id, _type) { if(this.portlet && this.portlet.settings) { var value = this.portlet.settings.entry || {}; if(value.app && value.app == _app && value.id && value.id == _id) { // We don't just get the updated title, in case there's a custom // template with more fields app.home.refresh(this.portlet.id); } } } }); app.classes.home.home_list_portlet = app.classes.home.home_portlet.extend({ observer: function(_msg, _app, _id, _type) { if(this.portlet && this.portlet.getWidgetById('list')) { var list = this.portlet.getWidgetById('list').options.value; for(var i = 0; i < list.length; i++) { if(list[i].app == _app && list[i].id == _id) { app.home.refresh(this.portlet.id); return; } } } } }); app.classes.home.home_weather_portlet = app.classes.home.home_portlet.extend({ init: function(portlet) { // call parent this._super.apply(this, arguments); // Use location API if(!this.portlet.options.settings && 'geolocation' in navigator) { navigator.geolocation.getCurrentPosition(function(position) { if(portlet && portlet.options && portlet.options.settings && portlet.options.settings.position && portlet.options.settings.position == position.coords.latitude + ',' + position.coords.longitude) { return; } portlet._process_edit(et2_dialog.OK_BUTTON, {position: position.coords.latitude + ',' + position.coords.longitude}); }); } } }); app.classes.home.home_favorite_portlet = app.classes.home.home_portlet.extend({ init: function(portlet) { // call parent this._super.apply(this, arguments); // Somehow favorite got lost, or is not set if(portlet.options && portlet.options.settings && typeof portlet.options.settings !== 'undefined' && !portlet.options.settings.favorite ) { portlet.edit_settings(); } }, observer: function(_msg, _app, _id, _type, _msg_type, _targetapp) { if(this.portlet.class.indexOf(_app) == 0 || this.portlet.class == 'home_favorite_portlet') { this.portlet.getWidgetById('nm').refresh(_id,_type); } } }); /** * An example illustrating extending the base code for a application specific code. * See also the calendar app, which needs custom handlers * * @type @exp;app@pro;classes@pro;home@pro;home_favorite_portlet@call;extend * Note we put it in home, but this code should go in addressbook/js/addressbook_favorite_portlet.js * app.classes.home.addressbook_favorite_portlet = app.classes.home.home_favorite_portlet.extend({ observer: function(_msg, _app, _id, _type, _msg_type, _targetapp) { // Just checking... debugger; } }); */