Merge branch 'next' into pipGui
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Upload.js
1 /**
2 * Uploads file via AJAX.
3 *
4 * @author Matthias Schmidt
5 * @copyright 2001-2018 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module WoltLabSuite/Core/Upload
8 */
9 define(['AjaxRequest', 'Core', 'Dom/ChangeListener', 'Language', 'Dom/Util', 'Dom/Traverse'], function(AjaxRequest, Core, DomChangeListener, Language, DomUtil, DomTraverse) {
10 "use strict";
11
12 if (!COMPILER_TARGET_DEFAULT) {
13 var Fake = function() {};
14 Fake.prototype = {
15 _createButton: function() {},
16 _createFileElement: function() {},
17 _createFileElements: function() {},
18 _failure: function() {},
19 _getParameters: function() {},
20 _insertButton: function() {},
21 _progress: function() {},
22 _removeButton: function() {},
23 _success: function() {},
24 _upload: function() {},
25 _uploadFiles: function() {}
26 };
27 return Fake;
28 }
29
30 /**
31 * @constructor
32 */
33 function Upload(buttonContainerId, targetId, options) {
34 options = options || {};
35
36 if (options.className === undefined) {
37 throw new Error("Missing class name.");
38 }
39
40 // set default options
41 this._options = Core.extend({
42 // name of the PHP action
43 action: 'upload',
44 // is true if multiple files can be uploaded at once
45 multiple: false,
46 // name if the upload field
47 name: '__files[]',
48 // is true if every file from a multi-file selection is uploaded in its own request
49 singleFileRequests: false,
50 // url for uploading file
51 url: 'index.php?ajax-upload/&t=' + SECURITY_TOKEN
52 }, options);
53
54 this._options.url = Core.convertLegacyUrl(this._options.url);
55 if (this._options.url.indexOf('index.php') === 0) {
56 this._options.url = WSC_API_URL + this._options.url;
57 }
58
59 this._buttonContainer = elById(buttonContainerId);
60 if (this._buttonContainer === null) {
61 throw new Error("Element id '" + buttonContainerId + "' is unknown.");
62 }
63
64 this._target = elById(targetId);
65 if (targetId === null) {
66 throw new Error("Element id '" + targetId + "' is unknown.");
67 }
68 if (options.multiple && this._target.nodeName !== 'UL' && this._target.nodeName !== 'OL' && this._target.nodeName !== 'TBODY') {
69 throw new Error("Target element has to be list or table body if uploading multiple files is supported.");
70 }
71
72 this._fileElements = [];
73 this._internalFileId = 0;
74
75 // upload ids that belong to an upload of multiple files at once
76 this._multiFileUploadIds = [];
77
78 this._createButton();
79 }
80 Upload.prototype = {
81 /**
82 * Creates the upload button.
83 */
84 _createButton: function() {
85 this._fileUpload = elCreate('input');
86 elAttr(this._fileUpload, 'type', 'file');
87 elAttr(this._fileUpload, 'name', this._options.name);
88 if (this._options.multiple) {
89 elAttr(this._fileUpload, 'multiple', 'true');
90 }
91 this._fileUpload.addEventListener('change', this._upload.bind(this));
92
93 this._button = elCreate('p');
94 this._button.className = 'button uploadButton';
95
96 var span = elCreate('span');
97 span.textContent = Language.get('wcf.global.button.upload');
98 this._button.appendChild(span);
99
100 DomUtil.prepend(this._fileUpload, this._button);
101
102 this._insertButton();
103
104 DomChangeListener.trigger();
105 },
106
107 /**
108 * Creates the document element for an uploaded file.
109 *
110 * @param {File} file uploaded file
111 * @return {HTMLElement}
112 */
113 _createFileElement: function(file) {
114 var progress = elCreate('progress');
115 elAttr(progress, 'max', 100);
116
117 if (this._target.nodeName === 'OL' || this._target.nodeName === 'UL') {
118 var li = elCreate('li');
119 li.innerText = file.name;
120 li.appendChild(progress);
121
122 this._target.appendChild(li);
123
124 return li;
125 }
126 else if (this._target.nodeName === 'TBODY') {
127 return this._createFileTableRow(file);
128 }
129 else {
130 var p = elCreate('p');
131 p.appendChild(progress);
132
133 this._target.appendChild(p);
134
135 return p;
136 }
137 },
138
139 /**
140 * Creates the document elements for uploaded files.
141 *
142 * @param {(FileList|Array.<File>)} files uploaded files
143 */
144 _createFileElements: function(files) {
145 if (files.length) {
146 var uploadId = this._fileElements.length;
147 this._fileElements[uploadId] = [];
148
149 for (var i = 0, length = files.length; i < length; i++) {
150 var file = files[i];
151 var fileElement = this._createFileElement(file);
152
153 if (!fileElement.classList.contains('uploadFailed')) {
154 elData(fileElement, 'filename', file.name);
155 elData(fileElement, 'internal-file-id', this._internalFileId++);
156 this._fileElements[uploadId][i] = fileElement;
157 }
158 }
159
160 DomChangeListener.trigger();
161
162 return uploadId;
163 }
164
165 return null;
166 },
167
168 _createFileTableRow: function(file) {
169 throw new Error("Has to be implemented in subclass.");
170 },
171
172 /**
173 * Handles a failed file upload.
174 *
175 * @param {int} uploadId identifier of a file upload
176 * @param {object<string, *>} data response data
177 * @param {string} responseText response
178 * @param {XMLHttpRequest} xhr request object
179 * @param {object<string, *>} requestOptions options used to send AJAX request
180 * @return {boolean} true if the error message should be shown
181 */
182 _failure: function(uploadId, data, responseText, xhr, requestOptions) {
183 // does nothing
184 return true;
185 },
186
187 /**
188 * Return additional parameters for upload requests.
189 *
190 * @return {object<string, *>} additional parameters
191 */
192 _getParameters: function() {
193 return {};
194 },
195
196 /**
197 * Inserts the created button to upload files into the button container.
198 */
199 _insertButton: function() {
200 DomUtil.prepend(this._button, this._buttonContainer);
201 },
202
203 /**
204 * Updates the progress of an upload.
205 *
206 * @param {int} uploadId internal upload identifier
207 * @param {XMLHttpRequestProgressEvent} event progress event object
208 */
209 _progress: function(uploadId, event) {
210 var percentComplete = Math.round(event.loaded / event.total * 100);
211
212 for (var i in this._fileElements[uploadId]) {
213 var progress = elByTag('PROGRESS', this._fileElements[uploadId][i]);
214 if (progress.length === 1) {
215 elAttr(progress[0], 'value', percentComplete);
216 }
217 }
218 },
219
220 /**
221 * Removes the button to upload files.
222 */
223 _removeButton: function() {
224 elRemove(this._button);
225
226 DomChangeListener.trigger();
227 },
228
229 /**
230 * Handles a successful file upload.
231 *
232 * @param {int} uploadId identifier of a file upload
233 * @param {object<string, *>} data response data
234 * @param {string} responseText response
235 * @param {XMLHttpRequest} xhr request object
236 * @param {object<string, *>} requestOptions options used to send AJAX request
237 */
238 _success: function(uploadId, data, responseText, xhr, requestOptions) {
239 // does nothing
240 },
241
242 /**
243 * File input change callback to upload files.
244 *
245 * @param {Event} event input change event object
246 * @param {File} file uploaded file
247 * @param {Blob} blob file blob
248 * @return {(int|Array.<int>|null)} identifier(s) for the uploaded files
249 */
250 _upload: function(event, file, blob) {
251 // remove failed upload elements first
252 var failedUploads = DomTraverse.childrenByClass(this._target, 'uploadFailed');
253 for (var i = 0, length = failedUploads.length; i < length; i++) {
254 elRemove(failedUploads[i]);
255 }
256
257 var uploadId = null;
258
259 var files = [];
260 if (file) {
261 files.push(file);
262 }
263 else if (blob) {
264 var fileExtension = '';
265 switch (blob.type) {
266 case 'image/jpeg':
267 fileExtension = '.jpg';
268 break;
269
270 case 'image/gif':
271 fileExtension = '.gif';
272 break;
273
274 case 'image/png':
275 fileExtension = '.png';
276 break;
277 }
278
279 files.push({
280 name: 'pasted-from-clipboard' + fileExtension
281 });
282 }
283 else {
284 files = this._fileUpload.files;
285 }
286
287 if (files.length) {
288 if (this._options.singleFileRequests) {
289 uploadId = [];
290 for (var i = 0, length = files.length; i < length; i++) {
291 var localUploadId = this._uploadFiles([ files[i] ], blob);
292
293 if (files.length !== 1) {
294 this._multiFileUploadIds.push(localUploadId)
295 }
296 uploadId.push(localUploadId);
297 }
298 }
299 else {
300 uploadId = this._uploadFiles(files, blob);
301 }
302 }
303
304 // re-create upload button to effectively reset the 'files'
305 // property of the input element
306 this._removeButton();
307 this._createButton();
308
309 return uploadId;
310 },
311
312 /**
313 * Sends the request to upload files.
314 *
315 * @param {(FileList|Array.<File>)} files uploaded files
316 * @param {Blob} blob file blob
317 * @return {(int|null)} identifier for the uploaded files
318 */
319 _uploadFiles: function(files, blob) {
320 var uploadId = this._createFileElements(files);
321
322 // no more files left, abort
323 if (!this._fileElements[uploadId].length) {
324 return null;
325 }
326
327 var formData = new FormData();
328 for (var i = 0, length = files.length; i < length; i++) {
329 if (this._fileElements[uploadId][i]) {
330 var internalFileId = elData(this._fileElements[uploadId][i], 'internal-file-id');
331
332 if (blob) {
333 formData.append('__files[' + internalFileId + ']', blob, files[i].name);
334 }
335 else {
336 formData.append('__files[' + internalFileId + ']', files[i]);
337 }
338 }
339 }
340
341 formData.append('actionName', this._options.action);
342 formData.append('className', this._options.className);
343 if (this._options.action === 'upload') {
344 formData.append('interfaceName', 'wcf\\data\\IUploadAction');
345 }
346
347 // recursively append additional parameters to form data
348 var appendFormData = function(parameters, prefix) {
349 prefix = prefix || '';
350
351 for (var name in parameters) {
352 if (typeof parameters[name] === 'object') {
353 appendFormData(parameters[name], prefix + '[' + name + ']');
354 }
355 else {
356 formData.append('parameters' + prefix + '[' + name + ']', parameters[name]);
357 }
358 }
359 };
360
361 appendFormData(this._getParameters());
362
363 var request = new AjaxRequest({
364 data: formData,
365 contentType: false,
366 failure: this._failure.bind(this, uploadId),
367 silent: true,
368 success: this._success.bind(this, uploadId),
369 uploadProgress: this._progress.bind(this, uploadId),
370 url: this._options.url,
371 withCredentials: true
372 });
373 request.sendRequest();
374
375 return uploadId;
376 },
377
378 /**
379 * Returns true if there are any pending uploads handled by this
380 * upload manager.
381 *
382 * @return {boolean}
383 * @since 3.2
384 */
385 hasPendingUploads: function() {
386 for (var uploadId in this._fileElements) {
387 for (var i in this._fileElements[uploadId]) {
388 var progress = elByTag('PROGRESS', this._fileElements[uploadId][i]);
389 if (progress.length === 1) {
390 return true;
391 }
392 }
393 }
394
395 return false;
396 },
397
398 /**
399 * Uploads the given file blob.
400 *
401 * @param {Blob} blob file blob
402 * @return {int} identifier for the uploaded file
403 */
404 uploadBlob: function(blob) {
405 return this._upload(null, null, blob);
406 },
407
408 /**
409 * Uploads the given file.
410 *
411 * @param {File} file uploaded file
412 * @return {int} identifier(s) for the uploaded file
413 */
414 uploadFile: function(file) {
415 return this._upload(null, file);
416 }
417 };
418
419 return Upload;
420 });