1/** 2 * EGroupware eTemplate2 - JS Number object 3 * 4 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License 5 * @package etemplate 6 * @subpackage api 7 * @link http://www.egroupware.org 8 * @author Nathan Gray 9 * @copyright Nathan Gray 2011 10 * @version $Id$ 11 */ 12 13/*egw:uses 14 et2_core_inputWidget; 15 phpgwapi.Resumable.resumable; 16*/ 17 18import {et2_inputWidget} from "./et2_core_inputWidget"; 19import {et2_register_widget, WidgetConfig} from "./et2_core_widget"; 20import {ClassWithAttributes} from "./et2_core_inheritance"; 21 22/** 23 * Class which implements file upload 24 * 25 * @augments et2_inputWidget 26 */ 27export class et2_file extends et2_inputWidget 28{ 29 static readonly _attributes : any = { 30 "multiple": { 31 "name": "Multiple files", 32 "type": "boolean", 33 "default": false, 34 "description": "Allow the user to select more than one file to upload at a time. Subject to browser support." 35 }, 36 "max_file_size": { 37 "name": "Maximum file size", 38 "type": "integer", 39 "default":0, 40 "description": "Largest file accepted, in bytes. Subject to server limitations. 8MB = 8388608" 41 }, 42 "mime": { 43 "name": "Allowed file types", 44 "type": "string", 45 "default": et2_no_init, 46 "description": "Mime type (eg: image/png) or regex (eg: /^text\//i) for allowed file types" 47 }, 48 "blur": { 49 "name": "Placeholder", 50 "type": "string", 51 "default": "", 52 "description": "This text get displayed if an input-field is empty and does not have the input-focus (blur). It can be used to show a default value or a kind of help-text." 53 }, 54 "progress": { 55 "name": "Progress node", 56 "type": "string", 57 "default": et2_no_init, 58 "description": "The ID of an alternate node (div) to display progress and results. The Node is fetched with et2 getWidgetById so you MUST use the id assigned in XET-File (it may not be available at creation time, so we (re)check on createStatus time)" 59 }, 60 "onStart": { 61 "name": "Start event handler", 62 "type": "any", 63 "default": et2_no_init, 64 "description": "A (js) function called when an upload starts. Return true to continue with upload, false to cancel." 65 }, 66 "onFinish": { 67 "name": "Finish event handler", 68 "type": "any", 69 "default": et2_no_init, 70 "description": "A (js) function called when all files to be uploaded are finished." 71 }, 72 drop_target: { 73 "name": "Optional, additional drop target for HTML5 uploads", 74 "type": "string", 75 "default": et2_no_init, 76 "description": "The ID of an additional drop target for HTML5 drag-n-drop file uploads" 77 }, 78 label: { 79 "name": "Label of file upload", 80 "type": "string", 81 "default": "Choose file...", 82 "description": "String caption to be displayed on file upload span" 83 }, 84 progress_dropdownlist: { 85 "name": "List on files in progress like dropdown", 86 "type": "boolean", 87 "default": false, 88 "description": "Style list of files in uploading progress like dropdown list with a total upload progress indicator" 89 }, 90 onFinishOne: { 91 "name": "Finish event handler for each one", 92 "type": "js", 93 "default": et2_no_init, 94 "description": "A (js) function called when a file to be uploaded is finished." 95 }, 96 accept: { 97 "name": "Acceptable extensions", 98 "type": "string", 99 "default": '', 100 "description": "Define types of files that the server accepts. Multiple types can be seperated by comma and the default is to accept everything." 101 }, 102 chunk_size: { 103 "name": "Chunk size", 104 "type": "integer", 105 "default": 1024*1024, 106 "description": "Max chunk size, gets set from server-side PHP (max_upload_size-1M)/2" // last chunk can be up to 2*chunk_size! 107 } 108 }; 109 110 asyncOptions : any = {}; 111 input : JQuery = null; 112 progress : JQuery = null; 113 span : JQuery = null; 114 disabled_buttons : JQuery; 115 resumable : any; 116 117 /** 118 * Constructor 119 * 120 * @memberOf et2_file 121 */ 122 constructor(_parent, _attrs? : WidgetConfig, _child? : object) 123 { 124 // Call the inherited constructor 125 super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_file._attributes, _child || {})); 126 127 this.node = null; 128 this.input = null; 129 this.progress = null; 130 this.span = null; 131 // Contains all submit buttons need to be disabled during upload process 132 this.disabled_buttons = jQuery("input[type='submit'], button"); 133 134 // Make sure it's an object, not an array, or values get lost when sent to server 135 this.options.value = jQuery.extend({},this.options.value); 136 137 if(!this.options.id) { 138 console.warn("File widget needs an ID. Used 'file_widget'."); 139 this.options.id = "file_widget"; 140 } 141 142 // Legacy - id ending in [] means multiple 143 if(this.options.id.substr(-2) == "[]") 144 { 145 this.options.multiple = true; 146 } 147 // If ID ends in /, it's a directory - allow multiple 148 else if (this.options.id.substr(-1) === "/") 149 { 150 this.options.multiple = true; 151 _attrs.multiple = true; 152 } 153 154 // Set up the URL to have the request ID & the widget ID 155 var instance = this.getInstanceManager(); 156 157 let self = this; 158 159 this.asyncOptions = jQuery.extend({ 160 161 },this.getAsyncOptions(this)); 162 this.asyncOptions.fieldName = this.options.id; 163 this.createInputWidget(); 164 this.set_readonly(this.options.readonly); 165 } 166 167 destroy() 168 { 169 super.destroy(); 170 this.set_drop_target(null); 171 this.node = null; 172 this.input = null; 173 this.span = null; 174 this.progress = null; 175 } 176 177 createInputWidget() 178 { 179 this.node = <HTMLElement><unknown>jQuery(document.createElement("div")).addClass("et2_file"); 180 this.span = jQuery(document.createElement("span")) 181 .addClass('et2_file_span et2_button') 182 .appendTo (this.node); 183 if (this.options.label != '') this.span.addClass('et2_button_text'); 184 let span = this.span; 185 this.input = jQuery(document.createElement("input")) 186 .attr("type", "file").attr("placeholder", this.options.blur) 187 .addClass ("et2_file_upload") 188 .appendTo(this.node) 189 .hover(function() { 190 jQuery(span) 191 .toggleClass('et2_file_spanHover'); 192 }) 193 .on({ 194 mousedown:function (){ 195 jQuery(span).addClass('et2_file_spanActive'); 196 }, 197 mouseup:function (){ 198 jQuery(span).removeClass('et2_file_spanActive'); 199 } 200 }); 201 if (this.options.accept) this.input.attr('accept', this.options.accept); 202 let self = this; 203 // trigger native input upload file 204 if (!this.options.readonly) this.span.click(function(){self.input.click()}); 205 // Check for File interface, should fall back to normal form submit if missing 206 if(typeof File != "undefined" && typeof (new XMLHttpRequest()).upload != "undefined") 207 { 208 this.resumable = new Resumable(this.asyncOptions); 209 this.resumable.assignBrowse(this.input); 210 this.resumable.on('fileAdded', jQuery.proxy(this._fileAdded, this)); 211 this.resumable.on('fileProgress', jQuery.proxy(this._fileProgress, this)); 212 this.resumable.on('fileSuccess', jQuery.proxy(this.finishUpload, this)); 213 this.resumable.on('complete', jQuery.proxy(this.onFinish, this)); 214 } 215 else 216 { 217 // This may be a problem submitting via ajax 218 } 219 if(this.options.progress) 220 { 221 let widget = this.getRoot().getWidgetById(this.options.progress); 222 if(widget) 223 { 224 //may be not available at createInputWidget time 225 this.progress = jQuery(widget.getDOMNode()); 226 } 227 } 228 if(!this.progress) 229 { 230 this.progress = jQuery(document.createElement("div")).appendTo(this.node); 231 } 232 this.progress.addClass("progress"); 233 234 if(this.options.multiple) 235 { 236 this.input.attr("multiple","multiple"); 237 } 238 239 this.setDOMNode(this.node[0]); 240 // set drop target to widget dom node if no target option is specified 241 if (!this.options.drop_target) this.resumable.assignDrop([this.getDOMNode()]); 242 } 243 244 /** 245 * Get any specific async upload options 246 */ 247 getAsyncOptions(self: et2_file) 248 { 249 return { 250 // Callbacks 251 onStart: function(event, file_count) { 252 return self.onStart(event, file_count); 253 }, 254 onFinish: function(event, file_count) { 255 self.onFinish.apply(self, [event, file_count]) 256 }, 257 onStartOne: function(event, file_name, index, file_count) { 258 259 }, 260 onFinishOne: function(event, response, name, number, total) { return self.finishUpload(event,response,name,number,total);}, 261 onProgress: function(event, progress, name, number, total) { return self.onProgress(event,progress,name,number,total);}, 262 onError: function(event, name, error) { return self.onError(event,name,error);}, 263 beforeSend: function(form) { return self.beforeSend(form);}, 264 265 chunkSize: this.options.chunk_size || 1024*1024, 266 267 target: egw.ajaxUrl("EGroupware\\Api\\Etemplate\\Widget\\File::ajax_upload"), 268 query: function(file) {return self.beforeSend(file);}, 269 // Disable checking for already uploaded chunks 270 testChunks: false 271 }; 272 } 273 /** 274 * Set a widget or DOM node as a HTML5 file drop target 275 * 276 * @param {string} new_target widget ID or DOM node ID to be used as a new target 277 */ 278 set_drop_target(new_target : string) 279 { 280 // Cancel old drop target 281 if(this.options.drop_target) 282 { 283 let widget = this.getRoot().getWidgetById(this.options.drop_target); 284 let drop_target = widget && widget.getDOMNode() || document.getElementById(this.options.drop_target); 285 if(drop_target) 286 { 287 this.resumable.unAssignDrop(drop_target); 288 } 289 } 290 291 this.options.drop_target = new_target; 292 293 if(!this.options.drop_target) return; 294 295 // Set up new drop target 296 let widget = this.getRoot().getWidgetById(this.options.drop_target); 297 let drop_target = widget && widget.getDOMNode() || document.getElementById(this.options.drop_target); 298 if(drop_target) 299 { 300 this.resumable.assignDrop([drop_target]); 301 } 302 else 303 { 304 this.egw().debug("warn", "Did not find file drop target %s", this.options.drop_target); 305 } 306 307 } 308 309 attachToDOM() 310 { 311 let res = super.attachToDOM(); 312 // Override parent's change, file widget will fire change when finished uploading 313 this.input.unbind("change.et2_inputWidget"); 314 return res; 315 } 316 317 getValue() 318 { 319 return this.options.value ? this.options.value : this.input.val(); 320 } 321 322 /** 323 * Set the value of the file widget. 324 * 325 * If you pass a FileList or list of files, it will trigger the async upload 326 * 327 * @param {FileList|File[]|false} value List of files to be uploaded, or false to reset. 328 * @param {Event} event Most browsers require the user to initiate file transfers in some way. 329 * Pass the event in, if you have it. 330 */ 331 set_value(value, event?) : boolean 332 { 333 if (!value || typeof value == "undefined") { 334 value = {}; 335 } 336 if (jQuery.isEmptyObject(value)) { 337 this.options.value = {}; 338 if (this.resumable.progress() == 1) this.progress.empty(); 339 340 // Reset the HTML element 341 this.input.wrap('<form>').closest('form').get(0).reset(); 342 this.input.unwrap(); 343 344 return; 345 } 346 347 let addFile = jQuery.proxy(function (i, file) { 348 this.resumable.addFile(file, event); 349 }, this); 350 if (typeof value == 'object' && value.length && typeof value[0] == 'object' && value[0].name) { 351 try { 352 this.input[0].files = value; 353 354 jQuery.each(value, addFile); 355 } catch (e) { 356 var self = this; 357 var args = arguments; 358 jQuery.each(value, addFile); 359 } 360 } 361 } 362 363 /** 364 * Set the value for label 365 * The label is used as caption for span tag which customize the HTML file upload styling 366 * 367 * @param {string} value text value of label 368 */ 369 set_label(value) 370 { 371 if (this.span != null && value != null) 372 { 373 this.span.text(value); 374 } 375 } 376 377 getInputNode() 378 { 379 if (typeof this.input == 'undefined') return <HTMLElement><unknown>false; 380 return this.input[0]; 381 } 382 383 384 set_mime(mime) 385 { 386 if(!mime) 387 { 388 this.options.mime = null; 389 } 390 if(mime.indexOf("/") != 0) 391 { 392 // Lower case it now, if it's not a regex 393 this.options.mime = mime.toLowerCase(); 394 } 395 else 396 { 397 this.options.mime = mime; 398 } 399 } 400 401 set_multiple(_multiple) 402 { 403 this.options.multiple = _multiple; 404 if(_multiple) 405 { 406 return this.input.attr("multiple", "multiple"); 407 } 408 return this.input.removeAttr("multiple"); 409 } 410 411 /** 412 * Check to see if the provided file's mimetype matches 413 * 414 * @param f File object 415 * @return boolean 416 */ 417 checkMime(f) 418 { 419 if(!this.options.mime) return true; 420 421 let mime: string | RegExp = ''; 422 if(this.options.mime.indexOf("/") != 0) 423 { 424 // Lower case it now, if it's not a regex 425 mime = this.options.mime.toLowerCase(); 426 } 427 else 428 { 429 // Convert into a js regex 430 var parts = this.options.mime.substr(1).match(/(.*)\/([igm]?)$/); 431 mime = new RegExp(parts[1],parts.length > 2 ? parts[2] : ""); 432 } 433 434 // If missing, let the server handle it 435 if(!mime || !f.type) return true; 436 437 var is_preg = (typeof mime == "object"); 438 if(!is_preg && f.type.toLowerCase() == mime || is_preg && mime.test(f.type)) 439 { 440 return true; 441 } 442 443 // Not right mime 444 return false; 445 } 446 447 private _fileAdded(file,event) 448 { 449 // Manual additions have no event 450 if(typeof event == 'undefined') 451 { 452 event = {}; 453 } 454 // Trigger start of uploading, calls callback 455 if(!this.resumable.isUploading()) 456 { 457 if (!(this.onStart(event,this.resumable.files.length))) return; 458 } 459 460 // Here 'this' is the input 461 if(this.checkMime(file.file)) 462 { 463 if(this.createStatus(event,file)) 464 { 465 466 // Disable buttons 467 this.disabled_buttons 468 .not("[disabled]") 469 .attr("disabled", true) 470 .addClass('et2_button_ro') 471 .removeClass('et2_clickable') 472 .css('cursor', 'default'); 473 474 // Actually start uploading 475 this.resumable.upload(); 476 } 477 } 478 else 479 { 480 // Wrong mime type - show in the list of files 481 return this.createStatus( 482 this.egw().lang("File is of wrong type (%1 != %2)!", file.file.type, this.options.mime), 483 file 484 ); 485 } 486 487 } 488 489 /** 490 * Add in the request id 491 */ 492 beforeSend(form) { 493 var instance = this.getInstanceManager(); 494 495 return { 496 request_id: instance.etemplate_exec_id, 497 widget_id: this.id 498 }; 499 } 500 501 /** 502 * Disables submit buttons while uploading 503 */ 504 onStart(event, file_count) { 505 // Hide any previous errors 506 this.hideMessage(); 507 508 509 event.data = this; 510 511 //Add dropdown_progress 512 if (this.options.progress_dropdownlist) 513 { 514 this._build_progressDropDownList(); 515 } 516 517 // Callback 518 if(this.options.onStart) return et2_call(this.options.onStart, event, file_count); 519 return true; 520 } 521 522 /** 523 * Re-enables submit buttons when done 524 */ 525 onFinish() { 526 this.disabled_buttons.removeAttr("disabled").css('cursor','pointer').removeClass('et2_button_ro'); 527 528 var file_count = this.resumable.files.length; 529 530 // Remove files from list 531 while(this.resumable.files.length > 0) 532 { 533 this.resumable.removeFile(this.resumable.files[this.resumable.files.length -1]); 534 } 535 536 var event = jQuery.Event('upload'); 537 538 event.data = this; 539 540 var result = false; 541 542 //Remove progress_dropDown_fileList class and unbind the click handler from body 543 if (this.options.progress_dropdownlist) 544 { 545 this.progress.removeClass("progress_dropDown_fileList"); 546 jQuery(this.node).find('span').removeClass('totalProgress_loader'); 547 jQuery('body').off('click'); 548 } 549 550 if(this.options.onFinish && !jQuery.isEmptyObject(this.getValue())) 551 { 552 result = et2_call(this.options.onFinish, event, file_count); 553 } 554 else 555 { 556 result = (file_count == 0 || !jQuery.isEmptyObject(this.getValue())); 557 } 558 if(result) 559 { 560 // Fire legacy change action when done 561 this.change(this.input); 562 } 563 } 564 565 /** 566 * Build up dropdown progress with total count indicator 567 * 568 * @todo Implement totalProgress bar instead of ajax-loader, in order to show how much percent of uploading is completed 569 */ 570 private _build_progressDropDownList() 571 { 572 this.progress.addClass("progress_dropDown_fileList"); 573 574 //Add uploading indicator and bind hover handler on it 575 jQuery(this.node).find('span').addClass('totalProgress_loader'); 576 577 jQuery(this.node).find('span.et2_file_span').hover(function(){ 578 jQuery('.progress_dropDown_fileList').show(); 579 }); 580 //Bind click handler to dismiss the dropdown while uploading 581 jQuery('body').on('click', function(event){ 582 if (event.target.className != 'remove') 583 { 584 jQuery('.progress_dropDown_fileList').hide(); 585 } 586 }); 587 588 } 589 590 /** 591 * Creates the elements used for displaying the file, and it's upload status, and 592 * attaches them to the DOM 593 * 594 * @param _event Either the event, or an error message 595 */ 596 createStatus(_event, file) 597 { 598 var error = (typeof _event == "object" ? "" : _event); 599 600 if(this.options.max_file_size && file.size > this.options.max_file_size) { 601 error = this.egw().lang("File too large. Maximum %1", et2_vfsSize.prototype.human_size(this.options.max_file_size)); 602 } 603 604 if(this.options.progress) 605 { 606 var widget = this.getRoot().getWidgetById(this.options.progress); 607 if(widget) 608 { 609 this.progress = jQuery(widget.getDOMNode()); 610 this.progress.addClass("progress"); 611 } 612 } 613 if(this.progress) 614 { 615 var fileName = file.fileName || 'file'; 616 var status = jQuery("<li data-file='"+fileName.replace(/'/g, '"')+"'>"+fileName 617 +"<div class='remove'/><span class='progressBar'><p/></span></li>") 618 .appendTo(this.progress); 619 jQuery("div.remove",status).on('click', file, jQuery.proxy(this.cancel,this)); 620 if(error != "") 621 { 622 status.addClass("message ui-state-error"); 623 status.append("<div>"+error+"</diff>"); 624 jQuery(".progressBar",status).css("display", "none"); 625 } 626 } 627 return error == ""; 628 } 629 630 private _fileProgress(file) 631 { 632 if(this.progress) 633 { 634 jQuery("li[data-file='"+file.fileName.replace(/'/g, '"')+"'] > span.progressBar > p").css("width", Math.ceil(file.progress()*100)+"%"); 635 636 } 637 return true; 638 } 639 640 onError(event, name, error) 641 { 642 console.warn(event,name,error); 643 } 644 645 /** 646 * A file upload is finished, update the UI 647 */ 648 finishUpload(file, response) 649 { 650 var name = file.fileName || 'file'; 651 652 if(typeof response == 'string') response = jQuery.parseJSON(response); 653 if(response.response[0] && typeof response.response[0].data.length == 'undefined') { 654 if(typeof this.options.value !== 'object' || !this.options.multiple) 655 { 656 this.set_value({}); 657 } 658 for(var key in response.response[0].data) { 659 if(typeof response.response[0].data[key] == "string") 660 { 661 // Message from server - probably error 662 jQuery("[data-file='"+name.replace(/'/g, '"')+"']",this.progress) 663 .addClass("error") 664 .css("display", "block") 665 .text(response.response[0].data[key]); 666 } 667 else 668 { 669 this.options.value[key] = response.response[0].data[key]; 670 // If not multiple, we already destroyed the status, so re-create it 671 if(!this.options.multiple) 672 { 673 this.createStatus({}, file); 674 } 675 if(this.progress) 676 { 677 jQuery("[data-file='"+name.replace(/'/g, '"')+"']",this.progress).addClass("message success"); 678 } 679 } 680 } 681 } 682 else if (this.progress) 683 { 684 jQuery("[data-file='"+name.replace(/'/g, '"')+"']",this.progress) 685 .addClass("ui-state-error") 686 .css("display", "block") 687 .text(this.egw().lang("Server error")); 688 } 689 var event = jQuery.Event('upload'); 690 691 event.data = this; 692 693 // Callback 694 if(typeof this.onFinishOne == 'function') 695 { 696 this.onFinishOne(event, response, name); 697 } 698 return true; 699 } 700 701 /** 702 * Remove a file from the list of values 703 * 704 * @param {File|string} File object, or file name, to remove 705 */ 706 remove_file(file) 707 { 708 //console.info(filename); 709 if(typeof file == 'string') 710 { 711 file = {fileName: file}; 712 } 713 for(var key in this.options.value) 714 { 715 if(this.options.value[key].name == file.fileName) 716 { 717 delete this.options.value[key]; 718 jQuery('[data-file="'+file.fileName.replace(/'/g, '"')+'"]',this.node).remove(); 719 return; 720 } 721 } 722 if(file.isComplete && !file.isComplete() && file.cancel) file.cancel(); 723 } 724 725 /** 726 * Cancel a file - event callback 727 */ 728 cancel(e) 729 { 730 e.preventDefault(); 731 // Look for file name in list 732 var target = jQuery(e.target).parents("li"); 733 734 this.remove_file(e.data); 735 736 // In case it didn't make it to the list (error) 737 target.remove(); 738 jQuery(e.target).remove(); 739 } 740 741 /** 742 * Set readonly 743 * 744 * @param {boolean} _ro boolean readonly state, true means readonly 745 */ 746 set_readonly(_ro) 747 { 748 if (typeof _ro != "undefined") 749 { 750 this.options.readonly = _ro; 751 this.span.toggleClass('et2_file_ro',_ro); 752 if (this.options.readonly) 753 { 754 this.span.unbind('click'); 755 } 756 else 757 { 758 var self = this; 759 this.span.off().bind('click',function(){self.input.click()}); 760 } 761 } 762 } 763} 764et2_register_widget(et2_file, ["file"]); 765 766 767