Merge branch '3.1' into 5.2
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Ajax / Request.js
1 /**
2 * Versatile AJAX request handling.
3 *
4 * In case you want to issue JSONP requests, please use `AjaxJsonp` instead.
5 *
6 * @author Alexander Ebert
7 * @copyright 2001-2019 WoltLab GmbH
8 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
9 * @module AjaxRequest (alias)
10 * @module WoltLabSuite/Core/Ajax/Request
11 */
12 define(['Core', 'Language', 'Dom/ChangeListener', 'Dom/Util', 'Ui/Dialog', 'WoltLabSuite/Core/Ajax/Status'], function(Core, Language, DomChangeListener, DomUtil, UiDialog, AjaxStatus) {
13 "use strict";
14
15 var _didInit = false;
16 var _ignoreAllErrors = false;
17
18 /**
19 * @constructor
20 */
21 function AjaxRequest(options) {
22 this._data = null;
23 this._options = {};
24 this._previousXhr = null;
25 this._xhr = null;
26
27 this._init(options);
28 }
29 AjaxRequest.prototype = {
30 /**
31 * Initializes the request options.
32 *
33 * @param {Object} options request options
34 */
35 _init: function(options) {
36 this._options = Core.extend({
37 // request data
38 data: {},
39 contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
40 responseType: 'application/json',
41 type: 'POST',
42 url: '',
43 withCredentials: false,
44
45 // behavior
46 autoAbort: false,
47 ignoreError: false,
48 pinData: false,
49 silent: false,
50 includeRequestedWith: true,
51
52 // callbacks
53 failure: null,
54 finalize: null,
55 success: null,
56 progress: null,
57 uploadProgress: null,
58
59 callbackObject: null
60 }, options);
61
62 if (typeof options.callbackObject === 'object') {
63 this._options.callbackObject = options.callbackObject;
64 }
65
66 this._options.url = Core.convertLegacyUrl(this._options.url);
67 if (this._options.url.indexOf('index.php') === 0) {
68 this._options.url = WSC_API_URL + this._options.url;
69 }
70
71 if (this._options.url.indexOf(WSC_API_URL) === 0) {
72 this._options.includeRequestedWith = true;
73 // always include credentials when querying the very own server
74 this._options.withCredentials = true;
75 }
76
77 if (this._options.pinData) {
78 this._data = Core.extend({}, this._options.data);
79 }
80
81 if (this._options.callbackObject !== null) {
82 if (typeof this._options.callbackObject._ajaxFailure === 'function') this._options.failure = this._options.callbackObject._ajaxFailure.bind(this._options.callbackObject);
83 if (typeof this._options.callbackObject._ajaxFinalize === 'function') this._options.finalize = this._options.callbackObject._ajaxFinalize.bind(this._options.callbackObject);
84 if (typeof this._options.callbackObject._ajaxSuccess === 'function') this._options.success = this._options.callbackObject._ajaxSuccess.bind(this._options.callbackObject);
85 if (typeof this._options.callbackObject._ajaxProgress === 'function') this._options.progress = this._options.callbackObject._ajaxProgress.bind(this._options.callbackObject);
86 if (typeof this._options.callbackObject._ajaxUploadProgress === 'function') this._options.uploadProgress = this._options.callbackObject._ajaxUploadProgress.bind(this._options.callbackObject);
87 }
88
89 if (_didInit === false) {
90 _didInit = true;
91
92 window.addEventListener('beforeunload', function() { _ignoreAllErrors = true; });
93 }
94 },
95
96 /**
97 * Dispatches a request, optionally aborting a currently active request.
98 *
99 * @param {boolean} abortPrevious abort currently active request
100 */
101 sendRequest: function(abortPrevious) {
102 if (abortPrevious === true || this._options.autoAbort) {
103 this.abortPrevious();
104 }
105
106 if (!this._options.silent) {
107 AjaxStatus.show();
108 }
109
110 if (this._xhr instanceof XMLHttpRequest) {
111 this._previousXhr = this._xhr;
112 }
113
114 this._xhr = new XMLHttpRequest();
115 this._xhr.open(this._options.type, this._options.url, true);
116 if (this._options.contentType) {
117 this._xhr.setRequestHeader('Content-Type', this._options.contentType);
118 }
119 if (this._options.withCredentials || this._options.includeRequestedWith) {
120 this._xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
121 }
122 if (this._options.withCredentials) {
123 this._xhr.withCredentials = true;
124 }
125
126 var self = this;
127 var options = Core.clone(this._options);
128 this._xhr.onload = function() {
129 if (this.readyState === XMLHttpRequest.DONE) {
130 if (this.status >= 200 && this.status < 300 || this.status === 304) {
131 if (options.responseType && this.getResponseHeader('Content-Type').indexOf(options.responseType) !== 0) {
132 // request succeeded but invalid response type
133 self._failure(this, options);
134 }
135 else {
136 self._success(this, options);
137 }
138 }
139 else {
140 self._failure(this, options);
141 }
142 }
143 };
144 this._xhr.onerror = function() {
145 self._failure(this, options);
146 };
147
148 if (this._options.progress) {
149 this._xhr.onprogress = this._options.progress;
150 }
151 if (this._options.uploadProgress) {
152 this._xhr.upload.onprogress = this._options.uploadProgress;
153 }
154
155 if (this._options.type === 'POST') {
156 var data = this._options.data;
157 if (typeof data === 'object' && Core.getType(data) !== 'FormData') {
158 data = Core.serialize(data);
159 }
160
161 this._xhr.send(data);
162 }
163 else {
164 this._xhr.send();
165 }
166 },
167
168 /**
169 * Aborts a previous request.
170 */
171 abortPrevious: function() {
172 if (this._previousXhr === null) {
173 return;
174 }
175
176 this._previousXhr.abort();
177 this._previousXhr = null;
178
179 if (!this._options.silent) {
180 AjaxStatus.hide();
181 }
182 },
183
184 /**
185 * Sets a specific option.
186 *
187 * @param {string} key option name
188 * @param {?} value option value
189 */
190 setOption: function(key, value) {
191 this._options[key] = value;
192 },
193
194 /**
195 * Returns an option by key or undefined.
196 *
197 * @param {string} key option name
198 * @return {(*|null)} option value or null
199 */
200 getOption: function(key) {
201 if (objOwns(this._options, key)) {
202 return this._options[key];
203 }
204
205 return null;
206 },
207
208 /**
209 * Sets request data while honoring pinned data from setup callback.
210 *
211 * @param {Object} data request data
212 */
213 setData: function(data) {
214 if (this._data !== null && Core.getType(data) !== 'FormData') {
215 data = Core.extend(this._data, data);
216 }
217
218 this._options.data = data;
219 },
220
221 /**
222 * Handles a successful request.
223 *
224 * @param {XMLHttpRequest} xhr request object
225 * @param {Object} options request options
226 */
227 _success: function(xhr, options) {
228 if (!options.silent) {
229 AjaxStatus.hide();
230 }
231
232 if (typeof options.success === 'function') {
233 var data = null;
234 if (xhr.getResponseHeader('Content-Type').split(';', 1)[0].trim() === 'application/json') {
235 try {
236 data = JSON.parse(xhr.responseText);
237 }
238 catch (e) {
239 // invalid JSON
240 this._failure(xhr, options);
241
242 return;
243 }
244
245 // trim HTML before processing, see http://jquery.com/upgrade-guide/1.9/#jquery-htmlstring-versus-jquery-selectorstring
246 if (data && data.returnValues && data.returnValues.template !== undefined) {
247 data.returnValues.template = data.returnValues.template.trim();
248 }
249
250 // force-invoke the background queue
251 if (data && data.forceBackgroundQueuePerform) {
252 require(['WoltLabSuite/Core/BackgroundQueue'], function(BackgroundQueue) {
253 BackgroundQueue.invoke();
254 });
255 }
256 }
257
258 options.success(data, xhr.responseText, xhr, options.data);
259 }
260
261 this._finalize(options);
262 },
263
264 /**
265 * Handles failed requests, this can be both a successful request with
266 * a non-success status code or an entirely failed request.
267 *
268 * @param {XMLHttpRequest} xhr request object
269 * @param {Object} options request options
270 */
271 _failure: function (xhr, options) {
272 if (_ignoreAllErrors) {
273 return;
274 }
275
276 if (!options.silent) {
277 AjaxStatus.hide();
278 }
279
280 var data = null;
281 try {
282 data = JSON.parse(xhr.responseText);
283 }
284 catch (e) {}
285
286 var showError = true;
287 if (typeof options.failure === 'function') {
288 showError = options.failure((data || {}), (xhr.responseText || ''), xhr, options.data);
289 }
290
291 if (options.ignoreError !== true && showError !== false) {
292 var html = this.getErrorHtml(data, xhr);
293
294 if (html) {
295 if (UiDialog === undefined) UiDialog = require('Ui/Dialog');
296 UiDialog.openStatic(DomUtil.getUniqueId(), html, {
297 title: Language.get('wcf.global.error.title')
298 });
299 }
300 }
301
302 this._finalize(options);
303 },
304
305 /**
306 * Returns the inner HTML for an error/exception display.
307 *
308 * @param {Object} data
309 * @param {XMLHttpRequest} xhr
310 * @return {string}
311 */
312 getErrorHtml: function(data, xhr) {
313 var details = '';
314 var message = '';
315
316 if (data !== null) {
317 if (data.file && data.line) {
318 details += '<br><p>File:</p><p>' + data.file + ' in line ' + data.line + '</p>';
319 }
320
321 if (data.stacktrace) details += '<br><p>Stacktrace:</p><p>' + data.stacktrace + '</p>';
322 else if (data.exceptionID) details += '<br><p>Exception ID: <code>' + data.exceptionID + '</code></p>';
323
324 message = data.message;
325
326 data.previous.forEach(function(previous) {
327 details += '<hr><p>' + previous.message + '</p>';
328 details += '<br><p>Stacktrace</p><p>' + previous.stacktrace + '</p>';
329 });
330 }
331 else {
332 message = xhr.responseText;
333 }
334
335 if (!message || message === 'undefined') {
336 if (!ENABLE_DEBUG_MODE) return null;
337
338 message = 'XMLHttpRequest failed without a responseText. Check your browser console.'
339 }
340
341 return '<div class="ajaxDebugMessage"><p>' + message + '</p>' + details + '</div>';
342 },
343
344 /**
345 * Finalizes a request.
346 *
347 * @param {Object} options request options
348 */
349 _finalize: function(options) {
350 if (typeof options.finalize === 'function') {
351 options.finalize(this._xhr);
352 }
353
354 this._previousXhr = null;
355
356 DomChangeListener.trigger();
357
358 // fix anchor tags generated through WCF::getAnchor()
359 var links = elBySelAll('a[href*="#"]');
360 for (var i = 0, length = links.length; i < length; i++) {
361 var link = links[i];
362 var href = elAttr(link, 'href');
363 if (href.indexOf('AJAXProxy') !== -1 || href.indexOf('ajax-proxy') !== -1) {
364 href = href.substr(href.indexOf('#'));
365 elAttr(link, 'href', document.location.toString().replace(/#.*/, '') + href);
366 }
367 }
368 }
369 };
370
371 return AjaxRequest;
372 });