2013-01-22 19:32:26 +01:00
|
|
|
/**
|
|
|
|
* @requires javelin-install
|
|
|
|
* javelin-stratcom
|
|
|
|
* javelin-util
|
|
|
|
* javelin-behavior
|
|
|
|
* javelin-json
|
|
|
|
* javelin-dom
|
|
|
|
* javelin-resource
|
Provide a global router for Ajax requests
Summary:
Fixes T430. Fixes T4834. Obsoletes D7641. Currently, we do some things less-well than we could:
- We just let the browser queue and prioritize requests, so if you load a revision with 50 changes and then click "Award Token", the action blocks until the changes load in most/all browsers. It would be better to prioritize this action and queue it immediately.
- Similarly, changes tend to load in order, even if the user has clicked to a specific file. When the user expresses a preference for a specific file, we should prioritize it.
- We show a spinning GIF when waiting on requests. This is appropriate for some types of reuqests, but distracting for others.
To fix this:
- Queue all (or, at least, most) requests into a new queue in JX.Router.
- JX.Router handles prioritizing the requests. Principally:
- You can submit a request with a specific priority (500 = general content loading, 1000 = default, 2000 = explicit user action) and JX.Router will get the higher stuff fired off sooner.
- You can name requests and then adjust their prorities later, if the user expresses an interest in specific results.
- Only use the spinner gif for "workflow" requests, which is bascially when the user clicked something and we're waiting on the server. I think it's useful and not-annoying in this case.
- Don't show any status for draft requests.
- For content requests, show a subtle hipster-style top loading bar.
Test Plan:
- Viewed a diff with 93 changes, and clicked award token.
- Prior to this patch, the action took many many seconds to resolve.
- After this patch, it resolves quickly.
- Viewed a diff with 93 changes and saw a pleasant subtle hipster-style loading bar.
- Viewed a diff with 93 changes and typed some draft text. Previews populated fairly quickly and there was no spinner.
- Viewed a diff with 93 changes and clicked something with workflow, saw a spinner after a moment.
- Viewed a diff with 93 changes and clicked a file in the table of contents near the end of the list.
- Prior to this patch, it took a long time to show up.
- After this patch, it loads directly.
Reviewers: chad, btrahan
Reviewed By: btrahan
Subscribers: epriestley
Maniphest Tasks: T430, T4834
Differential Revision: https://secure.phabricator.com/D8979
2014-05-05 19:57:42 +02:00
|
|
|
* javelin-routable
|
2013-01-22 19:32:26 +01:00
|
|
|
* @provides javelin-request
|
|
|
|
* @javelin
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Make basic AJAX XMLHTTPRequests.
|
|
|
|
*/
|
|
|
|
JX.install('Request', {
|
|
|
|
construct : function(uri, handler) {
|
|
|
|
this.setURI(uri);
|
|
|
|
if (handler) {
|
|
|
|
this.listen('done', handler);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
events : ['start', 'open', 'send', 'statechange', 'done', 'error', 'finally',
|
|
|
|
'uploadprogress'],
|
|
|
|
|
|
|
|
members : {
|
|
|
|
|
|
|
|
_xhrkey : null,
|
|
|
|
_transport : null,
|
|
|
|
_sent : false,
|
|
|
|
_finished : false,
|
|
|
|
_block : null,
|
|
|
|
_data : null,
|
|
|
|
|
|
|
|
_getSameOriginTransport : function() {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
return new XMLHttpRequest();
|
|
|
|
} catch (x) {
|
2015-01-13 21:59:56 +01:00
|
|
|
return new ActiveXObject('Msxml2.XMLHTTP');
|
2013-01-22 19:32:26 +01:00
|
|
|
}
|
|
|
|
} catch (x) {
|
2015-01-13 21:59:56 +01:00
|
|
|
return new ActiveXObject('Microsoft.XMLHTTP');
|
2013-01-22 19:32:26 +01:00
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
_getCORSTransport : function() {
|
|
|
|
try {
|
|
|
|
var xport = new XMLHttpRequest();
|
|
|
|
if ('withCredentials' in xport) {
|
|
|
|
// XHR supports CORS
|
|
|
|
} else if (typeof XDomainRequest != 'undefined') {
|
|
|
|
xport = new XDomainRequest();
|
|
|
|
}
|
|
|
|
return xport;
|
|
|
|
} catch (x) {
|
|
|
|
return new XDomainRequest();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
getTransport : function() {
|
|
|
|
if (!this._transport) {
|
|
|
|
this._transport = this.getCORS() ? this._getCORSTransport() :
|
|
|
|
this._getSameOriginTransport();
|
|
|
|
}
|
|
|
|
return this._transport;
|
|
|
|
},
|
|
|
|
|
Provide a global router for Ajax requests
Summary:
Fixes T430. Fixes T4834. Obsoletes D7641. Currently, we do some things less-well than we could:
- We just let the browser queue and prioritize requests, so if you load a revision with 50 changes and then click "Award Token", the action blocks until the changes load in most/all browsers. It would be better to prioritize this action and queue it immediately.
- Similarly, changes tend to load in order, even if the user has clicked to a specific file. When the user expresses a preference for a specific file, we should prioritize it.
- We show a spinning GIF when waiting on requests. This is appropriate for some types of reuqests, but distracting for others.
To fix this:
- Queue all (or, at least, most) requests into a new queue in JX.Router.
- JX.Router handles prioritizing the requests. Principally:
- You can submit a request with a specific priority (500 = general content loading, 1000 = default, 2000 = explicit user action) and JX.Router will get the higher stuff fired off sooner.
- You can name requests and then adjust their prorities later, if the user expresses an interest in specific results.
- Only use the spinner gif for "workflow" requests, which is bascially when the user clicked something and we're waiting on the server. I think it's useful and not-annoying in this case.
- Don't show any status for draft requests.
- For content requests, show a subtle hipster-style top loading bar.
Test Plan:
- Viewed a diff with 93 changes, and clicked award token.
- Prior to this patch, the action took many many seconds to resolve.
- After this patch, it resolves quickly.
- Viewed a diff with 93 changes and saw a pleasant subtle hipster-style loading bar.
- Viewed a diff with 93 changes and typed some draft text. Previews populated fairly quickly and there was no spinner.
- Viewed a diff with 93 changes and clicked something with workflow, saw a spinner after a moment.
- Viewed a diff with 93 changes and clicked a file in the table of contents near the end of the list.
- Prior to this patch, it took a long time to show up.
- After this patch, it loads directly.
Reviewers: chad, btrahan
Reviewed By: btrahan
Subscribers: epriestley
Maniphest Tasks: T430, T4834
Differential Revision: https://secure.phabricator.com/D8979
2014-05-05 19:57:42 +02:00
|
|
|
getRoutable: function() {
|
|
|
|
var routable = new JX.Routable();
|
|
|
|
routable.listen('start', JX.bind(this, function() {
|
|
|
|
// Pass the event to allow other listeners to "start" to configure this
|
|
|
|
// request before it fires.
|
|
|
|
JX.Stratcom.pass(JX.Stratcom.context());
|
|
|
|
this.send();
|
|
|
|
}));
|
|
|
|
this.listen('finally', JX.bind(routable, routable.done));
|
|
|
|
return routable;
|
|
|
|
},
|
|
|
|
|
2013-01-22 19:32:26 +01:00
|
|
|
send : function() {
|
|
|
|
if (this._sent || this._finished) {
|
|
|
|
if (__DEV__) {
|
|
|
|
if (this._sent) {
|
|
|
|
JX.$E(
|
|
|
|
'JX.Request.send(): ' +
|
|
|
|
'attempting to send a Request that has already been sent.');
|
|
|
|
}
|
|
|
|
if (this._finished) {
|
|
|
|
JX.$E(
|
|
|
|
'JX.Request.send(): ' +
|
|
|
|
'attempting to send a Request that has finished or aborted.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fire the "start" event before doing anything. A listener may
|
|
|
|
// perform pre-processing or validation on this request
|
|
|
|
this.invoke('start', this);
|
|
|
|
if (this._finished) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
var xport = this.getTransport();
|
|
|
|
xport.onreadystatechange = JX.bind(this, this._onreadystatechange);
|
|
|
|
if (xport.upload) {
|
|
|
|
xport.upload.onprogress = JX.bind(this, this._onuploadprogress);
|
|
|
|
}
|
|
|
|
|
|
|
|
var method = this.getMethod().toUpperCase();
|
|
|
|
|
|
|
|
if (__DEV__) {
|
|
|
|
if (this.getRawData()) {
|
|
|
|
if (method != 'POST') {
|
|
|
|
JX.$E(
|
|
|
|
'JX.Request.send(): ' +
|
|
|
|
'attempting to send post data over GET. You must use POST.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var list_of_pairs = this._data || [];
|
|
|
|
list_of_pairs.push(['__ajax__', true]);
|
|
|
|
|
|
|
|
this._block = JX.Stratcom.allocateMetadataBlock();
|
|
|
|
list_of_pairs.push(['__metablock__', this._block]);
|
|
|
|
|
|
|
|
var q = (this.getDataSerializer() ||
|
|
|
|
JX.Request.defaultDataSerializer)(list_of_pairs);
|
|
|
|
var uri = this.getURI();
|
|
|
|
|
|
|
|
// If we're sending a file, submit the metadata via the URI instead of
|
|
|
|
// via the request body, because the entire post body will be consumed by
|
|
|
|
// the file content.
|
|
|
|
if (method == 'GET' || this.getRawData()) {
|
|
|
|
uri += ((uri.indexOf('?') === -1) ? '?' : '&') + q;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.getTimeout()) {
|
|
|
|
this._timer = setTimeout(
|
|
|
|
JX.bind(
|
|
|
|
this,
|
|
|
|
this._fail,
|
|
|
|
JX.Request.ERROR_TIMEOUT),
|
|
|
|
this.getTimeout());
|
|
|
|
}
|
|
|
|
|
|
|
|
xport.open(method, uri, true);
|
|
|
|
|
|
|
|
// Must happen after xport.open so that listeners can modify the transport
|
|
|
|
// Some transport properties can only be set after the transport is open
|
|
|
|
this.invoke('open', this);
|
|
|
|
if (this._finished) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.invoke('send', this);
|
|
|
|
if (this._finished) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (method == 'POST') {
|
|
|
|
if (this.getRawData()) {
|
|
|
|
xport.send(this.getRawData());
|
|
|
|
} else {
|
|
|
|
xport.setRequestHeader(
|
|
|
|
'Content-Type',
|
|
|
|
'application/x-www-form-urlencoded');
|
|
|
|
xport.send(q);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
xport.send(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
this._sent = true;
|
|
|
|
},
|
|
|
|
|
|
|
|
abort : function() {
|
|
|
|
this._cleanup();
|
|
|
|
},
|
|
|
|
|
|
|
|
_onuploadprogress : function(progress) {
|
|
|
|
this.invoke('uploadprogress', progress);
|
|
|
|
},
|
|
|
|
|
|
|
|
_onreadystatechange : function() {
|
|
|
|
var xport = this.getTransport();
|
|
|
|
var response;
|
|
|
|
try {
|
|
|
|
this.invoke('statechange', this);
|
|
|
|
if (this._finished) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (xport.readyState != 4) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// XHR requests to 'file:///' domains return 0 for success, which is why
|
|
|
|
// we treat it as a good result in addition to HTTP 2XX responses.
|
|
|
|
if (xport.status !== 0 && (xport.status < 200 || xport.status >= 300)) {
|
|
|
|
this._fail();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (__DEV__) {
|
|
|
|
var expect_guard = this.getExpectCSRFGuard();
|
|
|
|
|
|
|
|
if (!xport.responseText.length) {
|
|
|
|
JX.$E(
|
|
|
|
'JX.Request("'+this.getURI()+'", ...): '+
|
|
|
|
'server returned an empty response.');
|
|
|
|
}
|
2013-05-19 02:04:22 +02:00
|
|
|
if (expect_guard && xport.responseText.indexOf('for (;;);') !== 0) {
|
2013-01-22 19:32:26 +01:00
|
|
|
JX.$E(
|
|
|
|
'JX.Request("'+this.getURI()+'", ...): '+
|
|
|
|
'server returned an invalid response.');
|
|
|
|
}
|
|
|
|
if (expect_guard && xport.responseText == 'for (;;);') {
|
|
|
|
JX.$E(
|
|
|
|
'JX.Request("'+this.getURI()+'", ...): '+
|
|
|
|
'server returned an empty response.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
response = this._extractResponse(xport);
|
|
|
|
if (!response) {
|
|
|
|
JX.$E(
|
|
|
|
'JX.Request("'+this.getURI()+'", ...): '+
|
|
|
|
'server returned an invalid response.');
|
|
|
|
}
|
|
|
|
} catch (exception) {
|
|
|
|
|
|
|
|
if (__DEV__) {
|
|
|
|
JX.log(
|
|
|
|
'JX.Request("'+this.getURI()+'", ...): '+
|
|
|
|
'caught exception processing response: '+exception);
|
|
|
|
}
|
|
|
|
this._fail();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
this._handleResponse(response);
|
|
|
|
this._cleanup();
|
|
|
|
} catch (exception) {
|
|
|
|
// In Firefox+Firebug, at least, something eats these. :/
|
|
|
|
setTimeout(function() {
|
|
|
|
throw exception;
|
|
|
|
}, 0);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
_extractResponse : function(xport) {
|
|
|
|
var text = xport.responseText;
|
|
|
|
|
|
|
|
if (this.getExpectCSRFGuard()) {
|
|
|
|
text = text.substring('for (;;);'.length);
|
|
|
|
}
|
|
|
|
|
|
|
|
var type = this.getResponseType().toUpperCase();
|
|
|
|
if (type == 'TEXT') {
|
|
|
|
return text;
|
|
|
|
} else if (type == 'JSON' || type == 'JAVELIN') {
|
|
|
|
return JX.JSON.parse(text);
|
|
|
|
} else if (type == 'XML') {
|
|
|
|
var doc;
|
|
|
|
try {
|
|
|
|
if (typeof DOMParser != 'undefined') {
|
|
|
|
var parser = new DOMParser();
|
2015-01-13 21:59:56 +01:00
|
|
|
doc = parser.parseFromString(text, 'text/xml');
|
2013-01-22 19:32:26 +01:00
|
|
|
} else { // IE
|
|
|
|
// an XDomainRequest
|
2015-01-13 21:59:56 +01:00
|
|
|
doc = new ActiveXObject('Microsoft.XMLDOM');
|
2013-01-22 19:32:26 +01:00
|
|
|
doc.async = false;
|
|
|
|
doc.loadXML(xport.responseText);
|
|
|
|
}
|
|
|
|
|
|
|
|
return doc.documentElement;
|
|
|
|
} catch (exception) {
|
|
|
|
if (__DEV__) {
|
|
|
|
JX.log(
|
|
|
|
'JX.Request("'+this.getURI()+'", ...): '+
|
|
|
|
'caught exception extracting response: '+exception);
|
|
|
|
}
|
|
|
|
this._fail();
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (__DEV__) {
|
|
|
|
JX.$E(
|
|
|
|
'JX.Request("'+this.getURI()+'", ...): '+
|
|
|
|
'unrecognized response type.');
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
},
|
|
|
|
|
|
|
|
_fail : function(error) {
|
|
|
|
this._cleanup();
|
|
|
|
|
|
|
|
this.invoke('error', error, this);
|
|
|
|
this.invoke('finally');
|
|
|
|
},
|
|
|
|
|
|
|
|
_done : function(response) {
|
|
|
|
this._cleanup();
|
|
|
|
|
|
|
|
if (response.onload) {
|
|
|
|
for (var ii = 0; ii < response.onload.length; ii++) {
|
|
|
|
(new Function(response.onload[ii]))();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var payload;
|
|
|
|
if (this.getRaw()) {
|
|
|
|
payload = response;
|
|
|
|
} else {
|
|
|
|
payload = response.payload;
|
|
|
|
JX.Request._parseResponsePayload(payload);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.invoke('done', payload, this);
|
|
|
|
this.invoke('finally');
|
|
|
|
},
|
|
|
|
|
|
|
|
_cleanup : function() {
|
|
|
|
this._finished = true;
|
|
|
|
clearTimeout(this._timer);
|
|
|
|
this._timer = null;
|
|
|
|
|
|
|
|
// Should not abort the transport request if it has already completed
|
|
|
|
// Otherwise, we may see an "HTTP request aborted" error in the console
|
|
|
|
// despite it possibly having succeeded.
|
|
|
|
if (this._transport && this._transport.readyState != 4) {
|
|
|
|
this._transport.abort();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
setData : function(dictionary) {
|
|
|
|
this._data = null;
|
|
|
|
this.addData(dictionary);
|
|
|
|
return this;
|
|
|
|
},
|
|
|
|
|
|
|
|
addData : function(dictionary) {
|
|
|
|
if (!this._data) {
|
|
|
|
this._data = [];
|
|
|
|
}
|
|
|
|
for (var k in dictionary) {
|
|
|
|
this._data.push([k, dictionary[k]]);
|
|
|
|
}
|
|
|
|
return this;
|
|
|
|
},
|
|
|
|
|
|
|
|
setDataWithListOfPairs : function(list_of_pairs) {
|
|
|
|
this._data = list_of_pairs;
|
|
|
|
return this;
|
|
|
|
},
|
|
|
|
|
|
|
|
_handleResponse : function(response) {
|
|
|
|
if (this.getResponseType().toUpperCase() == 'JAVELIN') {
|
|
|
|
if (response.error) {
|
|
|
|
this._fail(response.error);
|
|
|
|
} else {
|
|
|
|
JX.Stratcom.mergeData(
|
|
|
|
this._block,
|
|
|
|
response.javelin_metadata || {});
|
|
|
|
|
|
|
|
var when_complete = JX.bind(this, function() {
|
|
|
|
this._done(response);
|
|
|
|
JX.initBehaviors(response.javelin_behaviors || {});
|
|
|
|
});
|
|
|
|
|
|
|
|
if (response.javelin_resources) {
|
|
|
|
JX.Resource.load(response.javelin_resources, when_complete);
|
|
|
|
} else {
|
|
|
|
when_complete();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
this._cleanup();
|
|
|
|
this.invoke('done', response, this);
|
|
|
|
this.invoke('finally');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
statics : {
|
|
|
|
ERROR_TIMEOUT : -9000,
|
|
|
|
defaultDataSerializer : function(list_of_pairs) {
|
|
|
|
var uri = [];
|
|
|
|
for (var ii = 0; ii < list_of_pairs.length; ii++) {
|
|
|
|
var pair = list_of_pairs[ii];
|
2019-04-25 20:57:55 +02:00
|
|
|
|
|
|
|
if (pair[1] === null) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2013-01-22 19:32:26 +01:00
|
|
|
var name = encodeURIComponent(pair[0]);
|
|
|
|
var value = encodeURIComponent(pair[1]);
|
|
|
|
uri.push(name + '=' + value);
|
|
|
|
}
|
|
|
|
return uri.join('&');
|
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* When we receive a JSON blob, parse it to introduce meaningful objects
|
|
|
|
* where there are magic keys for placeholders.
|
|
|
|
*
|
|
|
|
* Objects with the magic key '__html' are translated into JX.HTML objects.
|
|
|
|
*
|
|
|
|
* This function destructively modifies its input.
|
|
|
|
*/
|
|
|
|
_parseResponsePayload: function(parent, index) {
|
|
|
|
var recurse = JX.Request._parseResponsePayload;
|
|
|
|
var obj = (typeof index !== 'undefined') ? parent[index] : parent;
|
|
|
|
if (JX.isArray(obj)) {
|
|
|
|
for (var ii = 0; ii < obj.length; ii++) {
|
|
|
|
recurse(obj, ii);
|
|
|
|
}
|
|
|
|
} else if (obj && typeof obj == 'object') {
|
2013-05-19 02:04:22 +02:00
|
|
|
if (('__html' in obj) && (obj.__html !== null)) {
|
2013-01-22 19:32:26 +01:00
|
|
|
parent[index] = JX.$H(obj.__html);
|
|
|
|
} else {
|
|
|
|
for (var key in obj) {
|
|
|
|
recurse(obj, key);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
properties : {
|
|
|
|
URI : null,
|
|
|
|
dataSerializer : null,
|
|
|
|
/**
|
|
|
|
* Configure which HTTP method to use for the request. Permissible values
|
|
|
|
* are "POST" (default) or "GET".
|
|
|
|
*
|
|
|
|
* @param string HTTP method, one of "POST" or "GET".
|
|
|
|
*/
|
|
|
|
method : 'POST',
|
|
|
|
/**
|
|
|
|
* Set the data parameter of transport.send. Useful if you want to send a
|
|
|
|
* file or FormData. Not that you cannot send raw data and data at the same
|
|
|
|
* time.
|
|
|
|
*
|
|
|
|
* @param Data, argument to transport.send
|
|
|
|
*/
|
|
|
|
rawData: null,
|
|
|
|
raw : false,
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Configure a timeout, in milliseconds. If the request has not resolved
|
|
|
|
* (either with success or with an error) within the provided timeframe,
|
|
|
|
* it will automatically fail with error JX.Request.ERROR_TIMEOUT.
|
|
|
|
*
|
|
|
|
* @param int Timeout, in milliseconds (e.g. 3000 = 3 seconds).
|
|
|
|
*/
|
|
|
|
timeout : null,
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Whether or not we should expect the CSRF guard in the response.
|
|
|
|
*
|
|
|
|
* @param bool
|
|
|
|
*/
|
|
|
|
expectCSRFGuard : true,
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Whether it should be a CORS (Cross-Origin Resource Sharing) request to
|
|
|
|
* a third party domain other than the current site.
|
|
|
|
*
|
|
|
|
* @param bool
|
|
|
|
*/
|
|
|
|
CORS : false,
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Type of the response.
|
|
|
|
*
|
|
|
|
* @param enum 'JAVELIN', 'JSON', 'XML', 'TEXT'
|
|
|
|
*/
|
|
|
|
responseType : 'JAVELIN'
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|