Browse Source

Merge pull request #1 from transloadit/master

Merge
Richard Willars 7 years ago
parent
commit
ef477ff258

+ 3 - 3
CHANGELOG.md

@@ -47,6 +47,7 @@ Ideas that will be planned and find their way into a release at one point
 - [ ] possibility to edit/delete more than one file at once #118, #97
 - [ ] possibility to edit/delete more than one file at once #118, #97
 - [ ] optimize problematic filenames #72
 - [ ] optimize problematic filenames #72
 - [ ] an uploader plugin to receive files in a callback instead of uploading them
 - [ ] an uploader plugin to receive files in a callback instead of uploading them
+- [ ] core: calling `upload` immediately after `addFile` does not upload all files (#249 @goto-bus-stop)
 
 
 ## 1.0 Goals
 ## 1.0 Goals
 
 
@@ -55,7 +56,7 @@ What we need to do to release Uppy 1.0
 - [x] feature: restrictions: by size, number of files, file type
 - [x] feature: restrictions: by size, number of files, file type
 - [x] feature: beta file recovering after closed tab / browser crash
 - [x] feature: beta file recovering after closed tab / browser crash
 - [ ] feature: improved UI for Provider, Google Drive and Instagram, grid/list views
 - [ ] feature: improved UI for Provider, Google Drive and Instagram, grid/list views
-- [ ] feature: finish the direct-to-s3 upload plugin and test it with the flow to then upload to :transloadit: afterwards. This is because this might influence the inner flow of the plugin architecture quite a bit
+- [x] feature: finish the direct-to-s3 upload plugin and test it with the flow to then upload to :transloadit: afterwards. This is because this might influence the inner flow of the plugin architecture quite a bit
 - [ ] feature: Uppy should work well with React/Redux and React Native
 - [ ] feature: Uppy should work well with React/Redux and React Native
 - [ ] feature: preset for Transloadit that mimics jQuery SDK
 - [ ] feature: preset for Transloadit that mimics jQuery SDK
 - [ ] QA: test how everything works together: user experience from `npm install` to production build with Webpack, using in React/Redux environment (npm pack)
 - [ ] QA: test how everything works together: user experience from `npm install` to production build with Webpack, using in React/Redux environment (npm pack)
@@ -95,9 +96,8 @@ Theme: React and Retry
 
 
 - [ ] core: add error in file progress state? error UI, question mark button, `core:error` (@arturi)
 - [ ] core: add error in file progress state? error UI, question mark button, `core:error` (@arturi)
 - [ ] core: retry or show error when upload can’t start / fails (offline, wrong endpoint) — now it just sits there (@arturi @goto-bus-stop)
 - [ ] core: retry or show error when upload can’t start / fails (offline, wrong endpoint) — now it just sits there (@arturi @goto-bus-stop)
-- [ ] core: calling `upload` immediately after `addFile` does not upload all files (#249 @goto-bus-stop)
 - [ ] core: React / Redux PRs (@arturi @goto-bus-stop)
 - [ ] core: React / Redux PRs (@arturi @goto-bus-stop)
-- [ ] transloadit: upload to S3, then import into :tl: assembly using `/add_file?s3url=${url}` (@goto-bus-stop)
+- [x] transloadit: upload to S3, then import into :tl: assembly using `/add_file?s3url=${url}` (@goto-bus-stop)
 - [ ] goldenretriver: add “ghost” files (@arturi @goto-bus-stop)
 - [ ] goldenretriver: add “ghost” files (@arturi @goto-bus-stop)
 - [ ] dashboard: cancel button for any kind of uploads? currently resume/pause only for tus, and cancel for XHR (@arturi @goto-bus-stop)
 - [ ] dashboard: cancel button for any kind of uploads? currently resume/pause only for tus, and cancel for XHR (@arturi @goto-bus-stop)
 - [x] informer: support “explanations”, a (?) button that shows more info on hover / click
 - [x] informer: support “explanations”, a (?) button that shows more info on hover / click

+ 14 - 11
src/core/Core.js

@@ -72,6 +72,7 @@ class Uppy {
     this.initSocket = this.initSocket.bind(this)
     this.initSocket = this.initSocket.bind(this)
     this.log = this.log.bind(this)
     this.log = this.log.bind(this)
     this.addFile = this.addFile.bind(this)
     this.addFile = this.addFile.bind(this)
+    this.removeFile = this.removeFile.bind(this)
     this.calculateProgress = this.calculateProgress.bind(this)
     this.calculateProgress = this.calculateProgress.bind(this)
     this.resetProgress = this.resetProgress.bind(this)
     this.resetProgress = this.resetProgress.bind(this)
 
 
@@ -115,10 +116,8 @@ class Uppy {
    *
    *
    */
    */
   updateAll (state) {
   updateAll (state) {
-    Object.keys(this.plugins).forEach((pluginType) => {
-      this.plugins[pluginType].forEach((plugin) => {
-        plugin.update(state)
-      })
+    this.iteratePlugins(plugin => {
+      plugin.update(state)
     })
     })
   }
   }
 
 
@@ -444,7 +443,11 @@ class Uppy {
 
 
     this.on('core:upload-error', (fileID, error) => {
     this.on('core:upload-error', (fileID, error) => {
       const fileName = this.state.files[fileID].name
       const fileName = this.state.files[fileID].name
-      this.info(`Failed to upload: ${fileName}`, 'error', 5000)
+      let message = `Failed to upload ${fileName}`
+      if (typeof error === 'object' && error.message) {
+        message = `${message}: ${error.message}`
+      }
+      this.info(message, 'error', 5000)
     })
     })
 
 
     this.on('core:upload', () => {
     this.on('core:upload', () => {
@@ -609,21 +612,21 @@ class Uppy {
 
 
     // Instantiate
     // Instantiate
     const plugin = new Plugin(this, opts)
     const plugin = new Plugin(this, opts)
-    const pluginName = plugin.id
+    const pluginId = plugin.id
     this.plugins[plugin.type] = this.plugins[plugin.type] || []
     this.plugins[plugin.type] = this.plugins[plugin.type] || []
 
 
-    if (!pluginName) {
-      throw new Error('Your plugin must have a name')
+    if (!pluginId) {
+      throw new Error('Your plugin must have an id')
     }
     }
 
 
     if (!plugin.type) {
     if (!plugin.type) {
       throw new Error('Your plugin must have a type')
       throw new Error('Your plugin must have a type')
     }
     }
 
 
-    let existsPluginAlready = this.getPlugin(pluginName)
+    let existsPluginAlready = this.getPlugin(pluginId)
     if (existsPluginAlready) {
     if (existsPluginAlready) {
-      let msg = `Already found a plugin named '${existsPluginAlready.name}'.
-        Tried to use: '${pluginName}'.
+      let msg = `Already found a plugin named '${existsPluginAlready.id}'.
+        Tried to use: '${pluginId}'.
         Uppy is currently limited to running one of every plugin.
         Uppy is currently limited to running one of every plugin.
         Share your use case with us over at
         Share your use case with us over at
         https://github.com/transloadit/uppy/issues/
         https://github.com/transloadit/uppy/issues/

+ 8 - 0
src/plugins/AwsS3/index.js

@@ -66,6 +66,14 @@ module.exports = class AwsS3 extends Plugin {
             key: getValue('Key'),
             key: getValue('Key'),
             etag: getValue('ETag')
             etag: getValue('ETag')
           }
           }
+        },
+        getResponseError (xhr) {
+          // If no response, we don't have a specific error message, use the default.
+          if (!xhr.responseXML) {
+            return
+          }
+          const error = xhr.responseXML.querySelector('Error > Message')
+          return new Error(error.textContent)
         }
         }
       })
       })
     })
     })

+ 11 - 7
src/plugins/Dashboard/FileCard.js

@@ -6,9 +6,13 @@ module.exports = function fileCard (props) {
   const file = props.fileCardFor ? props.files[props.fileCardFor] : false
   const file = props.fileCardFor ? props.files[props.fileCardFor] : false
   const meta = {}
   const meta = {}
 
 
-  function tempStoreMeta (ev) {
+  const tempStoreMetaOrSubmit = (ev) => {
+    if (ev.keyCode === 13) {
+      props.done(meta, file.id)
+    }
+
     const value = ev.target.value
     const value = ev.target.value
-    const name = ev.target.attributes.name.value
+    const name = ev.target.dataset.name
     meta[name] = value
     meta[name] = value
   }
   }
 
 
@@ -18,11 +22,11 @@ module.exports = function fileCard (props) {
       return html`<fieldset class="UppyDashboardFileCard-fieldset">
       return html`<fieldset class="UppyDashboardFileCard-fieldset">
         <label class="UppyDashboardFileCard-label">${field.name}</label>
         <label class="UppyDashboardFileCard-label">${field.name}</label>
         <input class="UppyDashboardFileCard-input"
         <input class="UppyDashboardFileCard-input"
-               name="${field.id}"
                type="text"
                type="text"
+               data-name="${field.id}"
                value="${file.meta[field.id]}"
                value="${file.meta[field.id]}"
                placeholder="${field.placeholder || ''}"
                placeholder="${field.placeholder || ''}"
-               onkeyup=${tempStoreMeta} /></fieldset>`
+               onkeyup=${tempStoreMetaOrSubmit} /></fieldset>`
     })
     })
   }
   }
 
 
@@ -46,8 +50,8 @@ module.exports = function fileCard (props) {
           <div class="UppyDashboardFileCard-info">
           <div class="UppyDashboardFileCard-info">
             <fieldset class="UppyDashboardFileCard-fieldset">
             <fieldset class="UppyDashboardFileCard-fieldset">
               <label class="UppyDashboardFileCard-label">Name</label>
               <label class="UppyDashboardFileCard-label">Name</label>
-              <input class="UppyDashboardFileCard-input" name="name" type="text" value="${file.meta.name}"
-                     onkeyup=${tempStoreMeta} />
+              <input class="UppyDashboardFileCard-input" data-name="name" type="text" value="${file.meta.name}"
+                     onkeyup=${tempStoreMetaOrSubmit} />
             </fieldset>
             </fieldset>
             ${renderMetaFields(file)}
             ${renderMetaFields(file)}
           </div>
           </div>
@@ -60,5 +64,5 @@ module.exports = function fileCard (props) {
               title="Finish editing file"
               title="Finish editing file"
               onclick=${() => props.done(meta, file.id)}>${checkIcon()}</button>
               onclick=${() => props.done(meta, file.id)}>${checkIcon()}</button>
     </div>
     </div>
-    </div>`
+  </div>`
 }
 }

+ 10 - 18
src/plugins/Dashboard/index.js

@@ -249,7 +249,7 @@ module.exports = class DashboardUI extends Plugin {
   }
   }
 
 
   handleFileCard (fileId) {
   handleFileCard (fileId) {
-    const modal = this.core.getState().modal
+    const modal = this.core.state.modal
 
 
     this.core.setState({
     this.core.setState({
       modal: Object.assign({}, modal, {
       modal: Object.assign({}, modal, {
@@ -318,44 +318,36 @@ module.exports = class DashboardUI extends Plugin {
       return target.type === 'progressindicator'
       return target.type === 'progressindicator'
     })
     })
 
 
-    // const addFile = (file) => {
-    //   this.core.emitter.emit('core:file-add', file)
-    // }
-
-    const removeFile = (fileID) => {
-      this.core.emitter.emit('core:file-remove', fileID)
-    }
-
     const startUpload = (ev) => {
     const startUpload = (ev) => {
       this.core.upload().catch((err) => {
       this.core.upload().catch((err) => {
         // Log error.
         // Log error.
-        console.error(err.stack || err.message || err)
+        this.core.log(err.stack || err.message || err)
       })
       })
     }
     }
 
 
     const pauseUpload = (fileID) => {
     const pauseUpload = (fileID) => {
-      this.core.emitter.emit('core:upload-pause', fileID)
+      this.core.emit.emit('core:upload-pause', fileID)
     }
     }
 
 
     const cancelUpload = (fileID) => {
     const cancelUpload = (fileID) => {
-      this.core.emitter.emit('core:upload-cancel', fileID)
-      this.core.emitter.emit('core:file-remove', fileID)
+      this.core.emit('core:upload-cancel', fileID)
+      this.core.emit('core:file-remove', fileID)
     }
     }
 
 
     const showFileCard = (fileID) => {
     const showFileCard = (fileID) => {
-      this.core.emitter.emit('dashboard:file-card', fileID)
+      this.core.emit('dashboard:file-card', fileID)
     }
     }
 
 
     const fileCardDone = (meta, fileID) => {
     const fileCardDone = (meta, fileID) => {
-      this.core.emitter.emit('core:update-meta', meta, fileID)
-      this.core.emitter.emit('dashboard:file-card')
+      this.core.emit('core:update-meta', meta, fileID)
+      this.core.emit('dashboard:file-card')
     }
     }
 
 
     const info = (text, type, duration) => {
     const info = (text, type, duration) => {
       this.core.info(text, type, duration)
       this.core.info(text, type, duration)
     }
     }
 
 
-    const resumableUploads = this.core.getState().capabilities.resumableUploads || false
+    const resumableUploads = this.core.state.capabilities.resumableUploads || false
 
 
     return Dashboard({
     return Dashboard({
       state: state,
       state: state,
@@ -382,7 +374,7 @@ module.exports = class DashboardUI extends Plugin {
       pauseAll: this.pauseAll,
       pauseAll: this.pauseAll,
       resumeAll: this.resumeAll,
       resumeAll: this.resumeAll,
       addFile: this.core.addFile,
       addFile: this.core.addFile,
-      removeFile: removeFile,
+      removeFile: this.core.removeFile,
       info: info,
       info: info,
       note: this.opts.note,
       note: this.opts.note,
       metaFields: state.metaFields,
       metaFields: state.metaFields,

+ 0 - 1
src/plugins/Plugin.js

@@ -17,7 +17,6 @@ module.exports = class Plugin {
   constructor (core, opts) {
   constructor (core, opts) {
     this.core = core
     this.core = core
     this.opts = opts || {}
     this.opts = opts || {}
-    this.type = 'none'
 
 
     // clear everything inside the target selector
     // clear everything inside the target selector
     this.opts.replaceTargetContent === this.opts.replaceTargetContent || true
     this.opts.replaceTargetContent === this.opts.replaceTargetContent || true

+ 21 - 1
src/plugins/Transloadit/Client.js

@@ -30,7 +30,7 @@ module.exports = class Client {
     Object.keys(fields).forEach((key) => {
     Object.keys(fields).forEach((key) => {
       data.append(key, fields[key])
       data.append(key, fields[key])
     })
     })
-    data.append('tus_num_expected_upload_files', expectedFiles)
+    data.append('num_expected_upload_files', expectedFiles)
 
 
     return fetch(`${this.apiUrl}/assemblies`, {
     return fetch(`${this.apiUrl}/assemblies`, {
       method: 'post',
       method: 'post',
@@ -47,6 +47,26 @@ module.exports = class Client {
     })
     })
   }
   }
 
 
+  reserveFile (assembly, file) {
+    const size = encodeURIComponent(file.size)
+    return fetch(`${assembly.assembly_ssl_url}/reserve_file?size=${size}`, { method: 'post' })
+      .then((response) => response.json())
+  }
+
+  addFile (assembly, file) {
+    if (!file.uploadURL) {
+      return Promise.reject(new Error('File does not have an `uploadURL`.'))
+    }
+    const size = encodeURIComponent(file.size)
+    const url = encodeURIComponent(file.uploadURL)
+    const filename = encodeURIComponent(file.name)
+    const fieldname = 'file'
+
+    const qs = `size=${size}&filename=${filename}&fieldname=${fieldname}&s3Url=${url}`
+    return fetch(`${assembly.assembly_ssl_url}/add_file?${qs}`, { method: 'post' })
+      .then((response) => response.json())
+  }
+
   /**
   /**
    * Get the current status for an assembly.
    * Get the current status for an assembly.
    *
    *

+ 78 - 13
src/plugins/Transloadit/index.js

@@ -24,6 +24,7 @@ module.exports = class Transloadit extends Plugin {
       waitForEncoding: false,
       waitForEncoding: false,
       waitForMetadata: false,
       waitForMetadata: false,
       alwaysRunAssembly: false, // TODO name
       alwaysRunAssembly: false, // TODO name
+      importFromUploadURLs: false,
       signature: null,
       signature: null,
       params: null,
       params: null,
       fields: {},
       fields: {},
@@ -44,6 +45,7 @@ module.exports = class Transloadit extends Plugin {
 
 
     this.prepareUpload = this.prepareUpload.bind(this)
     this.prepareUpload = this.prepareUpload.bind(this)
     this.afterUpload = this.afterUpload.bind(this)
     this.afterUpload = this.afterUpload.bind(this)
+    this.onFileUploadURLAvailable = this.onFileUploadURLAvailable.bind(this)
 
 
     if (this.opts.params) {
     if (this.opts.params) {
       this.validateParams(this.opts.params)
       this.validateParams(this.opts.params)
@@ -111,6 +113,8 @@ module.exports = class Transloadit extends Plugin {
   }
   }
 
 
   createAssembly (fileIDs, uploadID, options) {
   createAssembly (fileIDs, uploadID, options) {
+    const pluginOptions = this.opts
+
     this.core.log('Transloadit: create assembly')
     this.core.log('Transloadit: create assembly')
 
 
     return this.client.createAssembly({
     return this.client.createAssembly({
@@ -136,25 +140,28 @@ module.exports = class Transloadit extends Plugin {
         // Attach meta parameters for the Tus plugin. See:
         // Attach meta parameters for the Tus plugin. See:
         // https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus
         // https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus
         // TODO Should this `meta` be moved to a `tus.meta` property instead?
         // TODO Should this `meta` be moved to a `tus.meta` property instead?
-        // If the MetaData plugin can add eg. resize parameters, it doesn't
-        // make much sense to set those as upload-metadata for tus.
-        const meta = Object.assign({}, file.meta, {
+        const tlMeta = {
           assembly_url: assembly.assembly_url,
           assembly_url: assembly.assembly_url,
           filename: file.name,
           filename: file.name,
           fieldname: 'file'
           fieldname: 'file'
-        })
+        }
+        const meta = Object.assign({}, file.meta, tlMeta)
         // Add assembly-specific Tus endpoint.
         // Add assembly-specific Tus endpoint.
         const tus = Object.assign({}, file.tus, {
         const tus = Object.assign({}, file.tus, {
-          endpoint: assembly.tus_url
+          endpoint: assembly.tus_url,
+          // Only send assembly metadata to the tus endpoint.
+          metaFields: Object.keys(tlMeta)
         })
         })
         const transloadit = {
         const transloadit = {
           assembly: assembly.assembly_id
           assembly: assembly.assembly_id
         }
         }
-        return Object.assign(
-          {},
-          file,
-          { meta, tus, transloadit }
-        )
+
+        const newFile = Object.assign({}, file, { transloadit })
+        // Only configure the Tus plugin if we are uploading straight to Transloadit (the default).
+        if (!pluginOptions.importFromUploadURLs) {
+          Object.assign(newFile, { meta, tus })
+        }
+        return newFile
       }
       }
 
 
       const files = Object.assign({}, this.core.state.files)
       const files = Object.assign({}, this.core.state.files)
@@ -167,10 +174,12 @@ module.exports = class Transloadit extends Plugin {
       this.core.emit('transloadit:assembly-created', assembly, fileIDs)
       this.core.emit('transloadit:assembly-created', assembly, fileIDs)
 
 
       return this.connectSocket(assembly)
       return this.connectSocket(assembly)
-    }).then(() => {
+        .then(() => assembly)
+    }).then((assembly) => {
       this.core.log('Transloadit: Created assembly')
       this.core.log('Transloadit: Created assembly')
+      return assembly
     }).catch((err) => {
     }).catch((err) => {
-      this.core.info(this.opts.locale.strings.creatingAssemblyFailed, 'error', 0)
+      this.core.info(pluginOptions.locale.strings.creatingAssemblyFailed, 'error', 0)
 
 
       // Reject the promise.
       // Reject the promise.
       throw err
       throw err
@@ -181,6 +190,35 @@ module.exports = class Transloadit extends Plugin {
     return this.opts.waitForEncoding || this.opts.waitForMetadata
     return this.opts.waitForEncoding || this.opts.waitForMetadata
   }
   }
 
 
+  /**
+   * Used when `importFromUploadURLs` is enabled: reserves all files in
+   * the assembly.
+   */
+  reserveFiles (assembly, fileIDs) {
+    return Promise.all(fileIDs.map((fileID) => {
+      const file = this.core.getFile(fileID)
+      return this.client.reserveFile(assembly, file)
+    }))
+  }
+
+  /**
+   * Used when `importFromUploadURLs` is enabled: adds files to the assembly
+   * once they have been fully uploaded.
+   */
+  onFileUploadURLAvailable (fileID) {
+    const file = this.core.getFile(fileID)
+    if (!file || !file.transloadit || !file.transloadit.assembly) {
+      return
+    }
+
+    const assembly = this.state.assemblies[file.transloadit.assembly]
+
+    this.client.addFile(assembly, file).catch((err) => {
+      this.core.log(err)
+      this.core.emit('transloadit:import-error', assembly, file.id, err)
+    })
+  }
+
   findFile (uploadedFile) {
   findFile (uploadedFile) {
     const files = this.core.state.files
     const files = this.core.state.files
     for (const id in files) {
     for (const id in files) {
@@ -272,7 +310,11 @@ module.exports = class Transloadit extends Plugin {
     })
     })
 
 
     const createAssembly = ({ fileIDs, options }) => {
     const createAssembly = ({ fileIDs, options }) => {
-      return this.createAssembly(fileIDs, uploadID, options).then(() => {
+      return this.createAssembly(fileIDs, uploadID, options).then((assembly) => {
+        if (this.opts.importFromUploadURLs) {
+          return this.reserveFiles(assembly, fileIDs)
+        }
+      }).then(() => {
         fileIDs.forEach((fileID) => {
         fileIDs.forEach((fileID) => {
           this.core.emit('core:preprocess-complete', fileID)
           this.core.emit('core:preprocess-complete', fileID)
         })
         })
@@ -382,13 +424,28 @@ module.exports = class Transloadit extends Plugin {
         reject(error)
         reject(error)
       }
       }
 
 
+      const onImportError = (assembly, fileID, error) => {
+        if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
+          return
+        }
+
+        // Not sure if we should be doing something when it's just one file failing.
+        // ATM, the only options are 1) ignoring or 2) failing the entire upload.
+        // I think failing the upload is better than silently ignoring.
+        // In the future we should maybe have a way to resolve uploads with some failures,
+        // like returning an object with `{ successful, failed }` uploads.
+        onAssemblyError(assembly, error)
+      }
+
       const removeListeners = () => {
       const removeListeners = () => {
         this.core.off('transloadit:complete', onAssemblyFinished)
         this.core.off('transloadit:complete', onAssemblyFinished)
         this.core.off('transloadit:assembly-error', onAssemblyError)
         this.core.off('transloadit:assembly-error', onAssemblyError)
+        this.core.off('transloadit:import-error', onImportError)
       }
       }
 
 
       this.core.on('transloadit:complete', onAssemblyFinished)
       this.core.on('transloadit:complete', onAssemblyFinished)
       this.core.on('transloadit:assembly-error', onAssemblyError)
       this.core.on('transloadit:assembly-error', onAssemblyError)
+      this.core.on('transloadit:import-error', onImportError)
     }).then(() => {
     }).then(() => {
       // Clean up uploadID → assemblyIDs, they're no longer going to be used anywhere.
       // Clean up uploadID → assemblyIDs, they're no longer going to be used anywhere.
       const uploadsAssemblies = Object.assign({}, this.state.uploadsAssemblies)
       const uploadsAssemblies = Object.assign({}, this.state.uploadsAssemblies)
@@ -401,6 +458,10 @@ module.exports = class Transloadit extends Plugin {
     this.core.addPreProcessor(this.prepareUpload)
     this.core.addPreProcessor(this.prepareUpload)
     this.core.addPostProcessor(this.afterUpload)
     this.core.addPostProcessor(this.afterUpload)
 
 
+    if (this.opts.importFromUploadURLs) {
+      this.core.on('core:upload-success', this.onFileUploadURLAvailable)
+    }
+
     this.updateState({
     this.updateState({
       // Contains assembly status objects, indexed by their ID.
       // Contains assembly status objects, indexed by their ID.
       assemblies: {},
       assemblies: {},
@@ -416,6 +477,10 @@ module.exports = class Transloadit extends Plugin {
   uninstall () {
   uninstall () {
     this.core.removePreProcessor(this.prepareUpload)
     this.core.removePreProcessor(this.prepareUpload)
     this.core.removePostProcessor(this.afterUpload)
     this.core.removePostProcessor(this.afterUpload)
+
+    if (this.opts.importFromUploadURLs) {
+      this.core.off('core:upload-success', this.onFileUploadURLAvailable)
+    }
   }
   }
 
 
   getAssembly (id) {
   getAssembly (id) {

+ 8 - 3
src/plugins/XHRUpload.js

@@ -20,6 +20,9 @@ module.exports = class XHRUpload extends Plugin {
       headers: {},
       headers: {},
       getResponseData (xhr) {
       getResponseData (xhr) {
         return JSON.parse(xhr.response)
         return JSON.parse(xhr.response)
+      },
+      getResponseError (xhr) {
+        return new Error('Upload error')
       }
       }
     }
     }
 
 
@@ -88,8 +91,10 @@ module.exports = class XHRUpload extends Plugin {
 
 
           return resolve(file)
           return resolve(file)
         } else {
         } else {
-          this.core.emit('core:upload-error', file.id, xhr)
-          return reject('Upload error')
+          const error = opts.getResponseError(xhr) || new Error('Upload error')
+          error.request = xhr
+          this.core.emit('core:upload-error', file.id, error)
+          return reject(error)
         }
         }
 
 
         // var upload = {}
         // var upload = {}
@@ -103,7 +108,7 @@ module.exports = class XHRUpload extends Plugin {
 
 
       xhr.addEventListener('error', (ev) => {
       xhr.addEventListener('error', (ev) => {
         this.core.emit('core:upload-error', file.id)
         this.core.emit('core:upload-error', file.id)
-        return reject('Upload error')
+        return reject(new Error('Upload error'))
       })
       })
 
 
       xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
       xhr.open(opts.method.toUpperCase(), opts.endpoint, true)

+ 31 - 0
website/src/docs/transloadit.md

@@ -20,6 +20,8 @@ uppy.use(Transloadit, {
 })
 })
 ```
 ```
 
 
+NB: It is not required to use the `Tus10` plugin if [importFromUploadURLs](#importFromUploadURLs) is enabled.
+
 ## Options
 ## Options
 
 
 ### `waitForEncoding`
 ### `waitForEncoding`
@@ -31,6 +33,35 @@ Whether to wait for all assemblies to complete before completing the upload.
 Whether to wait for metadata to be extracted from uploaded files before completing the upload.
 Whether to wait for metadata to be extracted from uploaded files before completing the upload.
 If `waitForEncoding` is enabled, this has no effect.
 If `waitForEncoding` is enabled, this has no effect.
 
 
+### `importFromUploadURLs`
+
+Instead of uploading to Transloadit's servers directly, allow another plugin to upload files, and then import those files into the Transloadit assembly.
+Default `false`.
+
+When enabling this option, Transloadit will *not* configure the Tus plugin to upload to Transloadit.
+Instead, a separate upload plugin must be used.
+Once the upload completes, the Transloadit plugin adds the uploaded file to the assembly.
+
+For example, to upload files to an S3 bucket and then transcode them:
+
+```js
+uppy.use(AwsS3, {
+  getUploadParameters (file) {
+    return { /* upload parameters */ }
+  }
+})
+uppy.use(Transloadit, {
+  importFromUploadURLs: true,
+  params: {
+    auth: { key: /* secret */ },
+    template_id: /* secret */
+  }
+})
+```
+
+In order for this to work, the upload plugin must assign a publically accessible `uploadURL` property to the uploaded file object.
+The Tus and S3 plugins both do this—for the XHRUpload plugin, you may have to specify a custom `getUploadResponse` function.
+
 ### `params`
 ### `params`
 
 
 The assembly parameters to use for the upload.
 The assembly parameters to use for the upload.