/** * EGroupware eTemplate2 - JS file which contains the complete et2 module * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package etemplate * @subpackage api * @link https://www.egroupware.org * @author Andreas Stöckel * @copyright EGroupware GmbH 2011-2021 */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { et2_baseWidget, et2_container } from "./et2_core_baseWidget"; import { EgwApp } from "../jsapi/egw_app"; import { et2_IInput, et2_IPrint, et2_IResizeable, et2_ISubmitListener } from "./et2_core_interfaces"; import { egw } from "../jsapi/egw_global"; import { et2_arrayMgr, et2_readonlysArrayMgr } from "./et2_core_arrayMgr"; import { et2_checkType } from "./et2_core_common"; import { et2_compileLegacyJS } from "./et2_core_legacyJSFunctions"; import { et2_loadXMLFromURL } from "./et2_core_xml"; import { et2_nextmatch, et2_nextmatch_header_bar } from "./et2_extension_nextmatch"; import '../jsapi/egw_json.js'; import { egwIsMobile } from "../egw_action/egw_action_common.js"; /* Include all widget classes here, we only care about them registering, not importing anything import './et2_widget_template'; import './et2_widget_grid'; import './et2_widget_box'; import './et2_widget_hbox'; import './et2_widget_groupbox'; import './et2_widget_split'; import './et2_widget_button'; import './et2_widget_color'; import './et2_widget_description'; import './et2_widget_entry'; import './et2_widget_textbox'; import './et2_widget_number'; import './et2_widget_password'; import './et2_widget_url'; import './et2_widget_selectbox'; import './et2_widget_checkbox'; import './et2_widget_radiobox'; import './et2_widget_date'; import './et2_widget_dialog'; import './et2_widget_diff'; import './et2_widget_dropdown_button'; import './et2_widget_styles'; import './et2_widget_favorites'; import './et2_widget_html'; import './et2_widget_htmlarea'; import './et2_widget_tabs'; import './et2_widget_taglist'; import './et2_widget_timestamper'; import './et2_widget_toolbar'; import './et2_widget_tree'; import './et2_widget_historylog'; import './et2_widget_hrule'; import './et2_widget_image'; import './et2_widget_iframe'; import './et2_widget_file'; import './et2_widget_link'; import './et2_widget_progress'; import './et2_widget_portlet'; import './et2_widget_selectAccount'; import './et2_widget_ajaxSelect'; import './et2_widget_vfs'; import './et2_widget_video'; import './et2_widget_audio'; import './et2_widget_barcode'; import './et2_widget_itempicker'; import './et2_widget_script'; import './et2_widget_countdown'; import './et2_extension_nextmatch'; import './et2_extension_customfields'; */ /** * The etemplate2 class manages a certain etemplate2 instance. * * @param _container is the DOM-Node into which the DOM-Nodes of this instance * should be inserted * @param _menuaction is the URL to which the form data should be submitted. */ export class etemplate2 { constructor(_container, _menuaction, _uniqueId) { if (typeof _menuaction == "undefined") { _menuaction = "EGroupware\\Api\\Etemplate::ajax_process_content"; } // Copy the given parameters this._DOMContainer = _container; this.menuaction = _menuaction; // Unique ID to prevent DOM collisions across multiple templates this.uniqueId = _uniqueId ? _uniqueId : (_container.getAttribute("id") ? _container.getAttribute("id").replace('.', '-') : ''); /** * Preset the object variable * @type {et2_container} */ this._widgetContainer = null; // List of templates (XML) that are known, not always used. Indexed by id. // We share list of templates with iframes and popups try { if (opener && opener.etemplate2) { etemplate2.templates = opener.etemplate2.templates; } // @ts-ignore else if (top.etemplate2) { // @ts-ignore etemplate2.templates = top.etemplate2.templates; } } catch (e) { // catch security exception if opener is from a different domain console.log('Security exception accessing etemplate2.prototype of opener or top!'); } if (typeof etemplate2.templates == "undefined") { etemplate2.templates = {}; } } /** * Calls the resize event of all widgets * * @param {jQuery.event} e */ resize(e) { const event = e; const self = this; let excess_height = false; // Check if the framework has an specific excess height calculation if (typeof window.framework != 'undefined' && typeof window.framework.get_wExcessHeight != 'undefined') { excess_height = window.framework.get_wExcessHeight(window); } //@TODO implement getaccess height for other framework and remove if (typeof event != 'undefined' && event.type == 'resize') { if (this.resize_timeout) { clearTimeout(this.resize_timeout); } this.resize_timeout = setTimeout(function () { self.resize_timeout = false; if (self._widgetContainer) { const appHeader = jQuery('#divAppboxHeader'); //Calculate the excess height excess_height = egw(window).is_popup() ? jQuery(window).height() - jQuery(self._DOMContainer).height() - appHeader.outerHeight() + 11 : 0; // Recalculate excess height if the appheader is shown if (appHeader.length > 0 && appHeader.is(':visible')) excess_height -= appHeader.outerHeight() - 9; // Do not resize if the template height is bigger than screen available height // For templates which have sub templates and they are bigger than screenHeight if (screen.availHeight < jQuery(self._DOMContainer).height()) excess_height = 0; // If we're visible, call the "resize" event of all functions which implement the // "IResizeable" interface if (jQuery(self.DOMContainer).is(":visible")) { self._widgetContainer.iterateOver(function (_widget) { _widget.resize(excess_height); }, self, et2_IResizeable); } } }, 100); } // Initial resize needs to be resized immediately (for instance for nextmatch resize) else if (this._widgetContainer) { // Call the "resize" event of all functions which implement the // "IResizeable" interface this._widgetContainer.iterateOver(function (_widget) { _widget.resize(excess_height); }, this, et2_IResizeable); } } ; /** * Clears the current instance. * @param _keep_app_object keep app object */ clear(_keep_app_object) { jQuery(this._DOMContainer).trigger('clear'); // Remove any handlers on window (resize) if (this.uniqueId) { jQuery(window).off("." + this.uniqueId); } // call our destroy_session handler, if it is not already unbind, and unbind it after if (this.destroy_session) { this.destroy_session(); this.unbind_unload(); } if (this._widgetContainer != null) { // Un-register handler this._widgetContainer.egw().unregisterJSONPlugin(this.handle_assign, this, 'assign'); this._widgetContainer.destroy(); this._widgetContainer = null; } jQuery(this._DOMContainer).empty(); // Remove self from the index for (const name in etemplate2.templates) { if (typeof etemplate2._byTemplate[name] == "undefined") continue; for (let i = 0; i < etemplate2._byTemplate[name].length; i++) { if (etemplate2._byTemplate[name][i] == this) { etemplate2._byTemplate[name].splice(i, 1); } } } // If using a private app object, remove all of them if (!_keep_app_object && this.app_obj !== window.app) { for (const app_name in this.app_obj) { if (this.app_obj[app_name] instanceof EgwApp) { this.app_obj[app_name].destroy(); } } } } get widgetContainer() { return this._widgetContainer; } get DOMContainer() { return this._DOMContainer; } get etemplate_exec_id() { return this._etemplate_exec_id; } /** * Creates an associative array containing the data array managers for each part * of the associative data array. A part is something like "content", "readonlys" * or "sel_options". * * @param {object} _data object with values for attributes content, sel_options, readonlys, modifications */ _createArrayManagers(_data) { if (typeof _data == "undefined") { _data = {}; } // Create all neccessary _data entries const neededEntries = ["content", "sel_options", "readonlys", "modifications", "validation_errors"]; for (let i = 0; i < neededEntries.length; i++) { if (typeof _data[neededEntries[i]] == "undefined" || !_data[neededEntries[i]]) { egw.debug("log", "Created not passed entry '" + neededEntries[i] + "' in data array."); _data[neededEntries[i]] = {}; } } const result = {}; // Create an array manager object for each part of the _data array. for (const key in _data) { switch (key) { case "etemplate_exec_id": // already processed case "app_header": break; case "readonlys": result[key] = new et2_readonlysArrayMgr(_data[key]); result[key].perspectiveData.owner = this._widgetContainer; break; default: result[key] = new et2_arrayMgr(_data[key]); result[key].perspectiveData.owner = this._widgetContainer; } } return result; } /** * Bind our unload handler to notify server that eT session/request no longer needed * * We only bind, if we have an etemplate_exec_id: not the case for pure client-side * calls, eg. via et2_dialog. */ bind_unload() { // Prompt user to save for dirty popups if (window !== egw_topWindow() && !this.close_prompt) { this.close_prompt = this._close_changed_prompt.bind(this); window.addEventListener("beforeunload", this.close_prompt); } if (this._etemplate_exec_id) { this.destroy_session = jQuery.proxy(function (ev) { // need to use async === "keepalive" to run via beforeunload egw.json("EGroupware\\Api\\Etemplate::ajax_destroy_session", [this._etemplate_exec_id], null, null, "keepalive").sendRequest(); }, this); window.addEventListener("beforeunload", this.destroy_session); } } _close_changed_prompt(e) { if (this._skip_close_prompt || !this.isDirty()) { return; } // Cancel the event e.preventDefault(); // If you prevent default behavior in Mozilla Firefox prompt will always be shown // Chrome requires returnValue to be set e.returnValue = ''; } skip_close_prompt(skip = true) { this._skip_close_prompt = skip; } /** * Unbind our unload handler */ unbind_unload() { window.removeEventListener("beforeunload", this.destroy_session); window.removeEventListener("beforeunload", this.close_prompt); if (window.onbeforeunload === this.destroy_session) { window.onbeforeunload = null; } else { const onbeforeunload = window.onbeforeunload; window.onbeforeunload = null; // bind unload handler again (can NOT do it direct, as this would be quick enough to be still triggered!) window.setTimeout(function () { window.onbeforeunload = onbeforeunload; }, 100); } delete this.destroy_session; } /** * Download a URL not triggering our unload handler and therefore destroying our et2 request * * We use a new anchor element to avoid not destroying other etemplates as well, which * is what happens if we use window.location * * @param {string} _url */ download(_url) { const a = document.createElement('a'); a.href = _url; a.download = 'download'; // Programmatically trigger a click on the anchor element a.click(); } /** * Loads the template from the given URL and sets the data object * * @param {string} _name name of template * @param {string} _url url to load template * @param {object} _data object with attributes content, langRequire, etemplate_exec_id, ... * @param {function} _callback called after template is loaded * @param {object} _app local app object * @param {boolean} _no_et2_ready true: do not send et2_ready, used by et2_dialog to not overwrite app.js et2 object * @param {string} _open_target flag of string to distinguish between tab target and normal app object * @return Promise */ load(_name, _url, _data, _callback, _app, _no_et2_ready, _open_target) { return __awaiter(this, void 0, void 0, function* () { let app = _app || window.app; this.name = _name; // store top-level template name to have it available in widgets // store template base url, in case initial template is loaded via webdav, to use that for further loads too // need to split off domain first, as it could contain app-name part of template eg. stylite.report.xet and https://my.stylite.de/egw/... if (_url && _url[0] != '/') { this.template_base_url = _url.match(/https?:\/\/[^/]+/).shift(); _url = _url.split(this.template_base_url)[1]; } else { this.template_base_url = ''; } this.template_base_url += _url.split(_name.split('.').shift())[0]; egw().debug("info", "Loaded data", _data); const currentapp = this.app = _data.currentapp || egw().app_name(); const appname = _name.split('.')[0]; // if no app object provided and template app is not currentapp (eg. infolog CRM view) // create private app object / closure with just classes / prototypes if (!_app && appname && appname != currentapp || _open_target) { app = { classes: window.app.classes }; } // remember used app object, to eg. use: onchange="widget.getInstanceMgr().app_object[app].callback()" this.app_obj = app; // extract $content['msg'] and call egw.message() with it const msg = _data.content.msg; if (typeof msg != 'undefined') { egw(window).message(msg); delete _data.content.msg; } // Register a handler for AJAX responses egw(currentapp, window).registerJSONPlugin(this.handle_assign, this, 'assign'); if (egw.debug_level() >= 3) { if (console.groupCollapsed) { egw.window.console.groupCollapsed("Loading %s into ", _name, '#' + this._DOMContainer.id); } } // Timing & profiling on debug level 'log' (4) if (egw.debug_level() >= 4) { if (console.time) { console.time(_name); } if (console.profile) { console.profile(_name); } var start_time = (new Date).getTime(); } // require necessary translations from server AND the app.js file, if not already loaded let promisses = []; if (Array.isArray(_data.langRequire)) { promisses.push(egw(currentapp, window).langRequire(window, _data.langRequire)); } if (appname) { promisses.push(egw(currentapp, window).includeJS('/' + appname + '/js/app.js', undefined, undefined, egw.webserverUrl)); } return Promise.all(promisses).catch((err) => { console.log("et2.load(): error loading lang-files and app.js: " + err.message); }).then(() => { this.clear(); // Initialize application js let app_callback = null; // Only initialize once // new app class with constructor function in app.classes[appname] if (typeof app[appname] !== 'object' && typeof app.classes[appname] == 'function') { app[appname] = new app.classes[appname](); } else if (appname && typeof app[appname] !== "object") { egw.debug("warn", "Did not load '%s' JS object", appname); } // If etemplate current app does not match app owning the template, // initialize the current app too if (typeof app[this.app] !== 'object' && typeof app.classes[this.app] == 'function') { app[this.app] = new app.classes[this.app](); } if (typeof app[appname] == "object") { app_callback = function (_et2, _name) { app[appname].et2_ready(_et2, _name); }; } // Create the basic widget container and attach it to the DOM this._widgetContainer = new et2_container(null); this._widgetContainer.setApiInstance(egw(currentapp, egw.elemWindow(this._DOMContainer))); this._widgetContainer.setInstanceManager(this); this._widgetContainer.setParentDOMNode(this._DOMContainer); // store the id to submit it back to server if (_data) { this._etemplate_exec_id = _data.etemplate_exec_id; // set app_header if (typeof _data.app_header == 'string') { // @ts-ignore window.egw_app_header(_data.app_header); } // bind our unload handler this.bind_unload(); } const _load = function () { egw.debug("log", "Loading template..."); if (egw.debug_level() >= 4 && console.timeStamp) { console.timeStamp("Begin rendering template"); } // Add into indexed list - do this before, so anything looking can find it, // even if it's not loaded if (typeof etemplate2._byTemplate[_name] == "undefined") { etemplate2._byTemplate[_name] = []; } etemplate2._byTemplate[_name].push(this); // Read the XML structure of the requested template this._widgetContainer.loadFromXML(etemplate2.templates[this.name]); // List of Promises from widgets that are not quite fully loaded const deferred = []; // Inform the widget tree that it has been successfully loaded. this._widgetContainer.loadingFinished(deferred); // Connect to the window resize event jQuery(window).on("resize." + this.uniqueId, this, function (e) { e.data.resize(e); }); if (egw.debug_level() >= 3 && console.groupEnd) { egw.window.console.groupEnd(); } if (deferred.length > 0) { let still_deferred = 0; jQuery(deferred).each(function () { if (this.state() == "pending") still_deferred++; }); if (still_deferred > 0) { egw.debug("log", "Template loaded, waiting for %d/%d deferred to finish...", still_deferred, deferred.length); } } // Wait for everything to be loaded, then finish it up jQuery.when.apply(jQuery, deferred).done(jQuery.proxy(function () { egw.debug("log", "Finished loading %s, triggering load event", _name); if (typeof window.framework != 'undefined' && typeof window.framework.et2_loadingFinished != 'undefined') { //Call loading finished method of the framework with local window window.framework.et2_loadingFinished(egw(window).window); } // Trigger the "resize" event this.resize(); // Automatically set focus to first visible input for popups if (this._widgetContainer._egw.is_popup() && jQuery('[autofocus]', this._DOMContainer).focus().length == 0) { const $input = jQuery('input:visible', this._DOMContainer) // Date fields open the calendar popup on focus .not('.et2_date') .filter(function () { // Skip inputs that are out of tab ordering const $this = jQuery(this); return !$this.attr('tabindex') || parseInt($this.attr('tabIndex')) >= 0; }).first(); // mobile device, focus only if the field is empty (usually means new entry) // should focus always for non-mobile one if (egwIsMobile() && $input.val() == "" || !egwIsMobile()) $input.focus(); } // Tell others about it if (typeof _callback == "function") { _callback.call(window, this, _name); } if (app_callback && _callback != app_callback && !_no_et2_ready) { app_callback.call(window, this, _name); } if (appname && appname != this.app && typeof app[this.app] == "object" && !_no_et2_ready) { // Loaded a template from a different application? // Let the application that loaded it know too app[this.app].et2_ready(this, this.name); } jQuery(this._DOMContainer).trigger('load', this); if (etemplate2.templates[this.name].attributes.onload) { let onload = et2_checkType(etemplate2.templates[this.name].attributes.onload.value, 'js', 'onload', {}); if (typeof onload === 'string') { onload = et2_compileLegacyJS(onload, this, this._widgetContainer); } onload.call(this._widgetContainer); } // Profiling if (egw.debug_level() >= 4) { if (console.timeEnd) { console.timeEnd(_name); } if (console.profileEnd) { console.profileEnd(_name); } const end_time = (new Date).getTime(); let gen_time_div = jQuery('#divGenTime_' + appname); if (!gen_time_div.length) gen_time_div = jQuery('.pageGenTime'); gen_time_div.find('.et2RenderTime').remove(); gen_time_div.append('' + egw.lang('eT2 rendering took %1s', '' + ((end_time - start_time) / 1000)) + ''); } }, this)); }; // Load & process try { if (etemplate2.templates[_name]) { // Set array managers first, or errors will happen this._widgetContainer.setArrayMgrs(this._createArrayManagers(_data)); // Already have it _load.apply(this, []); return; } } catch (e) { // weird security exception in IE denying access to template cache in opener if (e.message == 'Permission denied') { etemplate2.templates = {}; } // other error eg. in app.js et2_ready or event handlers --> rethrow it else { throw e; } } // Split the given data into array manager objects and pass those to the // widget container - do this here because file is loaded async this._widgetContainer.setArrayMgrs(this._createArrayManagers(_data)); // Asynchronously load the XET file return et2_loadXMLFromURL(_url, function (_xmldoc) { // Scan for templates and store them for (let i = 0; i < _xmldoc.childNodes.length; i++) { const template = _xmldoc.childNodes[i]; if (template.nodeName.toLowerCase() != "template") continue; etemplate2.templates[template.getAttribute("id")] = template; if (!_name) this.name = template.getAttribute("id"); } _load.apply(this, []); }, this); }); }); } /** * Check if template contains any dirty (unsaved) content * * @returns {Boolean} */ isDirty() { let dirty = false; this._widgetContainer.iterateOver(function (_widget) { if (_widget.isDirty && _widget.isDirty()) { console.info(_widget.id + " is dirty", _widget); dirty = true; } }, this); return dirty; } /** * Submit the et2_container form to a blank iframe in order to activate browser autocomplete */ autocomplete_fixer() { const self = this; const form = self._DOMContainer; // Safari always do the autofill for password field regardless of autocomplete = off // and since there's no other way to switch the autocomplete of, we should switch the // form autocomplete off (e.g. compose dialog, attachment password field) if (navigator.userAgent.match(/safari/i) && !navigator.userAgent.match(/chrome/i) && jQuery('input[type="password"]').length > 0) { return; } if (form) { // Stop submit propagation in order to not fire other possible submit events form.onsubmit = function (e) { e.stopPropagation(); }; // Firefox give a security warning when transmitting to "about:blank" from a https site // we work around that by giving existing etemplate/empty.html url // Safari shows same warning, thought Chrome userAgent also includes Safari if (navigator.userAgent.match(/(firefox|safari|iceweasel)/i) && !navigator.userAgent.match(/chrome/i)) { jQuery(form).attr({ action: egw.webserverUrl + '/api/templates/default/empty.html', method: 'post' }); } // need to trigger submit because submit() would not trigger onsubmit event // since the submit does not get fired directly via user interaction. jQuery(form).trigger('submit'); } } _set_button(button, values) { if (typeof button == 'string') { button = this._widgetContainer.getWidgetById(button); } // Button parameter used for submit buttons in datagrid // TODO: This should probably go in nextmatch's getValues(), along with selected rows somehow. // I'm just not sure how. if (button && !values.button) { let i; values.button = {}; const path = button.getPath(); let target = values; for (i = 0; i < path.length; i++) { if (!values[path[i]]) values[path[i]] = {}; target = values[path[i]]; } if (target != values || button.id.indexOf('[') != -1 && path.length == 0) { let indexes = button.id.split('['); if (indexes.length > 1) { indexes = [indexes.shift(), indexes.join('[')]; indexes[1] = indexes[1].substring(0, indexes[1].length - 1); const children = indexes[1].split(']['); if (children.length) { indexes = jQuery.merge([indexes[0]], children); } } let idx = ''; for (i = 0; i < indexes.length; i++) { idx = indexes[i]; if (!target[idx] || target[idx]['$row_cont']) target[idx] = i < indexes.length - 1 ? {} : true; target = target[idx]; } } else if (typeof values.button == 'undefined' || jQuery.isEmptyObject(values.button)) { delete values.button; values[button.id] = true; } } } /** * Submit form via ajax * * @param {(et2_button|string)} button button widget or string with id * @param {boolean|string} async true: do an asynchronious submit, string: spinner message (please wait...) * default is asynchronoush with message * @param {boolean} no_validation - Do not do individual widget validation, just submit their current values * @param {et2_widget|undefined} _container container to submit, default whole template * @return {boolean} true if submit was send, false if eg. validation stoped submit */ submit(button, async, no_validation, _container) { const api = this._widgetContainer.egw(); if (typeof no_validation == 'undefined') { no_validation = false; } const container = _container || this._widgetContainer; // Get the form values const values = this.getValues(container); // Trigger the submit event let canSubmit = true; let invalid = null; if (!no_validation) { container.iterateOver(function (_widget) { if (_widget.submit(values) === false) { if (!invalid && !_widget.isValid()) { invalid = _widget; } canSubmit = false; } }, this, et2_ISubmitListener); } if (canSubmit) { if (typeof async == 'undefined' || typeof async == 'string') { api.loading_prompt('et2_submit_spinner', true, api.lang(typeof async == 'string' ? async : 'Please wait...')); async = true; } if (button) this._set_button(button, values); // Create the request object if (this.menuaction) { //Autocomplete this.autocomplete_fixer(); // unbind our session-destroy handler, as we are submitting this.unbind_unload(); const request = api.json(this.menuaction, [this._etemplate_exec_id, values, no_validation], function () { api.loading_prompt('et2_submit_spinner', false); }, this, async); request.sendRequest(); } else { this._widgetContainer.egw().debug("warn", "Missing menuaction for submit. Values: ", values); } } else if (invalid !== null) { // Show the first invalid widget, not the last let messages = []; let valid = invalid.isValid(messages); invalid.set_validation_error(messages); } return canSubmit; } /** * Does a full form post submit necessary for downloads * * Only use this one if you need it, use the ajax submit() instead. * It ensures eT2 session continues to exist on server by unbinding unload handler and rebinding it. * * @param {(et2_button|string)} button button widget or string with id */ postSubmit(button) { // Get the form values const values = this.getValues(this._widgetContainer); // Trigger the submit event let canSubmit = true; this._widgetContainer.iterateOver(function (_widget) { if (_widget.submit(values) === false) { canSubmit = false; } }, this, et2_ISubmitListener); if (canSubmit) { if (button) this._set_button(button, values); // unbind our session-destroy handler, as we are submitting this.unbind_unload(); const form = jQuery("