Source: upload/UploadMonitor.js

/**
 * Copyright (C) 2005-2016 Alfresco Software Limited.
 *
 * This file is part of Alfresco
 *
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 */

/**
 * This module provides display handling for file uploads. It's normally only used by the
 * [UploadService]{@link module:alfresco/services/UploadService}.
 *
 * @module alfresco/upload/UploadMonitor
 * @extends alfresco/upload/_UploadsDisplayMixin
 * @mixes alfresco/core/FileSizeMixin
 * @author Martin Doyle
 * @since 1.0.50
 */
define(["alfresco/core/FileSizeMixin",
        "alfresco/core/CoreWidgetProcessing", 
        "alfresco/core/topics",
        "alfresco/core/Core",
        "alfresco/upload/UploadsDisplayInterface",
        "dijit/_WidgetBase", 
        "dijit/_TemplatedMixin", 
        "dojo/_base/array", 
        "dojo/_base/declare", 
        "dojo/_base/lang", 
        "dojo/dom-class", 
        "dojo/dom-construct",
        "dojo/dom-style",
        "dojo/when", 
        "dojo/text!./templates/UploadMonitor.html"], 
        function(FileSizeMixin, CoreWidgetProcessing, topics, AlfCore, UploadsDisplayInterface, _WidgetBase, _TemplatedMixin, array, declare, lang, domClass, domConstruct, domStyle, when, template) {

   return declare([UploadsDisplayInterface, _WidgetBase, _TemplatedMixin, AlfCore, FileSizeMixin, CoreWidgetProcessing], {

      /**
       * An array of the i18n files to use with this widget.
       * 
       * @instance
       * @type {object[]}
       * @default [{i18nFile: "./i18n/UploadMonitor.properties"}]
       */
      i18nRequirements: [{
         i18nFile: "./i18n/UploadMonitor.properties"
      }, {
         i18nFile: "alfresco/renderers/i18n/Size.properties"
      }],

      /**
       * An array of the CSS files to use with this widget.
       * 
       * @instance cssRequirements {Array}
       * @type {object[]}
       * @default [{cssFile:"./css/UploadMonitor.css"}]
       */
      cssRequirements: [{
         cssFile: "./css/UploadMonitor.css"
      }],

      /**
       * The HTML template to use for the widget.
       * @instance
       * @type {String}
       */
      templateString: template,

      /**
       * The constant representing the base BEM CSS class for this widget.
       *
       * @instance
       * @readOnly
       * @type {string}
       * @default
       */
      baseClass: "alfresco-upload-UploadMonitor",

      /**
       * Whether to display the upload percentage against each item.
       *
       * @instance
       * @type {boolean}
       * @default
       */
      displayUploadPercentage: true,

      /**
       * The maximum length of the upload name (in characters) after which it will be truncated.
       *
       * @instance
       * @type {number}
       * @default
       */
      maxUploadNameLength: 50,

      /**
       * The characters which should swap when the text is reversed.
       *
       * @instance
       * @type {String[]}
       * @default ["[]", "{}", "<>", "()"]
       * @since 1.0.79
       */
      reverseChars: ["[]", "{}", "<>", "()"],

      /**
       * If set to true, this will override the [maxUploadNameLength property]{@see module:alfresco/upload/UploadMonitor#maxUploadNameLength}
       * and any long filenames will instead be truncated instead by the available space, with an ellipsis used at the end of the string to
       * denote any missing characters.
       *
       * @instance
       * @type {boolean}
       * @default
       * @since 1.0.66
       */
      useEllipsisForLongFilenames: true,

      /**
       * <p>This collection of [PublishAction]{@link module:alfresco/renderers/PublishAction} widgets
       * will be displayed against each inprogress item in the upload monitor. The upload item
       * (containing relevant information) will be added as the current item, and the
       * [publishPayloadType]{@link module:alfresco/renderers/_PublishPayloadMixin#publishPayloadType}
       * will default to [PayloadTypes.CURRENT_ITEM]{@link module:alfresco/renderers/_PublishPayloadMixin#PayloadTypes},
       * if not specified. In effect, this means that you should normally only need to specify the
       * [publishTopic]{@link module:alfresco/renderers/PublishAction#publishTopic} and
       * [iconClass]{@link module:alfresco/renderers/PublishAction#iconClass} in the PublishAction
       * config.</p>
       *
       * <p>The currentItem that's provided to the supplied action is an object with five properties.
       * Specifically, three simple properties of fileId, fileSize (bytes) and fileName and two additional
       * complex properties of fileObj (which is all the details of the upload object) and response
       * (which is the server response - valid for finished uploads only).</p>
       *
       * @instance
       * @type {object[]}
       * @default
       * @since 1.0.56
       */
      widgetsForInProgressActions: [
         {
            name: "alfresco/renderers/PublishAction",
            config: {
               publishTopic: topics.CANCEL_INPROGRESS_UPLOAD,
               iconClass: "cancel-16"
            }
         }
      ],

      /**
       * PublishActions for displaying against successful items. For more information on how to use this, see
       * [widgetsForInProgressActions]{@link module:alfresco/upload/UploadMonitor#widgetsForInProgressActions}.
       *
       * @instance
       * @type {object[]}
       * @default
       * @since 1.0.56
       */
      widgetsForSuccessfulActions: null,

      /**
       * PublishActions for displaying against unsuccessful items. For more information on how to use this, see
       * [widgetsForInProgressActions]{@link module:alfresco/upload/UploadMonitor#widgetsForInProgressActions}.
       *
       * @instance
       * @type {object[]}
       * @default
       * @since 1.0.56
       */
      widgetsForUnsuccessfulActions: null,

      /**
       * This defines the widget model for rendering an error icon. This is expected to be a single 
       * [SVGImage]{@link module:alfresco/html/SVGImage} but is made configurable in order to support customization
       * of dimensions and the image rendered. If a radically different widget model is provided then it may
       * be necessary to use an extension of this widget with an extension to the 
       * [handleFailedUpload]{@link module:alfresco/upload/UploadMonitor#handleFailedUpload} function.
       * 
       * @instance
       * @type {object[]}
       * @since 1.0.58
       */
      widgetsForErrorIcon: [
         {
            name: "alfresco/html/SVGImage",
            config: {
               source: "alfresco/html/svg/error.svg",
               symbolId: "error",
               height: 16,
               width: 16
            }
         }
      ],

      /**
       * A map of all uploads.
       *
       * @instance
       * @type {object}
       * @default
       */
      _uploads: null,

      /**
       * Constructor
       *
       * @instance
       */
      constructor: function alfesco_upload_UploadMonitor__constructor() {
         this._uploads = {};
         this.setupReverseChars();
      },

      /**
       * Run after the widget has been created.
       *
       * @instance
       * @override
       * @since 1.0.65
       * @listens module:alfresco/core/topics#UPLOAD_MODIFY_ITEM
       */
      postCreate: function alfesco_upload_UploadMonitor__postCreate() {
         this.alfSubscribe(topics.UPLOAD_MODIFY_ITEM, lang.hitch(this, this.handleModifyItem));
         if (this.useEllipsisForLongFilenames) {
            domClass.add(this.domNode, this.baseClass + "--use-ellipsis");
         }
      },

      /**
       * Create the actions widgets, ensure that the default publishPayloadType is set
       * to CURRENT_ITEM, and set the current item to be the supplied upload info.
       *
       * @instance
       * @param {object} actionPayload The upload information to be used in the action payload
       * @param {object} actionsNode The node in which to place the actions
       * @since 1.0.56
       */
      addActions: function alfesco_upload_UploadMonitor__addActions(actionPayload, actionsNode) {

         // Loop through the potential states of an upload
         var propTypes = ["InProgress", "Successful", "Unsuccessful"],
            actionClass = this.baseClass + "__item__action";
         array.forEach(propTypes, function(propType) {

            // Grab the widgets property for this state
            var propName = "widgetsFor" + propType + "Actions",
               widgets = this[propName] && lang.clone(this[propName]);
            if (widgets && widgets.length) {

               // Create state-specific class
               var actionStateClass = actionClass + "__" + propType.toLowerCase();

               // Mix in the default payload type and a class to control visibility
               array.forEach(widgets, function(action) {
                  action.config = lang.mixin({
                     publishPayloadType: "PROCESS",
                     publishGlobal: true,
                     publishPayloadItemMixin: true,
                     additionalCssClasses: actionClass + " " + actionStateClass,
                     publishPayloadModifiers: ["processCurrentItemTokens"],
                     currentItem: actionPayload
                  }, action.config || {});
               }, this);

               // Create the widgets under the appropriate node
               this.processWidgets(widgets, actionsNode);
            }
         }, this);
      },

      /**
       * This function handles displaying a file that an attempt will be made to upload. The
       * [updateUploadProgress function]{@link module:alfresco/upload/_UploadsDisplayMixin#updateUploadProgress}
       * will handle updating the upload progress.
       *
       * @instance
       * @override
       * @param {string} fileId The unique id of the file
       * @pararm {object} file The file requested to be uploaded
       */
      addInProgressFile: function alfesco_upload_UploadMonitor__addInProgressFile(fileId, file) {

         // Add new row
         var itemRow = domConstruct.create("tr", {
               className: this.baseClass + "__item"
            }, this.inProgressItemsNode),
            itemName = domConstruct.create("td", {
               className: this.baseClass + "__item__name"
            }, itemRow),
            itemNameContent = domConstruct.create("div", {
               className: this.baseClass + "__item__name__content",
               textContent: this.getDisplayText(file),
               title: this.getDisplayText(file, true)
            }, itemName),
            itemProgress = domConstruct.create("td", {
               className: this.baseClass + "__item__progress"
            }, itemRow),
            itemProgressContent = domConstruct.create("span", {
               className: this.baseClass + "__item__progress__content",
               textContent: this.displayUploadPercentage ? "0%" : ""
            }, itemProgress),
            itemStatus = domConstruct.create("td", {
               className: this.baseClass + "__item__status"
            }, itemRow),
            itemActions = domConstruct.create("td", {
               className: this.baseClass + "__item__actions"
            }, itemRow),
            progressRow = domConstruct.create("tr", {}, this.inProgressItemsNode),
            progressCell = domConstruct.create("td", {
               colspan: 4,
               className: this.baseClass + "__item__progress-cell"
            }, progressRow),
            progressBar = domConstruct.create("div", {
               className: this.baseClass + "__item__progress-bar"
            }, progressCell);

         // Add localised status messages
         domConstruct.create("span", {
            className: this.baseClass + "__item__status__inprogress",
            textContent: this.message("upload.status.inprogress")
         }, itemStatus);
         domConstruct.create("span", {
            className: this.baseClass + "__item__status__finishing",
            textContent: this.message("upload.status.finishing")
         }, itemStatus);
         domConstruct.create("span", {
            className: this.baseClass + "__item__status__successful",
            textContent: this.message("upload.status.successful")
         }, itemStatus);
         domConstruct.create("span", {
            className: this.baseClass + "__item__status__unsuccessful",
            textContent: this.message("upload.status.unsuccessful")
         }, itemStatus);
         var errorIconNode = domConstruct.create("span", {
            className: this.baseClass + "__item__status__unsuccessful_icon"
         }, itemStatus);

         // Store in uploads map
         var upload = this._uploads[fileId] = {
            id: fileId,
            file: file,
            actionPayload: {
               uploadId: fileId,
               fileSize: file.size,
               fileName : file.name,
               fileObj: file
            },
            nodes: {
               row: itemRow,
               name: itemNameContent,
               errorIcon: errorIconNode,
               progress: itemProgressContent,
               progressRow: progressRow,
               progressBar: progressBar
            }
         };

         // Add actions
         this.addActions(upload.actionPayload, itemActions);
      },

      /**
       * This function handles displaying a file that could not be uploaded (where the failure
       * was identified before any attempt was made to start uploading the file).
       *
       * @instance
       * @override
       * @param {string} fileName The name of the file that could not be uploaded
       * @param {object} error The details of why the file could not be uploaded.
       */
      addFailedFile: function alfesco_upload_UploadMonitor__addFailedFile(fileName, error) {
         var uniqueFileId = Date.now();
         while (this._uploads.hasOwnProperty(uniqueFileId)) {
            uniqueFileId = Date.now();
         }
         this.addInProgressFile(uniqueFileId, {
            name: fileName
         });
         this.handleFailedUpload(uniqueFileId, null, {
            statusText: error.reason
         });
      },

      /**
       * Create the display name of the upload, including the file size
       *
       * @instance
       * @param {object} file The upload file
       * @param {boolean} [doNotModify=false] If true then will prevent any post-modification of the display text
       * @returns {string} The name of the upload to be deisplayed
       */
      getDisplayText: function alfesco_upload_UploadMonitor__getDisplayText(file, doNotModify) {

         // Create upload name as "filename.ext, xxx kB"
         var filename = file.name,
            filesize = this.formatFileSize(file.size || 0, 1),
            separator = ", ",
            uploadName = filename + separator + filesize;

         // If filename is too long, adjust
         if (doNotModify) {
            // Leave it unchanged
         } else if (this.useEllipsisForLongFilenames) {
            uploadName = uploadName.split("").reverse().map(this.reverseDirectionalChars, this).join("");
         } else if (uploadName.length > this.maxUploadNameLength) {

            // Calculate how long name can be
            var maxNameLength = this.maxUploadNameLength - filesize.length - separator.length;

            // Create suffix if there's a detectable extension on the filename
            var suffix = "",
               lastDotIndex;
            if ((lastDotIndex = filename.lastIndexOf(".")) !== -1) {
               suffix = filename.substring(lastDotIndex);
               filename = filename.substring(0, lastDotIndex);
               maxNameLength -= suffix.length;
            }

            // Remove the middle of the filename to meet the max length with an ellipsis in it
            var numLetters = Math.floor(maxNameLength / 2);
            filename = filename.substring(0, numLetters) + "..." + filename.substring(filename.length - numLetters);

            // Reconstruct the upload name
            uploadName = filename + suffix + separator + filesize;
         }

         // Pass back the final value
         return uploadName;
      },

      /**
       * This function handles the successful completion of a file upload. By default it moves the
       * displayed file from the "In progress" section to the "Completed" section.
       * 
       * @instance
       * @override
       * @param {string} fileId The unique id of the file
       * @param {object} completionEvt The upload completions event
       * @param {object} request The request object used to attempt to upload the file
       */
      handleCompletedUpload: function alfesco_upload_UploadMonitor__handleCompletedUpload(fileId, /*jshint unused:false*/ completionEvt, /*jshint unused:false*/ request) {
         var upload = this._uploads[fileId];
         if (upload) {

            // Mark as completed and move to successful section
            upload.completed = true;
            upload.nodes.progressBar.parentNode.removeChild(upload.nodes.progressBar);
            upload.nodes.progress.textContent = this.displayUploadPercentage ? "100%" : "";
            domConstruct.place(upload.nodes.row, this.successfulItemsNode, "first");
            domClass.remove(upload.nodes.row, this.baseClass + "__item--finishing");

            // Parse the request to get the information about the resulting nodes that have been created
            // This information could be used to allow actions or links to be generated for the uploaded content
            // before the display is closed...
            if (request && request.responseText) {
               var response = request.responseText;
               try {
                  response = JSON.parse(response);
               } catch (e) {
                  this.alfLog("debug", "Unable to parse upload response as JSON", response);
               }
               upload.actionPayload.response = response;
            }
         } else {
            this.alfLog("warn", "Attempt to mark as complete an upload that is not being tracked (id=" + fileId + ")");
         }
      },

      /**
       * This function handles the failure to upload a file. By default it moves the displayed file
       * from the "In Progress" section to the "Failed" section.
       *
       * @instance
       * @override
       * @param {string} fileId The unique id of the file
       * @param {object} completionEvt The upload completions event
       * @param {object} request The request object used to attempt to upload the file
       */
      handleFailedUpload: function alfesco_upload_UploadMonitor__handleFailedUpload(fileId, /*jshint unused:false*/ failureEvt, request) {

         // Get the upload
         var upload = this._uploads[fileId];
         if (upload) {

            // Get the error message
            var errorMessage = "upload.failure.unknown-reason";
            if (request) {
               if (request.status === 0) {
                  errorMessage = "upload.cancelled";
               } else if (request.statusText) {
                  errorMessage = request.statusText;
               }
            }

            errorMessage = this.message("upload.failure.icon.title", {
               "0": upload.file.name,
               "1": this.message(errorMessage)
            });

            // Move the item to the unsuccessful items section and update the properties accordingly
            upload.completed = true;
            if (upload.nodes.progressBar.parentNode) {
               upload.nodes.progressBar.parentNode.removeChild(upload.nodes.progressBar);
            }
            domConstruct.place(upload.nodes.row, this.unsuccessfulItemsNode, "first");
            upload.nodes.progress.textContent = "";
            
            var processId = Date.now() + "_actionWidgets";
            var widgets = lang.clone(this.widgetsForErrorIcon);
            array.forEach(widgets, function(widget) {
               widget.config = lang.mixin({
                  title: errorMessage,
                  description: this.message("upload.failure.icon.description")
               }, widget.config);
            }, this);
            domConstruct.empty(upload.nodes.errorIcon);
            this.processWidgets(widgets, upload.nodes.errorIcon, processId);
            
            domClass.add(upload.nodes.row, this.baseClass + "__item--has-error");

         } else {
            this.alfLog("warn", "Attempt to mark as failed an upload that is not being tracked (id=" + fileId + ")");
         }
      },

      /**
       * Handle modification requests for a specific item
       *
       * @instance
       * @param {Object} payload The published payload
       * @since 1.0.65
       */
      handleModifyItem: function alfesco_upload_UploadMonitor__handleModifyItem(payload) {
         var upload;
         if (payload && payload.uploadId && (upload = this._uploads[payload.uploadId]) && payload.action) {
            switch (payload.action) {
               case "REMOVE":
                  var uploadRow = upload.nodes.row;
                  uploadRow.parentNode.removeChild(uploadRow);
                  break;
               default:
                  this.alfLog("warn", "Invalid action requested for modifying item: ", payload.action);
                  break;
            }
         }
      },

      /**
       * Resets the display.
       *
       * @instance
       * @override
       */
      reset: function alfresco_upload_AlfUploadDisplay__reset() {
         this.uploads = {};
         domConstruct.empty(this.inProgressItemsNode);
         domConstruct.empty(this.successfulItemsNode);
         domConstruct.empty(this.unsuccessfulItemsNode);
      },

      /**
       * Reverse any directional characters (e.g. brackets)
       *
       * @instance
       * @param {String} nextChar The next character to be checked
       * @returns {String} The replaced or original character
       * @since 1.0.79
       */
      reverseDirectionalChars: function alfresco_upload_AlfUploadDisplay__reverseDirectionalChars(nextChar) {
         var code = nextChar.charCodeAt(0),
            reverseCode = this.reverseChars[code];
         return reverseCode ? String.fromCharCode(reverseCode) : nextChar;
      },

      /**
       * Setup the reverse-chars code lookup array (one-time run)
       *
       * @instance
       * @since 1.0.79
       */
      setupReverseChars: function alfresco_upload_AlfUploadDisplay__setupReverseChars() {
         this.reverseChars = this.reverseChars.reduce(function(arr, nextChars) {
            var fromCode = nextChars.charCodeAt(0),
              toCode = nextChars.charCodeAt(1);
            arr[fromCode] = toCode;
            arr[toCode] = fromCode;
            return arr;
         }, []);
      },

      /**
       * Displays the overall upload progress of all the files.
       *
       * @instance
       * @override
       * @param {number} aggregateProgress The aggregate progress as a decimal of 1.
       */
      updateAggregateProgress: function alfesco_upload_UploadMonitor__updateAggregateProgress( /*jshint unused:false*/ aggregateProgress) {
         // NOOP currently
      },

      /**
       * Updates the displayed progress for an individual file upload.
       *
       * @instance
       * @override
       * @param {string} fileId The unique id of the file
       * @param {number} percentageComplete The current upload progress as a percentage
       */
      updateUploadProgress: function alfesco_upload_UploadMonitor__updateUploadProgress(fileId, percentageComplete) {
         if (!this.displayUploadPercentage) {
            return;
         }
         var upload = this._uploads[fileId];
         if (upload) {
            if (!upload.completed) {
               domStyle.set(upload.nodes.progressBar, "width", percentageComplete + "%");
               upload.nodes.progress.textContent = percentageComplete + "%";
               if (percentageComplete === 100) {
                  domClass.add(upload.nodes.row, this.baseClass + "__item--finishing");
               }
            }
         } else {
            this.alfLog("warn", "Attempt to update upload that is not being tracked (id=" + fileId + ")");
         }
      }
   });
});