Browse Source

Merge pull request #202 from goto-bus-stop/feature/progressbar

Add progress API & display for pre- and postprocessors
Renée Kooi 7 years ago
parent
commit
4e164deb63

+ 62 - 12
src/core/Core.js

@@ -189,12 +189,13 @@ class Uppy {
     this.bus.emit('file-added', fileID)
     this.log(`Added file: ${fileName}, ${fileID}, mime type: ${fileType}`)
 
-    if (this.opts.autoProceed) {
-      this.upload()
-        .catch((err) => {
+    if (this.opts.autoProceed && !this.scheduledAutoProceed) {
+      this.scheduledAutoProceed = setTimeout(() => {
+        this.scheduledAutoProceed = null
+        this.upload().catch((err) => {
           console.error(err.stack || err.message)
         })
-      // this.bus.emit('core:upload')
+      }, 4)
     }
   }
 
@@ -352,6 +353,48 @@ class Uppy {
       this.updateMeta(data, fileID)
     })
 
+    this.on('core:preprocess-progress', (fileID, progress) => {
+      const files = Object.assign({}, this.getState().files)
+      files[fileID] = Object.assign({}, files[fileID], {
+        progress: Object.assign({}, files[fileID].progress, {
+          preprocess: progress
+        })
+      })
+
+      this.setState({ files: files })
+    })
+    this.on('core:preprocess-complete', (fileID) => {
+      const files = Object.assign({}, this.getState().files)
+      files[fileID] = Object.assign({}, files[fileID], {
+        progress: Object.assign({}, files[fileID].progress)
+      })
+      delete files[fileID].progress.preprocess
+
+      this.setState({ files: files })
+    })
+    this.on('core:postprocess-progress', (fileID, progress) => {
+      const files = Object.assign({}, this.getState().files)
+      files[fileID] = Object.assign({}, files[fileID], {
+        progress: Object.assign({}, files[fileID].progress, {
+          postprocess: progress
+        })
+      })
+
+      this.setState({ files: files })
+    })
+    this.on('core:postprocess-complete', (fileID) => {
+      const files = Object.assign({}, this.getState().files)
+      files[fileID] = Object.assign({}, files[fileID], {
+        progress: Object.assign({}, files[fileID].progress)
+      })
+      delete files[fileID].progress.postprocess
+      // TODO should we set some kind of `fullyComplete` property on the file object
+      // so it's easier to see that the file is upload…fully complete…rather than
+      // what we have to do now (`uploadComplete && !postprocess`)
+
+      this.setState({ files: files })
+    })
+
     // show informer if offline
     if (typeof window !== 'undefined') {
       window.addEventListener('online', () => this.isOnline(true))
@@ -532,18 +575,25 @@ class Uppy {
   }
 
   upload () {
-    let promise = Promise.resolve()
-
     this.emit('core:upload')
 
-    ;[].concat(
-      this.preProcessors,
-      this.uploaders,
-      this.postProcessors
-    ).forEach((fn) => {
-      promise = promise.then(() => fn())
+    const waitingFileIDs = []
+    Object.keys(this.state.files).forEach((fileID) => {
+      const file = this.state.files[fileID]
+      // TODO: replace files[file].isRemote with some logic
+      //
+      // filter files that are now yet being uploaded / haven’t been uploaded
+      // and remote too
+      if (!file.progress.uploadStarted || file.isRemote) {
+        waitingFileIDs.push(file.id)
+      }
     })
 
+    const promise = Utils.runPromiseSequence(
+      [...this.preProcessors, ...this.uploaders, ...this.postProcessors],
+      waitingFileIDs
+    )
+
     // Not returning the `catch`ed promise, because we still want to return a rejected
     // promise from this method if the upload failed.
     promise.catch((err) => {

+ 12 - 0
src/core/Utils.js

@@ -122,6 +122,17 @@ function extend (...objs) {
   return Object.assign.apply(this, [{}].concat(objs))
 }
 
+/**
+ * Runs an array of promise-returning functions in sequence.
+ */
+function runPromiseSequence (functions, ...args) {
+  let promise = Promise.resolve()
+  functions.forEach((func) => {
+    promise = promise.then(() => func(...args))
+  })
+  return promise
+}
+
 /**
  * Takes function or class, returns its name.
  * Because IE doesn’t support `constructor.name`.
@@ -395,6 +406,7 @@ module.exports = {
   // $,
   // $$,
   extend,
+  runPromiseSequence,
   supportsMediaRecorder,
   isTouchDevice,
   getFileNameAndExtension,

+ 1 - 0
src/plugins/Dashboard/Dashboard.js

@@ -159,6 +159,7 @@ module.exports = function Dashboard (props) {
             totalETA: props.totalETA,
             startUpload: props.startUpload,
             newFileCount: props.newFiles.length,
+            files: props.files,
             i18n: props.i18n,
             resumableUploads: props.resumableUploads
           })}

+ 115 - 25
src/plugins/Dashboard/StatusBar.js

@@ -1,44 +1,134 @@
 const html = require('yo-yo')
 const throttle = require('lodash.throttle')
 
-function progressBarWidth (props) {
-  return props.totalProgress
-}
-
 function progressDetails (props) {
-  // console.log(Date.now())
   return html`<span>${props.totalProgress || 0}%・${props.complete} / ${props.inProgress}・${props.totalUploadedSize} / ${props.totalSize}・↑ ${props.totalSpeed}/s・${props.totalETA}</span>`
 }
 
 const throttledProgressDetails = throttle(progressDetails, 1000, {leading: true, trailing: true})
-// const throttledProgressBarWidth = throttle(progressBarWidth, 300, {leading: true, trailing: true})
+
+const STATE_WAITING = 'waiting'
+const STATE_PREPROCESSING = 'preprocessing'
+const STATE_UPLOADING = 'uploading'
+const STATE_POSTPROCESSING = 'postprocessing'
+const STATE_COMPLETE = 'complete'
+
+function getUploadingState (props, files) {
+  // If ALL files have been completed, show the completed state.
+  if (props.isAllComplete) {
+    return STATE_COMPLETE
+  }
+
+  let state = STATE_WAITING
+  const fileIDs = Object.keys(files)
+  for (let i = 0; i < fileIDs.length; i++) {
+    const progress = files[fileIDs[i]].progress
+    // If ANY files are being uploaded right now, show the uploading state.
+    if (progress.uploadStarted && !progress.uploadComplete) {
+      return STATE_UPLOADING
+    }
+    // If files are being preprocessed AND postprocessed at this time, we show the
+    // preprocess state. If any files are being uploaded we show uploading.
+    if (progress.preprocess && state !== STATE_UPLOADING) {
+      state = STATE_PREPROCESSING
+    }
+    // If NO files are being preprocessed or uploaded right now, but some files are
+    // being postprocessed, show the postprocess state.
+    if (progress.postprocess && state !== STATE_UPLOADING && state !== STATE_PREPROCESSING) {
+      state = STATE_POSTPROCESSING
+    }
+  }
+  return state
+}
 
 module.exports = (props) => {
   props = props || {}
 
-  const isHidden = props.totalFileCount === 0 || !props.isUploadStarted
+  const uploadState = getUploadingState(props, props.files || {})
+
+  let progressValue = props.totalProgress
+  let progressMode
+  let progressBarContent
+  if (uploadState === STATE_PREPROCESSING || uploadState === STATE_POSTPROCESSING) {
+    // TODO set progressValue and progressMode depending on the actual pre/postprocess
+    // progress state
+    progressMode = 'indeterminate'
+    progressValue = undefined
+
+    progressBarContent = ProgressBarProcessing(props)
+  } else if (uploadState === STATE_COMPLETE) {
+    progressBarContent = ProgressBarComplete(props)
+  } else if (uploadState === STATE_UPLOADING) {
+    progressBarContent = ProgressBarUploading(props)
+  }
+
+  const width = typeof progressValue === 'number' ? progressValue : 100
 
   return html`
-    <div class="UppyDashboard-statusBar
-                ${props.isAllComplete ? 'is-complete' : ''}"
-                aria-hidden="${isHidden}"
+    <div class="UppyDashboard-statusBar is-${uploadState}"
+                aria-hidden="${uploadState === STATE_WAITING}"
                 title="">
-      <progress style="display: none;" min="0" max="100" value="${props.totalProgress}"></progress>
-      <div class="UppyDashboard-statusBarProgress" style="width: ${progressBarWidth(props)}%"></div>
-      <div class="UppyDashboard-statusBarContent">
-        ${props.isUploadStarted && !props.isAllComplete
-          ? !props.isAllPaused
-            ? html`<span title="Uploading">${pauseResumeButtons(props)} Uploading... ${throttledProgressDetails(props)}</span>`
-            : html`<span title="Paused">${pauseResumeButtons(props)} Paused・${props.totalProgress}%</span>`
-          : null
-          }
-        ${props.isAllComplete
-          ? html`<span title="Complete"><svg class="UppyDashboard-statusBarAction UppyIcon" width="18" height="17" viewBox="0 0 23 17">
-              <path d="M8.944 17L0 7.865l2.555-2.61 6.39 6.525L20.41 0 23 2.645z" />
-            </svg>Upload complete・${props.totalProgress}%</span>`
-          : null
+      <progress style="display: none;" min="0" max="100" value=${progressValue}></progress>
+      <div class="UppyDashboard-statusBarProgress ${progressMode ? `is-${progressMode}` : ''}"
+           style="width: ${width}%"></div>
+      ${progressBarContent}
+    </div>
+  `
+}
+
+const ProgressBarProcessing = (props) => {
+  // Collect pre or postprocessing progress states.
+  const progresses = []
+  Object.keys(props.files).forEach((fileID) => {
+    const { progress } = props.files[fileID]
+    if (progress.preprocess) {
+      progresses.push(progress.preprocess)
+    }
+    if (progress.postprocess) {
+      progresses.push(progress.postprocess)
+    }
+  })
+
+  // In the future we should probably do this differently. For now we'll take the
+  // mode and message from the first file…
+  const { mode, message } = progresses[0]
+  const value = progresses.filter(isDeterminate).reduce((total, progress, all) => {
+    return total + progress.value / all.length
+  }, 0)
+  function isDeterminate (progress) {
+    return progress.mode === 'determinate'
+  }
+
+  return html`
+    <div class="UppyDashboard-statusBarContent">
+      ${mode === 'determinate' ? `${value * 100}%・` : ''}
+      ${message}
+    </div>
+  `
+}
+
+const ProgressBarUploading = (props) => {
+  return html`
+    <div class="UppyDashboard-statusBarContent">
+      ${props.isUploadStarted && !props.isAllComplete
+        ? !props.isAllPaused
+          ? html`<span title="Uploading">${pauseResumeButtons(props)} Uploading... ${throttledProgressDetails(props)}</span>`
+          : html`<span title="Paused">${pauseResumeButtons(props)} Paused・${props.totalProgress}%</span>`
+        : null
         }
-      </div>
+    </div>
+  `
+}
+
+const ProgressBarComplete = ({ totalProgress }) => {
+  return html`
+    <div class="UppyDashboard-statusBarContent">
+      <span title="Complete">
+        <svg class="UppyDashboard-statusBarAction UppyIcon" width="18" height="17" viewBox="0 0 23 17">
+          <path d="M8.944 17L0 7.865l2.555-2.61 6.39 6.525L20.41 0 23 2.645z" />
+        </svg>
+        Upload complete・${totalProgress}%
+      </span>
     </div>
   `
 }

+ 6 - 1
src/plugins/Dashboard/index.js

@@ -317,6 +317,9 @@ module.exports = class DashboardUI extends Plugin {
              files[file].progress.uploadStarted &&
              !files[file].isPaused
     })
+    const processingFiles = Object.keys(files).filter((file) => {
+      return files[file].progress.preprocess || files[file].progress.postprocess
+    })
 
     let inProgressFilesArray = []
     inProgressFiles.forEach((file) => {
@@ -336,7 +339,9 @@ module.exports = class DashboardUI extends Plugin {
     totalSize = prettyBytes(totalSize)
     totalUploadedSize = prettyBytes(totalUploadedSize)
 
-    const isAllComplete = state.totalProgress === 100
+    const isAllComplete = state.totalProgress === 100 &&
+      completeFiles.length === Object.keys(files).length &&
+      processingFiles.length === 0
     const isAllPaused = inProgressFiles.length === 0 && !isAllComplete && uploadStartedFiles.length > 0
     const isUploadStarted = uploadStartedFiles.length > 0
 

+ 7 - 11
src/plugins/Multipart.js

@@ -150,16 +150,9 @@ module.exports = class Multipart extends Plugin {
       return
     }
 
-    const filesForUpload = []
-    Object.keys(files).forEach((file) => {
-      if (!files[file].progress.uploadStarted || files[file].isRemote) {
-        filesForUpload.push(files[file])
-      }
-    })
-
-    filesForUpload.forEach((file, i) => {
+    files.forEach((file, i) => {
       const current = parseInt(i, 10) + 1
-      const total = filesForUpload.length
+      const total = files.length
 
       if (file.isRemote) {
         this.uploadRemote(file, current, total)
@@ -177,9 +170,12 @@ module.exports = class Multipart extends Plugin {
     //   }
   }
 
-  handleUpload () {
+  handleUpload (fileIDs) {
     this.core.log('Multipart is uploading...')
-    const files = this.core.getState().files
+    const files = fileIDs.map(getFile, this)
+    function getFile (fileID) {
+      return this.core.state.files[fileID]
+    }
 
     this.selectForUpload(files)
 

+ 111 - 53
src/plugins/Transloadit/index.js

@@ -59,26 +59,23 @@ module.exports = class Transloadit extends Plugin {
     }
 
     this.client = new Client()
+    this.sockets = {}
   }
 
-  createAssembly () {
+  createAssembly (filesToUpload) {
     this.core.log('Transloadit: create assembly')
 
-    const files = this.core.state.files
-    const expectedFiles = Object.keys(files).reduce((count, fileID) => {
-      if (!files[fileID].progress.uploadStarted || files[fileID].isRemote) {
-        return count + 1
-      }
-      return count
-    }, 0)
-
     return this.client.createAssembly({
       params: this.opts.params,
       fields: this.opts.fields,
-      expectedFiles,
+      expectedFiles: Object.keys(filesToUpload).length,
       signature: this.opts.signature
     }).then((assembly) => {
-      this.updateState({ assembly })
+      this.updateState({
+        assemblies: Object.assign(this.state.assemblies, {
+          [assembly.assembly_id]: assembly
+        })
+      })
 
       function attachAssemblyMetadata (file, assembly) {
         // Attach meta parameters for the Tus plugin. See:
@@ -95,22 +92,24 @@ module.exports = class Transloadit extends Plugin {
         const tus = Object.assign({}, file.tus, {
           endpoint: assembly.tus_url
         })
+        const transloadit = {
+          assembly: assembly.assembly_id
+        }
         return Object.assign(
           {},
           file,
-          { meta, tus }
+          { meta, tus, transloadit }
         )
       }
 
-      const filesObj = this.core.state.files
-      const files = {}
-      Object.keys(filesObj).forEach((id) => {
-        files[id] = attachAssemblyMetadata(filesObj[id], assembly)
+      const files = Object.assign({}, this.core.state.files)
+      Object.keys(filesToUpload).forEach((id) => {
+        files[id] = attachAssemblyMetadata(files[id], assembly)
       })
 
       this.core.setState({ files })
 
-      return this.connectSocket()
+      return this.connectSocket(assembly)
     }).then(() => {
       this.core.log('Transloadit: Created assembly')
     }).catch((err) => {
@@ -161,66 +160,125 @@ module.exports = class Transloadit extends Plugin {
     this.core.bus.emit('transloadit:result', stepName, result)
   }
 
-  connectSocket () {
-    this.socket = new StatusSocket(
-      this.state.assembly.websocket_url,
-      this.state.assembly
+  connectSocket (assembly) {
+    const socket = new StatusSocket(
+      assembly.websocket_url,
+      assembly
     )
+    this.sockets[assembly.assembly_id] = socket
 
-    this.socket.on('upload', this.onFileUploadComplete.bind(this))
+    socket.on('upload', this.onFileUploadComplete.bind(this))
+    socket.on('error', (error) => {
+      this.core.emit('transloadit:assembly-error', assembly, error)
+    })
 
     if (this.opts.waitForEncoding) {
-      this.socket.on('result', this.onResult.bind(this))
+      socket.on('result', this.onResult.bind(this))
     }
 
-    this.assemblyReady = new Promise((resolve, reject) => {
-      if (this.opts.waitForEncoding) {
-        this.socket.on('finished', resolve)
-      } else if (this.opts.waitForMetadata) {
-        this.socket.on('metadata', resolve)
-      }
-      this.socket.on('error', reject)
-    })
+    if (this.opts.waitForEncoding) {
+      socket.on('finished', () => {
+        this.core.emit('transloadit:complete', assembly)
+      })
+    } else if (this.opts.waitForMetadata) {
+      socket.on('metadata', () => {
+        this.core.emit('transloadit:complete', assembly)
+      })
+    }
 
     return new Promise((resolve, reject) => {
-      this.socket.on('connect', resolve)
-      this.socket.on('error', reject)
+      socket.on('connect', resolve)
+      socket.on('error', reject)
     }).then(() => {
       this.core.log('Transloadit: Socket is ready')
     })
   }
 
-  prepareUpload () {
-    this.core.emit('informer', this.opts.locale.strings.creatingAssembly, 'info', 0)
-    return this.createAssembly().then(() => {
-      this.core.emit('informer:hide')
+  prepareUpload (fileIDs) {
+    const filesToUpload = fileIDs.map(getFile, this).reduce(intoFileMap, {})
+    function getFile (fileID) {
+      return this.core.state.files[fileID]
+    }
+    function intoFileMap (map, file) {
+      map[file.id] = file
+      return map
+    }
+
+    fileIDs.forEach((fileID) => {
+      this.core.emit('core:preprocess-progress', fileID, {
+        mode: 'indeterminate',
+        message: this.opts.locale.strings.creatingAssembly
+      })
+    })
+    return this.createAssembly(filesToUpload).then(() => {
+      fileIDs.forEach((fileID) => {
+        this.core.emit('core:preprocess-complete', fileID)
+      })
     })
   }
 
-  afterUpload () {
+  afterUpload (fileIDs) {
+    // A file ID that is part of this assembly...
+    const fileID = fileIDs[0]
+
     // If we don't have to wait for encoding metadata or results, we can close
     // the socket immediately and finish the upload.
     if (!this.shouldWait()) {
-      this.socket.close()
+      const file = this.core.getState().files[fileID]
+      const socket = this.socket[file.assembly]
+      socket.close()
       return
     }
 
-    this.core.emit('informer', this.opts.locale.strings.encoding, 'info', 0)
-    return this.assemblyReady.then(() => {
-      return this.client.getAssemblyStatus(this.state.assembly.assembly_ssl_url)
-    }).then((assembly) => {
-      this.updateState({ assembly })
+    return new Promise((resolve, reject) => {
+      fileIDs.forEach((fileID) => {
+        this.core.emit('core:postprocess-progress', fileID, {
+          mode: 'indeterminate',
+          message: this.opts.locale.strings.encoding
+        })
+      })
 
-      // TODO set the `file.uploadURL` to a result?
-      // We will probably need an option here so the plugin user can tell us
-      // which result to pick…?
+      const onAssemblyFinished = (assembly) => {
+        const file = this.core.state.files[fileID]
+        // An assembly for a different upload just finished. We can ignore it.
+        if (assembly.assembly_id !== file.transloadit.assembly) {
+          return
+        }
+        // Remove this handler once we find the assembly we needed.
+        this.core.emitter.off('transloadit:complete', onAssemblyFinished)
+
+        this.client.getAssemblyStatus(assembly.assembly_ssl_url).then((assembly) => {
+          this.updateState({
+            assemblies: Object.assign({}, this.state.assemblies, {
+              [assembly.assembly_id]: assembly
+            })
+          })
+
+          // TODO set the `file.uploadURL` to a result?
+          // We will probably need an option here so the plugin user can tell us
+          // which result to pick…?
+
+          fileIDs.forEach((fileID) => {
+            this.core.emit('core:postprocess-complete', fileID)
+          })
+        }).then(resolve, reject)
+      }
 
-      this.core.emit('informer:hide')
-    }).catch((err) => {
-      // Always hide the Informer
-      this.core.emit('informer:hide')
+      const onAssemblyError = (assembly, error) => {
+        const file = this.core.state.files[fileID]
+        // An assembly for a different upload just errored. We can ignore it.
+        if (assembly.assembly_id !== file.transloadit.assembly) {
+          return
+        }
+        // Remove this handler once we find the assembly we needed.
+        this.core.emitter.off('transloadit:assembly-error', onAssemblyError)
 
-      throw err
+        // Reject the `afterUpload()` promise.
+        reject(error)
+      }
+
+      this.core.on('transloadit:complete', onAssemblyFinished)
+      this.core.on('transloadit:assembly-error', onAssemblyError)
     })
   }
 
@@ -229,7 +287,7 @@ module.exports = class Transloadit extends Plugin {
     this.core.addPostProcessor(this.afterUpload)
 
     this.updateState({
-      assembly: null,
+      assemblies: {},
       files: {},
       results: []
     })

+ 6 - 20
src/plugins/Tus10.js

@@ -298,28 +298,14 @@ module.exports = class Tus10 extends Plugin {
     })
   }
 
-  selectForUpload (files) {
-    // TODO: replace files[file].isRemote with some logic
-    //
-    // filter files that are now yet being uploaded / haven’t been uploaded
-    // and remote too
-    const filesForUpload = Object.keys(files).filter((file) => {
-      if (!files[file].progress.uploadStarted || files[file].isRemote) {
-        return true
-      }
-      return false
-    }).map((file) => {
-      return files[file]
-    })
-
-    this.uploadFiles(filesForUpload)
-  }
-
-  handleUpload () {
+  handleUpload (fileIDs) {
     this.core.log('Tus is uploading...')
-    const files = this.core.getState().files
+    const filesToUpload = fileIDs.map(getFile, this)
+    function getFile (fileID) {
+      return this.core.state.files[fileID]
+    }
 
-    this.selectForUpload(files)
+    this.uploadFiles(filesToUpload)
 
     return new Promise((resolve) => {
       this.core.bus.once('core:upload-complete', resolve)

+ 13 - 1
src/scss/_dashboard.scss

@@ -1133,7 +1133,19 @@
   height: 100%;
   position: absolute;
   z-index: $zIndex-2;
-  transition: all .3s ease-out;
+  transition: background-color, width .3s ease-out;
+
+  &.is-indeterminate {
+    $stripe-color: darken($color-cornflower-blue, 10%);
+    background-size: 64px 64px;
+    background-image: linear-gradient(45deg, $stripe-color 25%, transparent 25%, transparent 50%, $stripe-color 50%, $stripe-color 75%, transparent 75%, transparent);
+    animation: statusBarProgressStripes 1s linear infinite;
+  }
+}
+
+@keyframes statusBarProgressStripes {
+  from { background-position: 64px 0; }
+  to { background-position: 0 0; }
 }
 
 .UppyDashboard-statusBarContent {