Permalink
Browse files

feature(ajax): improves the elgg/Ajax API and adds docs

Modifies the response hooks API so that handlers receive a wrapper object
rather than the simple return value. This allows handlers to more reliably
attach metadata to the response without the need to "wrap" the value.

Requests can specify that system messages be left in the queue. This is
sometimes desirable if the client code intends to redirect/reload a page.

Prefixes with `elgg_` the server-side options.

The client-side response hook no longer fires twice.

Moves the system message delivery to hooks.

Documents using hooks to filter requests and responses.

Fixes #9404
  • Loading branch information...
mrclay committed Feb 21, 2016
1 parent fbefa62 commit 4211155eb223fdd3bc67534377757453ba2de398
View
@@ -30,7 +30,8 @@ More notes:
* Elgg gives you a default error handler that shows a generic message if output fails.
* PHP exceptions or denied resource return HTTP error codes, resulting in use of the client-side error handler.
* The default HTTP method is ``POST`` for actions, otherwise ``GET``. You can set it via ``options.method``.
* For client caching, set ``options.method`` to ``"GET"`` and ``options.data.response_ttl`` to the max-age you want in seconds.
* For client caching, set ``options.method`` to ``"GET"`` and ``options.data.elgg_response_ttl`` to the max-age you want in seconds.
* To save system messages for the next page load, set ``options.data.elgg_fetch_messages = 0``. You may want to do this if you intent to redirect the user based on the response.
Performing actions
------------------
@@ -214,6 +215,75 @@ Notes for forms:
``get_input()``, but may not be the type you're expecting or may have unexpected keys.
Piggybacking on an Ajax request
-------------------------------
The client-side ``ajax_request_data`` hook can be used to append or filter data being sent by an ``elgg/Ajax`` request.
Let's say when the view ``foo`` is fetched, we want to also send the server some data:
.. code-block:: js
// in your boot module
var Ajax = require('elgg/Ajax');
var elgg = require('elgg');
elgg.register_hook_handler(Ajax.REQUEST_DATA_HOOK, 'view:foo', function (name, type, params, data) {
// send some data back
data.bar = 1;
return data;
});
This data can be read server-side via ``get_input('bar');``.
Piggybacking on an Ajax response
--------------------------------
The server-side ``ajax_response`` hook can be used to append or filter response data (or metadata).
Let's say when the view ``foo`` is fetched, we want to also send the client some additional data:
.. code-block:: php
use Elgg\Services\AjaxResponse;
function myplugin_append_ajax($hook, $type, AjaxResponse $response, $params) {
// alter the value being returned
$response->getData()->value .= " hello";
// send some metadata back. Only client-side "ajax_response" hooks can see this!
$response->getData()->myplugin_alert = 'Listen to me!';
return $response;
}
// in myplugin_init()
elgg_register_plugin_hook_handler(AjaxResponse::RESPONSE_HOOK, 'view:foo', 'myplugin_append_ajax');
To capture the metadata send back to the client, we use the client-side ``ajax_response`` hook:
.. code-block:: js
// in your boot module
var Ajax = require('elgg/Ajax');
var elgg = require('elgg');
elgg.register_hook_handler(Ajax.RESPONSE_DATA_HOOK, 'view:foo', function (name, type, params, data) {
// the return value is data.value
// the rest is metadata
alert(data.myplugin_alert);
return data;
});
.. note:: Only ``data.value`` is returned to the ``success`` function or available via the `Deferred` interface.
.. note:: Elgg uses these same hooks to deliver system messages over ``elgg/Ajax`` responses.
Legacy elgg.ajax APIs
=====================
@@ -32,7 +32,10 @@ public function getTtl() {
/**
* {@inheritdoc}
*/
public function setData($data) {
public function setData(\stdClass $data) {
if (!property_exists($data, 'value')) {
throw new \InvalidArgumentException('$data must have a property "value"');
}
$this->data = $data;
return $this;
}
@@ -47,6 +47,11 @@ public function __construct(PluginHooksService $hooks, SystemMessagesService $ms
$this->hooks = $hooks;
$this->msgs = $msgs;
$this->input = $input;
if ($this->input->get('elgg_fetch_messages', true)) {
$message_filter = [$this, 'appendMessages'];
$this->hooks->registerHandler(AjaxResponse::RESPONSE_HOOK, 'all', $message_filter, 999);
}
}
/**
@@ -100,7 +105,9 @@ public function respondFromOutput($output, $hook_type = '', $try_decode = true)
}
$api_response = new Response();
$api_response->setData($output);
$api_response->setData((object)[
'value' => $output,
]);
$api_response = $this->filterApiResponse($api_response, $hook_type);
$response = $this->buildHttpResponse($api_response);
@@ -146,7 +153,7 @@ public function respondWithError($msg, $status = 400) {
* @return AjaxResponse
*/
private function filterApiResponse(AjaxResponse $api_response, $hook_type = '') {
$api_response->setTtl($this->input->get('response_ttl', 0, false));
$api_response->setTtl($this->input->get('elgg_response_ttl', 0, false));
if ($hook_type) {
$hook = AjaxResponse::RESPONSE_HOOK;
@@ -173,10 +180,7 @@ private function buildHttpResponse(AjaxResponse $api_response, $allow_removing_h
return new JsonResponse(['error' => "The response was cancelled"], 400);
}
$response = new JsonResponse([
'msgs' => (object)$this->msgs->dumpRegister(),
'data' => $api_response->getData(),
]);
$response = new JsonResponse($api_response->getData());
$ttl = $api_response->getTtl();
if ($ttl > 0) {
@@ -198,4 +202,21 @@ private function buildHttpResponse(AjaxResponse $api_response, $allow_removing_h
return $response;
}
/**
* Send system messages back with the response
*
* @param string $hook "ajax_response"
* @param string $type "all"
* @param AjaxResponse $response Ajax response
* @param array $params Hook params
*
* @return AjaxResponse
* @access private
* @internal
*/
public function appendMessages($hook, $type, AjaxResponse $response, $params) {
$response->getData()->_elgg_msgs = (object)$this->msgs->dumpRegister();
return $response;
}
}
@@ -28,15 +28,15 @@ public function getTtl();
/**
* Set the response data
*
* @param mixed $data Response data. Must be able to be encoded in JSON.
* @param \stdClass $data Response data. Must be able to be encoded in JSON.
* @return self
*/
public function setData($data);
public function setData(\stdClass $data);
/**
* Get the response data
* Get the response data, which will be a stdClass object with property "value"
*
* @return mixed
* @return \stdClass
*/
public function getData();
@@ -18,18 +18,31 @@ define(function(require) {
Ajax.REQUEST_DATA_HOOK,
'action:developers/ajax_demo',
function (name, type, params, value) {
// alter the data object sent to server
value.client_request_altered = 1;
return value;
}
);
var got_metadata_from_server = false,
num_hook_calls = 0;
// alter request data response for the action
elgg.register_hook_handler(
Ajax.RESPONSE_DATA_HOOK,
'action:developers/ajax_demo',
function (name, type, params, value) {
value.client_response_altered = 3;
return value;
function (name, type, params, data) {
// check the data wrapper for our expected metadata
if (data.server_response_altered) {
got_metadata_from_server = true;
}
// alter the return value
data.value.altered_value = true;
num_hook_calls++;
return data;
}
);
@@ -55,14 +68,18 @@ define(function(require) {
log("form() successful!");
return ajax.action('developers/ajax_demo', {
data: {arg1: 2, arg2: 3}
data: {arg1: 2, arg2: 3},
success: function (obj) {
// we should not get two sets of system messages
}
});
}
})
.then(function (obj) {
if (obj.sum === 5
&& obj.server_response_altered == 2
&& obj.client_response_altered == 3) {
&& got_metadata_from_server
&& obj.altered_value
&& num_hook_calls == 1) {
log("action() successful!");
alert('Success!');
}
View
@@ -19,50 +19,57 @@ define(function (require) {
*
* Note that this function does not support the array form of "success".
*
* To request the response be cached, set options.method to "GET" and options.data.response_ttl
* To request the response be cached, set options.method to "GET" and options.data.elgg_response_ttl
* to a number of seconds.
*
* To bypass downloading system messages with the response, set options.data.elgg_fetch_messages = 0.
*
* @param {Object} options See {@link jQuery#ajax}. The default method is "GET" (or "POST" for actions).
*
* url : {String} Path of the Ajax API endpoint (required)
* error : {Function} Error handler. Default is elgg.ajax.handleAjaxError. To cancel this altogether,
* pass in function(){}.
* data : {Object} Data to send to the server (optional). Unlike jQuery, you cannot set this
* to a string.
*
* @param {String} hook_type Type of the plugin hooks. If missing, the hooks will not trigger.
*
* @returns {jqXHR}
*/
function fetch(options, hook_type) {
var orig_options,
msgs_were_set = 0,
params;
params,
unwrapped = false,
result;
function unwrap_data(data) {
var params;
if (!msgs_were_set) {
data.msgs.error && elgg.register_error(data.msgs.error);
data.msgs.success && elgg.system_message(data.msgs.success);
msgs_were_set = 1;
}
params = {
options: orig_options
};
if (hook_type) {
return elgg.trigger_hook(Ajax.RESPONSE_DATA_HOOK, hook_type, params, data.data);
// between the deferred and a success function, make sure this runs only once.
if (!unwrapped) {
var params = {
options: orig_options
};
if (hook_type) {
data = elgg.trigger_hook(Ajax.RESPONSE_DATA_HOOK, hook_type, params, data);
}
result = data.value;
unwrapped = true;
}
return data.data;
return result;
}
hook_type = hook_type || '';
if (!$.isPlainObject(options) || !options.url) {
throw new Error('options must be a plain with key "url"');
throw new Error('options must be a plain object with key "url"');
}
// ease hook filtering by making these keys always available
options.data = options.data || {};
if (options.data === undefined || $.isPlainObject(options.data)) {
options.data = options.data || {};
} else {
throw new Error('if defined, options.data must be a plain object');
}
options.dataType = 'json';
if (!options.method) {
options.method = 'GET';
@@ -194,12 +201,21 @@ define(function (require) {
Ajax.REQUEST_DATA_HOOK = 'ajax_request_data';
/**
* The returned data will be passed through this hook, with the endpoint name as hook type and
* params.option will have a copy of the original options object.
* The returned data object will be passed through this hook, with the endpoint name as hook type
* and params.option will have a copy of the original options object.
*
* Note this hook will be triggered twice if you provide an options.success function.
* data.value will be returned to the caller.
*/
Ajax.RESPONSE_DATA_HOOK = 'ajax_response_data';
// handle system messages
elgg.register_hook_handler(Ajax.RESPONSE_DATA_HOOK, 'all', function (name, type, params, data) {
var m = data._elgg_msgs;
m && m.error && elgg.register_error(m.error);
m && m.success && elgg.system_message(m.success);
delete data._elgg_msgs;
return data;
});
return Ajax;
});

0 comments on commit 4211155

Please sign in to comment.