/** * EGroupware clientside Application javascript base object * * @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$ */ "use strict"; /*egw:uses egw_inheritance; */ /** * Object to collect instanciated appliction objects * * Attributes classes collects loaded application classes, * which can get instanciated: * * app[appname] = new app.classes[appname](); * * On destruction only app[appname] gets deleted, app.classes[appname] need to be used again! * * @type object */ window.app = {classes: {}}; /** * Common base class for application javascript * Each app should extend as needed. * * All application javascript should be inside. Intitialization goes in init(), * clean-up code goes in destroy(). Initialization is done once all js is loaded. * * var app.appname = AppJS.extend({ * // Actually set this one, the rest is example * appname: appname, * * internal_var: 1000, * * init: function() * { * // Call the super * this._super.apply(this, arguments); * * // Init the stuff * if ( egw.preference('dateformat', 'common') ) * { * // etc * } * }, * _private: function() * { * // Underscore private by convention * } * }); * * @class AppJS * @augments Class */ var AppJS = Class.extend( { /** * Internal application name - override this */ appname: '', /** * Internal reference to etemplate2 widget tree * * @var {et2_container} */ et2: null, /** * Internal reference to egw client-side api object for current app and window * * @var {egw} */ egw: null, /** * Initialization and setup goes here, but the etemplate2 object * is not yet ready. */ init: function() { window.app[this.appname] = this; this.egw = egw(this.appname, window); // Initialize sidebox for non-popups. // ID set server side if(!this.egw.is_popup()) { var sidebox = jQuery('#favorite_sidebox_'+this.appname); if(sidebox.length == 0 && egw_getFramework() != null) { var egw_fw = egw_getFramework(); sidebox= $j('#favorite_sidebox_'+this.appname,egw_fw.sidemenuDiv); } // Make sure we're running in the top window when we init sidebox if(window.top.app[this.appname] !== this && window.top.app[this.appname]) { window.top.app[this.appname]._init_sidebox(sidebox); } else { this._init_sidebox(sidebox); } } }, /** * Clean up any created objects & references */ destroy: function() { delete this.et2; if (this.sidebox) this.sidebox.off(); delete this.sidebox; delete window.app[this.appname]; }, /** * 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(). Note that this can be called * several times, with different et2 objects, as templates are loaded. * * @param {etemplate2} et2 * @param {string} name template name */ et2_ready: function(et2,name) { if(this.et2 !== null) { egw.debug('log', "Changed et2 object"); } this.et2 = et2.widgetContainer; this._fix_iFrameScrolling(); if (this.egw.is_popup()) this._set_Window_title(); // Highlights the favorite based on initial list state this.highlight_favorite(); }, /** * Observer method receives update notifications from all applications * * App is responsible for only reacting to "messages" it is interested in! * * @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 {object|null} _links app => array of ids of linked entries * or null, if not triggered on server-side, which adds that info * @return {false|*} false to stop regular refresh, thought all observers are run */ observer: function(_msg, _app, _id, _type, _msg_type, _links) { }, /** * Open an entry. * * Designed to be used with the action system as a callback * eg: onExecute => app..open * * @param _action * @param _senders */ open: function(_action, _senders) { var id_app = _senders[0].id.split('::'); egw.open(id_app[1], this.appname); }, /** * A generic method to action to server asynchronously * * Designed to be used with the action system as a callback. * In the PHP side, set the action * 'onExecute' => 'javaScript:app..action', and * implement _do_action(action_id, selected) * * @param {egwAction} _action * @param {egwActionObject[]} _elems */ action: function(_action, _elems) { // let user confirm select-all var select_all = _action.getManager().getActionById("select_all"); var confirm_msg = (_elems.length > 1 || select_all && select_all.checked) && typeof _action.data.confirm_multiple != 'undefined' ? _action.data.confirm_multiple : _action.data.confirm; if (typeof confirm_msg != 'undefined') { var that = this; var action_id = _action.id; et2_dialog.show_dialog(function(button_id,value) { if (button_id != et2_dialog.NO_BUTTON) { that._do_action(action_id, _elems); } }, confirm_msg, egw.lang('Confirmation required'), et2_dialog.BUTTONS_YES_NO, et2_dialog.QUESTION_MESSAGE); } else if (typeof this._do_action == 'function') { this._do_action(_action.id, _elems); } else { // If this is a nextmatch action, do an ajax submit setting the action var nm = null; var action = _action; while(nm == null && action.parent != null) { if(action.data.nextmatch) nm = action.data.nextmatch; action = action.parent; } if(nm != null) { var value = {}; value[nm.options.settings.action_var] = _action.id; nm.set_value(value); nm.getInstanceManager().submit(); } } }, /** * Set the application's state to the given state. * * While not pretending to implement the history API, it is patterned similarly * @link http://www.whatwg.org/specs/web-apps/current-work/multipage/history.html * * The default implementation works with the favorites to apply filters to a nextmatch. * * * @param {{name: string, state: object}|string} state Object (or JSON string) for a state. * Only state is required, and its contents are application specific. * @param {string} template template name to check, instead of trying all templates of current app * @return {boolean} false - Returns false to stop event propagation */ setState: function(state, template) { // State should be an object, not a string, but we'll parse if(typeof state == "string") { if(state.indexOf('{') != -1 || state =='null') { state = JSON.parse(state); } } if(typeof state != "object") { egw.debug('error', 'Unable to set state to %o, needs to be an object',state); return; } if(state == null) { state = {}; } // Check for egw.open() parameters if(state.state && state.state.id && state.state.app) { return egw.open(state.state,undefined,undefined,{},'_self'); } // Try and find a nextmatch widget, and set its filters var nextmatched = false; var et2 = template ? etemplate2.getByTemplate(template) : etemplate2.getByApplication(this.appname); for(var i = 0; i < et2.length; i++) { et2[i].widgetContainer.iterateOver(function(_widget) { // Firefox has trouble with spaces in search if(state.state && state.state.search) state.state.search = unescape(state.state.search); // Apply if(state.state && state.state.sort && state.state.sort.id) { _widget.sortBy(state.state.sort.id, state.state.sort.asc,false); } if(state.state && state.state.selectcols) { // Make sure it's a real array, not an object, then set cols _widget.set_columns(jQuery.extend([],state.state.selectcols)); } _widget.applyFilters(state.state || state.filter || {}); nextmatched = true; }, this, et2_nextmatch); if(nextmatched) return false; } // 'blank' is the special name for no filters, send that instead of the nice translated name var safe_name = jQuery.isEmptyObject(state) || jQuery.isEmptyObject(state.state||state.filter) ? 'blank' : state.name.replace(/[^A-Za-z0-9-_]/g, '_'); var url = '/'+this.appname+'/index.php'; // Try a redirect to list, if app defines a "list" value in registry if (egw.link_get_registry(this.appname, 'list')) { url = egw.link('/index.php', jQuery.extend({'favorite': safe_name}, egw.link_get_registry(this.appname, 'list'))); } // if no list try index value from application else if (egw.app(this.appname).index) { url = egw.link('/index.php', 'menuaction='+egw.app(this.appname).index+'&favorite='+safe_name); } egw.open_link(url, undefined, undefined, this.appname); return false; }, /** * Retrieve the current state of the application for future restoration * * The state can be anything, as long as it's an object. The contents are * application specific. The default implementation finds a nextmatch and * returns its value. * The return value of this function cannot be passed directly to setState(), * since setState is expecting an additional wrapper, eg: * {name: 'something', state: getState()} * * @return {object} Application specific map representing the current state */ getState: function() { var state = {}; // Try and find a nextmatch widget, and set its filters var et2 = etemplate2.getByApplication(this.appname); for(var i = 0; i < et2.length; i++) { et2[i].widgetContainer.iterateOver(function(_widget) { state = _widget.getValue(); }, this, et2_nextmatch); } return state; }, /** * Initializes actions and handlers on sidebox (delete) * * @param {jQuery} sidebox jQuery of DOM node */ _init_sidebox: function(sidebox) { if(sidebox.length) { var self = this; if(this.sidebox) this.sidebox.off(); this.sidebox = sidebox; sidebox .off() // removed .on("mouse(enter|leave)" (wrapping trash icon), as it stalls delete in IE11 .on("click.sidebox","div.ui-icon-trash", this, this.delete_favorite) // need to install a favorite handler, as we switch original one off with .off() .on('click.sidebox','li[data-id]', this, function(event) { var li = $j(this); li.siblings().removeClass('ui-state-highlight'); // Wait an arbitrary 50ms to avoid having the class removed again // by the change handler. if(li.attr('data-id') !== 'blank') { window.setTimeout(function() { li.addClass('ui-state-highlight'); },50); } var href = jQuery('a[href^="javascript:"]', this).prop('href'); var matches = href ? href.match(/^javascript:([^\(]+)\((.*)?\);?$/) : null; if (matches && matches.length > 1 && matches[2] !== undefined) { event.stopImmediatePropagation(); self.setState.call(self, JSON.parse(decodeURI(matches[2]))); return false; } }) .addClass("ui-helper-clearfix"); //Add Sortable handler to sideBox fav. menu jQuery('ul','#favorite_sidebox_'+this.appname).sortable({ items:'li:not([data-id$="add"])', placeholder:'ui-fav-sortable-placeholder', delay:250, //(millisecond) delay before the sorting should start helper: function(event, item) { // We'll need to know which app this is for item.attr('data-appname',self.appname); // Create custom helper so it can be dragged to Home var h_parent = item.parent().parent().clone(); h_parent.find('li').not('[data-id="'+item.attr('data-id')+'"]').remove(); h_parent.appendTo('body'); return h_parent; }, refreshPositions: true, update: function (event, ui) { var favSortedList = jQuery(this).sortable('toArray', {attribute:'data-id'}); self.egw.set_preference(self.appname,'fav_sort_pref',favSortedList); self._refresh_fav_nm(); } }); // Bind favorite de-select var egw_fw = egw_getFramework(); if(egw_fw && egw_fw.applications[this.appname] && egw_fw.applications[this.appname].browser && egw_fw.applications[this.appname].browser.baseDiv) { $j(egw_fw.applications[this.appname].browser.baseDiv) .off('.sidebox') .on('change.sidebox', function() { self.highlight_favorite(); }); } return true; } return false; }, /** * Add a new favorite * * Fetches the current state from the application, then opens a dialog to get the * name and other settings. If user proceeds, the favorite is saved, and if possible * the sidebox is directly updated to include the new favorite * * @param {object} [state] State settings to be merged into the application state */ add_favorite: function(state) { if(typeof this.favorite_popup == "undefined") { this._create_favorite_popup(); } // Get current state this.favorite_popup.state = jQuery.extend({}, this.getState(), state||{}); /* // Add in extras for(var extra in this.options.filters) { // Don't overwrite what nm has, chances are nm has more up-to-date value if(typeof this.popup.current_filters == 'undefined') { this.popup.current_filters[extra] = this.nextmatch.options.settings[extra]; } } // Add in application's settings if(this.filters != true) { for(var i = 0; i < this.filters.length; i++) { this.popup.current_filters[this.options.filters[i]] = this.nextmatch.options.settings[this.options.filters[i]]; } } */ // Make sure it's an object - deep copy to prevent references in sub-objects (col_filters) this.favorite_popup.state = jQuery.extend(true,{},this.favorite_popup.state); // Update popup with current set filters (more for debug than user) var filter_list = []; var add_to_popup = function(arr) { filter_list.push(""); }; add_to_popup(this.favorite_popup.state); $j("#"+this.appname+"_favorites_popup_state",this.favorite_popup) .replaceWith( $j(filter_list.join("")).attr("id",this.appname+"_favorites_popup_state") ); $j("#"+this.appname+"_favorites_popup_state",this.favorite_popup) .hide() .siblings(".ui-icon-circle-plus") .removeClass("ui-icon-circle-minus"); // Popup this.favorite_popup.dialog("open"); console.log(this); // Stop the normal bubbling if this is called on click return false; }, /** * Update favorite items in nm fav. menu * */ _refresh_fav_nm: function () { var self = this; if(etemplate2 && etemplate2.getByApplication) { var et2 = etemplate2.getByApplication(self.appname); for(var i = 0; i < et2.length; i++) { et2[i].widgetContainer.iterateOver(function(_widget) { _widget.stored_filters = _widget.load_favorites(self.appname); _widget.init_filters(_widget); }, self, et2_favorites); } } else { throw new Error ("_refresh_fav_nm():Either et2 is not ready/ not there yet. Make sure that etemplate2 is ready before call this method."); } }, /** * Create the "Add new" popup dialog */ _create_favorite_popup: function() { var self = this; var favorite_prefix = 'favorite_'; // Clear old, if existing if(this.favorite_popup && this.favorite_popup.group) { this.favorite_popup.group.free(); delete this.favorite_popup; } // Create popup this.favorite_popup = $j('
\
\ ' + '\
\ '+ this.egw.lang("Details") + '\
    \ \
' ).appendTo(this.et2 ? this.et2.getDOMNode() : $j('body')); $j(".ui-icon-circle-plus",this.favorite_popup).prev().andSelf().click(function() { var details = $j("#"+self.appname+"_favorites_popup_state",self.favorite_popup) .slideToggle() .siblings(".ui-icon-circle-plus") .toggleClass("ui-icon-circle-minus"); }); // Add some controls if user is an admin var apps = egw().user('apps'); var is_admin = (typeof apps['admin'] != "undefined"); if(is_admin) { this.favorite_popup.group = et2_createWidget("select-account",{ id: "favorite[group]", account_type: "groups", empty_label: "Groups", no_lang: true, parent_node: this.appname+'_favorites_popup_admin' },this.et2 || null); this.favorite_popup.group.loadingFinished(); } var buttons = {}; buttons['save'] = { text: this.egw.lang('save'), default: true, click: function() { // Add a new favorite var name = $j("#name",this); if(name.val()) { // Add to the list name.val(name.val().replace(/(<([^>]+)>)/ig,"")); var safe_name = name.val().replace(/[^A-Za-z0-9-_]/g,"_"); var favorite = { name: name.val(), group: (typeof self.favorite_popup.group != "undefined" && self.favorite_popup.group.get_value() ? self.favorite_popup.group.get_value() : false), state: self.favorite_popup.state }; var favorite_pref = favorite_prefix+safe_name; // Save to preferences if(typeof self.favorite_popup.group != "undefined" && self.favorite_popup.group.getValue() != '') { // Admin stuff - save preference server side self.egw.jsonq(self.appname+'.egw_framework.ajax_set_favorite.template', [ self.appname, name.val(), "add", self.favorite_popup.group.get_value(), self.favorite_popup.state ] ); self.favorite_popup.group.set_value(''); } else { // Normal user - just save to preferences client side self.egw.set_preference(self.appname,favorite_pref,favorite); } // Add to list immediately if(self.sidebox) { // Remove any existing with that name $j('[data-id="'+safe_name+'"]',self.sidebox).remove(); // Create new item var html = "\n"; $j(html).insertBefore($j('li',self.sidebox).last()); self._init_sidebox(self.sidebox); } // Try to update nextmatch favorites too self._refresh_fav_nm(); } // Reset form delete self.favorite_popup.state; name.val(""); $j("#filters",self.favorite_popup).empty(); $j(this).dialog("close"); } }; buttons[this.egw.lang("cancel")] = function() { if(typeof self.favorite_popup.group !== 'undefined' && self.favorite_popup.group.set_value) { self.favorite_popup.group.set_value(null); } $j(this).dialog("close"); }; this.favorite_popup.dialog({ autoOpen: false, modal: true, buttons: buttons, close: function() { } }); // Bind handler for enter keypress this.favorite_popup.off('keydown').on('keydown', jQuery.proxy(function(e) { var tagName = e.target.tagName.toLowerCase(); tagName = (tagName === 'input' && e.target.type === 'button') ? 'button' : tagName; if(e.keyCode == jQuery.ui.keyCode.ENTER && tagName !== 'textarea' && tagName !== 'select' && tagName !=='button') { e.preventDefault(); $j('button[default]',this.favorite_popup.parent()).trigger('click'); return false; } },this)); return false; }, /** * Delete a favorite from the list and update preferences * Registered as a handler on the delete icons * * @param {jQuery.event} event event object */ delete_favorite: function(event) { // Don't do the menu event.stopImmediatePropagation(); var app = event.data; var id = $j(this).parentsUntil('li').parent().attr("data-id"); var group = $j(this).parentsUntil('li').parent().attr("data-group") || ''; var line = $j('li[data-id="'+id+'"]',app.sidebox); var name = line.first().text(); var trash = this; line.addClass('loading'); // Make sure first var do_delete = function(button_id) { if(button_id != et2_dialog.YES_BUTTON) { line.removeClass('loading'); return; } // Hide the trash $j(trash).hide(); // Delete preference server side var request = egw.json(app.appname + ".egw_framework.ajax_set_favorite.template", [app.appname, id, "delete", group, ''], function(result) { // Got the full response from callback, which we don't want if(result.type) return; if(result && typeof result == 'boolean') { // Remove line from list line.slideUp("slow", function() { }); app._refresh_fav_nm(); } else { // Something went wrong server side line.removeClass('loading').addClass('ui-state-error'); } }, $j(trash).parentsUntil("li").parent(), true, $j(trash).parentsUntil("li").parent() ); request.sendRequest(true); }; et2_dialog.show_dialog(do_delete, (egw.lang("Delete") + " " +name +"?"), "Delete", et2_dialog.YES_NO, et2_dialog.QUESTION_MESSAGE); return false; }, /** * Mark the favorite closest matching the current state * * Closest matching takes into account not set values, so we pick the favorite * with the most matching values without a value that differs. */ highlight_favorite: function() { if(!this.sidebox) return; var state = this.getState(); var best_match = false; var best_count = 0; $j('li[data-id]',this.sidebox).removeClass('ui-state-highlight'); $j('li[data-id] a[href^="javascript:"]',this.sidebox).each(function(i,href) { var matches = href.href ? href.href.match(/^javascript:([^\(]+)\((.*)?\);?$/) : null; var favorite = {} if (matches && matches.length > 1 && matches[2] !== undefined) { favorite = JSON.parse(decodeURI(matches[2])); } if(!favorite || jQuery.isEmptyObject(favorite)) return; var match_count = 0; for(var state_key in state) { if(state[state_key] == favorite.state[state_key] || !state[state_key] && !favorite.state[state_key]) { match_count++; } else if (state[state_key] && typeof state[state_key] === 'object' && favorite.state[state_key] && typeof favorite.state[state_key] === 'object') { if((typeof state[state_key].length !== 'undefined' || typeof state[state_key].length !== 'undefined') && (state[state_key].length || Object.keys(state[state_key]).length) != (favorite.state[state_key].length || Object.keys(favorite.state[state_key]).length )) { // State or favorite has a length, but the other does not if((state[state_key].length === 0 || Object.keys(state[state_key]).length === 0) && (favorite.state[state_key].length == 0 || Object.keys(favorite.state[state_key]).length === 0)) { // Just missing, or one is an array and the other is an object continue; } // One has a value and the other doesn't, no match debugger; return; } // Consider sub-objects (column filters) individually for(var sub_key in state[state_key]) { if(state[state_key][sub_key] == favorite.state[state_key][sub_key] || !state[state_key][sub_key] && !favorite.state[state_key][sub_key]) { match_count++; } else if (state[state_key][sub_key] && favorite.state[state_key][sub_key] && typeof state[state_key][sub_key] === 'object' && typeof favorite.state[state_key][sub_key] === 'object') { // Too deep to keep going, just string compare for perfect match if(JSON.stringify(state[state_key][sub_key]) === JSON.stringify(favorite.state[state_key][sub_key])) { match_count++; } } else if(state[state_key][sub_key] && state[state_key][sub_key] != favorite.state[state_key][sub_key]) { // Different values, do not match debugger; return; } } } else if (state_key == 'selectcols') { // Skip, might be set, might not } else if (typeof state[state_key] !== 'undefined' && state[state_key] != favorite.state[state_key]) { // Different values, do not match debugger; return; } } if(match_count > best_count) { best_match = href.parentNode.dataset.id; best_count = match_count; } }); if(best_match) { $j('li[data-id="'+best_match+'"]',this.sidebox).addClass('ui-state-highlight'); } }, /** * Fix scrolling iframe browsed by iPhone/iPod/iPad touch devices */ _fix_iFrameScrolling: function() { if (/iPhone|iPod|iPad/.test(navigator.userAgent)) { jQuery("iframe").on({ load: function() { var body = this.contentWindow.document.body; var div = jQuery(document.createElement("div")) .css ({ 'height' : jQuery(this.parentNode).height(), 'width' : jQuery(this.parentNode).width(), 'overflow' : 'scroll'}); while (body.firstChild) { div.append(body.firstChild); } jQuery(body).append(div); } }); } }, /** * Set document title, uses getWindowTitle to get the correct title, * otherwise set it with uniqueID as default title */ _set_Window_title: function () { var title = this.getWindowTitle(); if (title) { document.title = this.et2._inst.uniqueId + ": " + title; } }, /** * Window title getter function in order to set the window title * this can be overridden on each application app.js file to customize the title value * * @returns {string} window title */ getWindowTitle: function () { var titleWidget = this.et2.getWidgetById('title'); if (titleWidget) { return titleWidget.options.value; } else { return this.et2._inst.uniqueId; } } });