egroupware/api/js/jsapi/egw_json.js
nathangray 38ff63f778 Api: New JavaScript API method egw.request(menuaction : string, parameters : any[]) : Promise
Does an AJAX request, and resolves the returned Promise with just the data (no piggybacks) when it arrives from the server.  Any registered data handlers are run before resolving the Promise.
2020-10-16 10:33:13 -06:00

780 lines
24 KiB
JavaScript

/**
* EGroupware clientside API object
*
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package etemplate
* @subpackage api
* @link http://www.egroupware.org
* @author Andreas Stöckel (as AT stylite.de)
* @author Ralf Becker <RalfBecker@outdoor-training.de>
*/
/*egw:uses
vendor.bower-asset.jquery.dist.jquery;
egw_core;
egw_utils;
egw_files;
egw_debug;
*/
/**
* Module sending json requests
*
* @param {string} _app application name object is instanciated for
* @param {object} _wnd window object is instanciated for
*/
egw.extend('json', egw.MODULE_WND_LOCAL, function(_app, _wnd)
{
"use strict";
/**
* Object which contains all registered handlers for JS responses.
* The handlers are organized per response type in the top level of the
* object, where each response type can have an array of handlers attached
* to it.
*/
var plugins = {};
/**
* Global json handlers are from global modules, not window level
*/
if(typeof egw._global_json_handlers == 'undefined')
{
egw._global_json_handlers = {};
}
var global_plugins = egw._global_json_handlers;
/**
* Internal implementation of the JSON request object.
*
* @param {string} _menuaction
* @param {array} _parameters
* @param {function} _callback
* @param {object} _context
* @param {boolean|"keepalive"} _async true: asynchronious request, false: synchronious request,
* "keepalive": async. request with keepalive===true / sendBeacon, to be used in boforeunload event
* @param {object} _sender
* @param {egw} _egw
*/
function json_request(_menuaction, _parameters, _callback, _context,
_async, _sender, _egw)
{
// Copy the parameters
this.url = _egw.ajaxUrl(_menuaction);
// IE JSON-serializes arrays passed in from different window contextx (eg. popups)
// as objects (it looses object-type of array), causing them to be JSON serialized
// as objects and loosing parameters which are undefined
// JSON.strigify([123,undefined]) --> '{"0":123}' instead of '[123,null]'
this.parameters = _parameters ? [].concat(_parameters) : [];
this.async = typeof _async != 'undefined' ? _async : true;
this.callback = _callback ? _callback : null;
this.context = _context ? _context : null;
this.sender = _sender ? _sender : null;
this.egw = _egw;
// We currently don't have a request object
this.request = null;
// Some variables needed for notification about a JS files done loading
this.onLoadFinish = null;
this.jsFiles = 0;
this.jsCount = 0;
// Function which is currently used to display alerts -- may be replaced by
// some API function.
this.alertHandler = function(_message, _details) {
// we need to use the alert function of the window of the request, not just the main window
(this.egw ? this.egw.window : window).alert(_message);
if (_details)
{
_egw.debug('info', _message, _details);
}
};
}
/**
* Open websocket to push server (and keeps it open)
*
* @param {string} url this.websocket(s)://host:port
* @param {array} tokens tokens to subscribe too: sesssion-, user- and instance-token (in that order!)
* @param {number} account_id to connect for
* @param {function} error option error callback(_msg) used instead our default this.error
* @param {int} reconnect timeout in ms (internal)
*/
json_request.prototype.openWebSocket = function(url, tokens, account_id, error, reconnect)
{
const min_reconnect_time = 1000;
const max_reconnect_time = 300000;
const check_interval = 30000; // 30 sec
const max_ping_response_time = 1000;
let reconnect_time = reconnect || min_reconnect_time;
let check_timer;
let check = function()
{
this.websocket.send('ping');
check_timer = window.setTimeout(function()
{
console.log("Server did not respond to ping in "+max_ping_response_time+" seconds --> try reconnecting");
check_timer = null;
this.websocket.close(); // closing it now, before reopening it, to not end up with multiple connections
this.openWebSocket(url, tokens, account_id, error, reconnect_time);
}.bind(this), max_ping_response_time);
}.bind(this);
this.websocket = new WebSocket(url);
this.websocket.onopen = jQuery.proxy(function(e)
{
check_timer = window.setTimeout(check, check_interval);
this.websocket.send(JSON.stringify({
subscribe: tokens,
account_id: parseInt(account_id)
}));
}, this);
this.websocket.onmessage = jQuery.proxy(function(event)
{
reconnect_time = min_reconnect_time;
console.log(event);
if (check_timer) window.clearTimeout(check_timer);
check_timer = window.setTimeout(check, check_interval);
if (event.data === 'pong') return; // just a keepalive message
let data = JSON.parse(event.data);
if (data && data.type)
{
this.handleResponse({ response: [data]});
}
}, this);
this.websocket.onerror = jQuery.proxy(function(error)
{
reconnect_time *= 2;
if (reconnect_time > max_reconnect_time) reconnect_time = max_reconnect_time;
console.log(error);
(error||this.handleError({}, error));
}, this);
this.websocket.onclose = jQuery.proxy(function(event)
{
if (event.wasClean)
{
reconnect_time = min_reconnect_time;
console.log(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
}
else
{
reconnect_time *= 2;
if (reconnect_time > max_reconnect_time) reconnect_time = max_reconnect_time;
// e.g. server process killed or network down
// event.code is usually 1006 in this case
console.log('[close] Connection died --> reconnect in '+reconnect_time+'ms');
if (check_timer) window.clearTimeout(check_timer);
check_timer = null;
window.setTimeout(jQuery.proxy(this.openWebSocket, this, url, tokens, account_id, error, reconnect_time), reconnect_time);
}
}, this);
},
/**
* Sends the assembled request to the server
* @param {boolean|"keepalive"} _async Overrides async provided in constructor: true: asynchronious request,
* false: synchronious request, "keepalive": async. request with keepalive===true / sendBeacon, to be used in beforeunload event
* @param {string} method ='POST' allow to eg. use a (cachable) 'GET' request instead of POST
* @param {function} error option error callback(_xmlhttp, _err) used instead our default this.error
*
* @return {jqXHR|boolean} jQuery jqXHR request object or for async==="keepalive" boolean is returned
*/
json_request.prototype.sendRequest = function(async, method, error)
{
if(typeof async != "undefined")
{
this.async = async;
}
if (typeof method === 'undefined') method = 'POST';
// Assemble the complete request
var request_obj = JSON.stringify({
request: {
parameters: this.parameters
}
});
// send with keepalive===true or sendBeacon to be used in beforeunload event
if (this.async === "keepalive" && typeof navigator.sendBeacon !== "undefined")
{
const data = new FormData();
data.append('json_data', request_obj);
//(window.opener||window).console.log("navigator.sendBeacon", this.url, request_obj, data.getAll('json_data'));
return navigator.sendBeacon(this.url, data);
}
// Send the request via AJAX using the jquery ajax function
// we need to use jQuery of window of egw object, as otherwise the one from main window is used!
// (causing eg. apply from server with app.$app.method to run in main window instead of popup)
this.request = (this.egw.window?this.egw.window.jQuery:jQuery).ajax({
url: this.url,
async: this.async,
context: this,
// only POST can send JSON as direct payload, GET can not
data: method === 'GET' ? { json_data: request_obj } : request_obj,
contentType: method === 'GET' ? false : 'application/json',
dataType: 'json',
type: method,
success: this.handleResponse,
jsonp: false,
error: error || this.handleError
});
return this.request;
};
/**
* Default error callback displaying error via egw.message
*
* @param {XMLHTTP} _xmlhttp
* @param {string} _err
*/
json_request.prototype.handleError = function(_xmlhttp, _err) {
// Don't error about an abort
if(_err !== 'abort')
{
this.egw.message.call(this.egw,
this.egw.lang('A request to the EGroupware server returned with an error')+
': '+_xmlhttp.statusText+' ('+_xmlhttp.status+")\n\n"+
this.egw.lang('Please reload the EGroupware desktop (F5 / Cmd+r).')+"\n"+
this.egw.lang('If the error persists, contact your administrator for help and ask to check the error-log of the webserver.')+
"\n\nURL: "+this.url+"\n"+
(_xmlhttp.getAllResponseHeaders() ? (_xmlhttp.getAllResponseHeaders().match(/^Date:.*$/mi) ? _xmlhttp.getAllResponseHeaders().match(/^Date:.*$/mi)[0]:''):'')+
// if EGroupware send JSON payload with error, errno show it here too
(_err === 'error' && _xmlhttp.status === 400 && typeof _xmlhttp.responseJSON === 'object' && _xmlhttp.responseJSON.error ?
"\nError: "+_xmlhttp.responseJSON.error+' ('+_xmlhttp.responseJSON.errno+')' : '')
);
this.egw.debug('error', 'Ajax request to', this.url, ' failed: ', _err, _xmlhttp.status, _xmlhttp.statusText, _xmlhttp.responseJSON);
// check of unparsable JSON on server-side, which might be caused by some network problem --> resend max. twice
if (_err === 'error' && _xmlhttp.status === 400 && typeof _xmlhttp.responseJSON === 'object' &&
_xmlhttp.responseJSON.errno && _xmlhttp.responseJSON.error.substr(0, 5) === 'JSON ')
{
// ToDo: resend request max. twice
}
}
};
json_request.prototype.handleResponse = function(data) {
if (data && typeof data.response != 'undefined')
{
if (egw.preference('show_generation_time', 'common', false) == "1")
{
var gen_time_div = jQuery('#divGenTime').length > 0 ? jQuery('#divGenTime')
:jQuery('<div id="divGenTime" class="pageGenTime"><span class="pageTime"></span></div>').appendTo('#egw_fw_footer');
}
// Load files first
var js_files = [];
for (var i = data.response.length - 1; i >= 0; --i)
{
var res = data.response[i];
if(res.type == 'js' && typeof res.data == 'string')
{
js_files.unshift(res.data);
data.response.splice(i,1);
}
}
if(js_files.length > 0)
{
var start_time = (new Date).getTime();
this.egw.includeJS(js_files, function() {
var end_time = (new Date).getTime();
this.handleResponse(data);
if (egw.preference('show_generation_time', 'common', false) == "1")
{
var gen_time_div = jQuery('#divGenTime');
if (!gen_time_div.length) gen_time_div = jQuery('.pageGenTime');
var gen_time_async = jQuery('.asyncIncludeTime').length > 0 ? jQuery('.asyncIncludeTime'):
gen_time_div.append('<span class="asyncIncludeTime"></span>').find('.asyncIncludeTime');
gen_time_async.text(egw.lang('async includes took %1s', (end_time-start_time)/1000));
}
}, this);
return;
}
// Flag for only data response - don't call callback if only data
var only_data = (data.response.length > 0);
for (var i = 0; i < data.response.length; i++)
{
// Get the response object
var res = data.response[i];
if(typeof res.type == 'string' && res.type != 'data') only_data = false;
// Check whether a plugin for the given type exists
var handlers = [plugins, global_plugins];
for(var handler_idx = 0; handler_idx < handlers.length; handler_idx++)
{
var handler_level = handlers[handler_idx];
if (typeof handler_level[res.type] !== 'undefined')
{
for (var j = 0; j < handler_level[res.type].length; j++) {
try {
// Get a reference to the plugin
var plugin = handler_level[res.type][j];
if (res.type.match(/et2_load/))
{
if (egw.preference('show_generation_time', 'common', false) == "1")
{
if (gen_time_div.length > 0)
{
gen_time_div.find('span.pageTime').text(egw.lang("Page was generated in %1 seconds ", data.page_generation_time));
if (data.session_restore_time)
{
var gen_time_session_span = gen_time_div.find('span.session').length > 0 ? gen_time_div.find('span.session'):
gen_time_div.append('<span class="session"></span>').find('.session');
gen_time_session_span.text(egw.lang("session restore time in %1 seconds ", data.page_generation_time));
}
}
}
}
// Call the plugin callback
plugin.callback.call(
plugin.context ? plugin.context : this.context,
res.type, res, this
);
} catch(e) {
var msg = e.message ? e.message : e + '';
var stack = e.stack ? "\n-- Stack trace --\n" + e.stack : "";
this.egw.debug('error', 'Exception "' + msg + '" while handling JSON response from ' +
this.url + ' [' + JSON.stringify(this.parameters) + '] type "' + res.type +
'", plugin', plugin, 'response', res, stack);
}
}
}
}
}
// Call request callback, if provided
if(this.callback != null && !only_data)
{
this.callback.call(this.context,res);
}
}
this.request = null;
};
var json = {
/** The constructor of the egw_json_request class.
*
* @param _menuaction the menuaction function which should be called and
* which handles the actual request. If the menuaction is a full featured
* url, this one will be used instead.
* @param _parameters which should be passed to the menuaction function.
* @param _async specifies whether the request should be asynchronous or
* not.
* @param _callback specifies the callback function which should be
* called, once the request has been sucessfully executed.
* @param _context is the context which will be used for the callback function
* @param _sender is a parameter being passed to the _callback function
*/
json: function(_menuaction, _parameters, _callback, _context, _async,
_sender)
{
return new json_request(_menuaction, _parameters, _callback,
_context, _async, _sender, this);
},
/**
* Do an AJAX call and get a javascript promise, which will be resolved with the returned data.
*
* egw.request() returns immediately with a Promise. The promise will be resolved with just the returned data,
* any other "piggybacked" responses will be handled by registered handlers. The data will also be passed to
* any registered data handlers (egw.data) before it is passed to your handler.
*
* To use:
* @example
* egw.request(
* "EGroupware\\Api\\Etemplate\\Widget\\Select::ajax_get_options",
* ["select-cat"]
* )
* .then(function(data) {
* // Deal with the returned data here. data may be undefined if no data was returned.
* console.log("Here's the categories:",data);
* });
*
*
* The return is a Promise, so multiple .then() can be chained in the usual ways:
* @example
* egw.request(...)
* .then(function(data) {
* if(debug) console.log("Requested data", data);
* }
* .then(function(data) {
* // Change the data for the rest of the chain
* if(typeof data === "undefined") return [];
* }
* .then(function(data) {
* // data is never undefined now, if it was before it's an empty array now
* for(let i = 0; i < data.length; i++)
* {
* ...
* }
* }
*
*
* You can also fire off multiple requests, and wait for them to all be answered:
* @example
* let first = egw.request(...);
* let second = egw.request(...);
* Promise.all([first, second])
* .then(function(values) {
* console.log("First:", values[0], "Second:", values[1]);
* }
*
*
* @param {string} _menuaction
* @param {any[]} _parameters
*
* @return Promise
*/
request: function(_menuaction, _parameters)
{
let request = new json_request(_menuaction, _parameters, null, this, true, this, this);
let ajax_promise = request.sendRequest();
// This happens first, immediately
let resolvePromise = function(resolve, reject) {
// Bind to ajax response - this is called _after_ any other handling
ajax_promise.always(function(response, status, p) {
if(status !== "success") reject();
// The ajax request has completed, get just the data & pass it on
if(response && response.response)
{
for(let value of response.response)
{
if(value.type && value.type === "data" && typeof value.data !== "undefined")
{
// Data was packed in response
resolve(value.data);
}
else if (value && typeof value.type === "undefined" && typeof value.data === "undefined")
{
// Just raw data
resolve(value);
}
}
}
// No data? Resolve the promise with nothing
resolve();
});
};
const myPromise = new Promise(resolvePromise);
return myPromise;
},
/**
* Registers a new handler plugin.
*
* @param _callback is the callback function which should be called
* whenever a response is comming from the server.
* @param _context is the context in which the callback function should
* be called. If null is given, the plugin is executed in the context
* of the request object context.
* @param _type is an optional parameter defaulting to 'global'.
* it describes the response type which this plugin should be
* handling.
* @param {boolean} [_global=false] Register the handler globally or
* locally. Global handlers must stay around, so should be used
* for global modules.
*/
registerJSONPlugin: function(_callback, _context, _type, _global)
{
// _type defaults to 'global'
if (typeof _type === 'undefined')
{
_type = 'global';
}
// _global defaults to false
if (typeof _global === 'undefined')
{
_global = false;
}
var scoped = _global ? global_plugins : plugins;
// Create an array for the given category inside the plugins object
if (typeof scoped[_type] === 'undefined')
{
scoped[_type] = [];
}
// Add the entry
scoped[_type].push({
'callback': _callback,
'context': _context
});
},
/**
* Removes a previously registered plugin.
*
* @param _callback is the callback function which should be called
* whenever a response is comming from the server.
* @param _context is the context in which the callback function should
* be called.
* @param _type is an optional parameter defaulting to 'global'.
* it describes the response type which this plugin should be
* handling.
* @param {boolean} [_global=false] Remove a global or local handler.
*/
unregisterJSONPlugin: function(_callback, _context, _type, _global)
{
// _type defaults to 'global'
if (typeof _type === 'undefined')
{
_type = 'global';
}
// _global defaults to false
if (typeof _global === 'undefined')
{
_global = false;
}
var scoped = _global ? global_plugins : plugins;
if (typeof scoped[_type] !== 'undefined') {
for (var i = 0; i < scoped[_type].length; i++)
{
if (scoped[_type][i].callback == _callback &&
scoped[_type][i].context == _context)
{
scoped[_type].slice(i, 1);
break;
}
}
}
}
};
// Regisert the "alert" plugin
json.registerJSONPlugin(function(type, res, req) {
//Check whether all needed parameters have been passed and call the alertHandler function
if ((typeof res.data.message != 'undefined') &&
(typeof res.data.details != 'undefined'))
{
req.alertHandler(
res.data.message,
res.data.details);
return true;
}
throw 'Invalid parameters';
}, null, 'alert');
// Regisert the "message" plugin
json.registerJSONPlugin(function(type, res, req) {
//Check whether all needed parameters have been passed and call the alertHandler function
if ((typeof res.data.message != 'undefined'))
{
req.egw.message(res.data.message, res.data.type);
return true;
}
throw 'Invalid parameters';
}, null, 'message');
// Register the "assign" plugin
json.registerJSONPlugin(function(type, res, req) {
//Check whether all needed parameters have been passed and call the alertHandler function
if ((typeof res.data.id != 'undefined') &&
(typeof res.data.key != 'undefined') &&
(typeof res.data.value != 'undefined'))
{
var obj = _wnd.document.getElementById(res.data.id);
if (obj)
{
obj[res.data.key] = res.data.value;
if (res.data.key == "innerHTML")
{
egw_insertJS(res.data.value);
}
return true;
}
return false;
}
throw 'Invalid parameters';
}, null, 'assign');
// Register the "data" plugin
json.registerJSONPlugin(function(type, res, req) {
//Callback the caller in order to allow him to handle the data
if (req.callback)
{
req.callback.call(req.sender, res.data);
return true;
}
}, null, 'data');
// Register the "script" plugin
json.registerJSONPlugin(function(type, res, req) {
if (typeof res.data == 'string')
{
try
{
var func = new Function(res.data);
func.call(req.egw ? req.egw.window : window);
}
catch (e)
{
req.egw.debug('error', 'Error while executing script: ',
res.data,e);
}
return true;
}
throw 'Invalid parameters';
}, null, 'script');
// Register the "apply" plugin
json.registerJSONPlugin(function(type, res, req) {
if (typeof res.data.func == 'string')
{
var parts = res.data.func.split('.');
var func = parts.pop();
var parent = req.egw.window;
for(var i=0; i < parts.length; ++i)
{
if (typeof parent[parts[i]] != 'undefined')
{
parent = parent[parts[i]];
}
// check if we need a not yet instanciated app.js object --> instanciate it now
else if (i == 1 && parts[0] == 'app' && typeof req.egw.window.app.classes[parts[1]] == 'function')
{
parent = parent[parts[1]] = new req.egw.window.app.classes[parts[1]]();
}
}
if (typeof parent[func] == 'function')
{
try
{
parent[func].apply(parent, res.data.parms);
}
catch (e)
{
req.egw.debug('error', e.message, ' in function', res.data.func,
'Parameters', res.data.parms);
}
return true;
}
else
{
throw '"' + res.data.func + '" is not a callable function (type is ' + typeof parent[func] + ')';
}
}
throw 'Invalid parameters';
}, null, 'apply');
// Register the "jquery" plugin
json.registerJSONPlugin(function(type, res, req) {
if (typeof res.data.select == 'string' &&
typeof res.data.func == 'string')
{
try
{
var jQueryObject = jQuery(res.data.select, req.context);
jQueryObject[res.data.func].apply(jQueryObject, res.data.parms);
}
catch (e)
{
req.egw.debug('error', 'Function', res.data.func,
'Parameters', res.data.parms);
}
return true;
}
throw 'Invalid parameters';
}, _wnd, 'jquery');
// Register the "redirect" plugin
json.registerJSONPlugin(function(type, res, req) {
//console.log(res.data.url);
if (typeof res.data.url == 'string' &&
typeof res.data.global == 'boolean')
{
//Special handling for framework reload
res.data.global |= (res.data.url.indexOf("?cd=10") > 0);
if (res.data.global)
{
egw_topWindow().location.href = res.data.url;
}
// json request was originating from a different popup --> redirect that one
else if(this && this.DOMContainer && this.DOMContainer.ownerDocument.defaultView != window &&
egw(this.DOMContainer.ownerDocument.defaultView).is_popup())
{
this.DOMContainer.ownerDocument.location.href = res.data.url;
}
// main window, open url in respective tab
else
{
egw_appWindowOpen(res.data.app, res.data.url);
}
return true;
}
throw 'Invalid parameters';
}, null, 'redirect');
// Register the 'css' plugin
json.registerJSONPlugin(function(type, res, req) {
if (typeof res.data == 'string')
{
req.egw.includeCSS(res.data);
return true;
}
throw 'Invalid parameters';
}, null, 'css');
// Register the 'js' plugin
json.registerJSONPlugin(function(type, res, req) {
if (typeof res.data == 'string')
{
req.jsCount++;
req.egw.includeJS(res.data, function() {
req.jsFiles++;
if (req.jsFiles == req.jsCount && req.onLoadFinish)
{
req.onLoadFinish.call(req.sender);
}
});
return true;
}
throw 'Invalid parameters';
}, null, 'js');
// Register the 'html' plugin, replacing document content with send html
json.registerJSONPlugin(function(type, res, req) {
if (typeof res.data == 'string')
{
// Empty the document tree
while (_wnd.document.childNodes.length > 0)
{
_wnd.document.removeChild(_wnd.document.childNodes[0]);
}
// Write the given content
_wnd.document.write(res.data);
// Close the document
_wnd.document.close();
return true;
}
throw 'Invalid parameters';
}, null, 'html');
// Return the extension
return json;
});