Browse Source

Merge branch 'master' into feature/store

Artur Paikin 7 years ago
parent
commit
6148fec6ed

+ 1 - 0
.eslintignore

@@ -10,3 +10,4 @@ website/public/**
 website/themes/uppy/source/js/smooth-scroll.min.js
 website/themes/uppy/source/js/smooth-scroll.min.js
 website/themes/uppy/source/js/uppy.js
 website/themes/uppy/source/js/uppy.js
 website/themes/uppy/source/uppy/**
 website/themes/uppy/source/uppy/**
+bundle.js

+ 16 - 6
CHANGELOG.md

@@ -51,6 +51,10 @@ Ideas that will be planned and find their way into a release at one point
 - [ ] have a `resetProgress` method for resetting a single file, and call it before starting an upload. see comment in #393
 - [ ] have a `resetProgress` method for resetting a single file, and call it before starting an upload. see comment in #393
 - [ ] “Custom Provider” plugin for  Dashboard — shows already uploaded files or files from a custom service; accepts an array of files to show in options, no uppy-server required #362
 - [ ] “Custom Provider” plugin for  Dashboard — shows already uploaded files or files from a custom service; accepts an array of files to show in options, no uppy-server required #362
 - [ ] WordPress plugin
 - [ ] WordPress plugin
+- [ ] Transformations, cropping, filters for images, see #53
+- [ ] Prepare for (piwik-) tracking of usage of uppy ? see #83
+- [ ] screenshot+screencast support similar to Webcam #148
+- [ ] Webcam modes #198
 
 
 ## 1.0 Goals
 ## 1.0 Goals
 
 
@@ -75,7 +79,7 @@ What we need to do to release Uppy 1.0
 - [ ] refactoring: reduce size where possible, like, socket.io --> websockets (saves 20KB)
 - [ ] refactoring: reduce size where possible, like, socket.io --> websockets (saves 20KB)
 - [ ] refactoring: possibly add CSS-in-JS
 - [ ] refactoring: possibly add CSS-in-JS
 - [ ] refactoring: possibly switch from Yo-Yo to Preact, because it’s more stable, solves a few issues we are struggling with (onload being weird/hard/modern-browsers-only with bel; no way to pass refs to elements; extra network requests with base64 urls) and mature, “new standard”, larger community
 - [ ] refactoring: possibly switch from Yo-Yo to Preact, because it’s more stable, solves a few issues we are struggling with (onload being weird/hard/modern-browsers-only with bel; no way to pass refs to elements; extra network requests with base64 urls) and mature, “new standard”, larger community
-- [ ] refactoring: possibly differentiate UI plugins from logic plugins, so that, say uploading plugins don’t include rendering stuff
+- [ ] refactoring: possibly differentiate UI plugins from logic plugins, so that, say, uploading plugins don’t include rendering stuff
 - [x] refactoring: webcam plugin
 - [x] refactoring: webcam plugin
 - [ ] docs: on using plugins, all options, list of plugins, i18n
 - [ ] docs: on using plugins, all options, list of plugins, i18n
 - [ ] uppy-server: better error handling, general cleanup (remove unused code. etc)
 - [ ] uppy-server: better error handling, general cleanup (remove unused code. etc)
@@ -84,6 +88,10 @@ What we need to do to release Uppy 1.0
 - [ ] consider iframe / more security for Transloadit/Uppy integration widget and Uppy itself. Page can’t get files from Google Drive if its an iframe; possibility for folder restriction for provider plugins
 - [ ] consider iframe / more security for Transloadit/Uppy integration widget and Uppy itself. Page can’t get files from Google Drive if its an iframe; possibility for folder restriction for provider plugins
 - [ ] automatically host releases on edgly and use that as our main CDN
 - [ ] automatically host releases on edgly and use that as our main CDN
 
 
+## 0.23
+
+- [ ] Audio: audio recording similar to Webcam #143
+
 # next
 # next
 
 
 ## 0.22.0
 ## 0.22.0
@@ -91,22 +99,24 @@ What we need to do to release Uppy 1.0
 To be released: 2017-12-20
 To be released: 2017-12-20
 Theme: 🎄 Christmas edition
 Theme: 🎄 Christmas edition
 
 
-- [ ] add `Form`: a plugin that is used in conjunction with any other acquirer, responsible for 1\. acquiring the metadata from form; 2\. intercepting submit event on the form, opening Uppy dialog instead; 3\. injecting any result (like from Transloadit plugin) back into the form (jquery-sdk includes the whole Assembly Status JSON in a hidden field i think) (@arturi)
+- [ ] add `Form`: a plugin that is used in conjunction with any other acquirer, responsible for 1. acquiring the metadata from form; 2. intercepting submit event on the form, opening Uppy dialog instead; 3. injecting any result (like from Transloadit plugin) back into the form (jquery-sdk includes the whole Assembly Status JSON in a hidden field i think) (@arturi)
 - [ ] core: Redux PR (#216 / @arturi, @goto-bus-stop, @richardwillars)
 - [ ] core: Redux PR (#216 / @arturi, @goto-bus-stop, @richardwillars)
 - [ ] core: css-in-js, while keeping non-random classnames (ideally prefixed) and useful preprocessor features. also see simple https://github.com/codemirror/CodeMirror/blob/master/lib/codemirror.css (@arturi, @goto-bus-stop)
 - [ ] core: css-in-js, while keeping non-random classnames (ideally prefixed) and useful preprocessor features. also see simple https://github.com/codemirror/CodeMirror/blob/master/lib/codemirror.css (@arturi, @goto-bus-stop)
-- [x] core: improve on Redux PR #216 to allow using Redux (or any other solution) for all Uppy state management, instead of proxy-only (#426 / @goto-bus-stop, @arturi)
-- [ ] core: limit amount of simultaneous uploads, queuing? #360 (@goto-bus-stop)
+- [x] core: improve on Redux PR #216 to allow using Redux (or any other solution) for all Uppy state management, instead of proxy-only (@goto-bus-stop, @arturi)
+- [x] core: limit amount of simultaneous uploads, queuing? #360 (#427 / @goto-bus-stop)
+- [ ] core: queue preview generation #431
 - [ ] core: research !important styles to be immune to any environment/page. Maybe use smth like `postcss-safe-important`, http://cleanslatecss.com/ Or increase specificity (with .uppy prefix) (@arturi)
 - [ ] core: research !important styles to be immune to any environment/page. Maybe use smth like `postcss-safe-important`, http://cleanslatecss.com/ Or increase specificity (with .uppy prefix) (@arturi)
 - [ ] dashboard: allow minimizing the Dashboard during upload (Uppy then becomes just a tiny progress indicator) (@arturi)
 - [ ] dashboard: allow minimizing the Dashboard during upload (Uppy then becomes just a tiny progress indicator) (@arturi)
 - [ ] 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)
-- [ ] dashboard: place upload button into StatusBar, use Alex’s suggestions for retry
+- [ ] dashboard: place upload button into StatusBar, use Alex’s suggestions for retry button
 - [ ] docs: quick start guide: https://community.transloadit.com/t/quick-start-guide-would-be-really-helpful/14605 (@arturi)
 - [ ] docs: quick start guide: https://community.transloadit.com/t/quick-start-guide-would-be-really-helpful/14605 (@arturi)
-- [ ] docs: writing plugins
+- [ ] docs: on writing plugins
 - [ ] goldenretriever: add “ghost” files (@arturi)
 - [ ] goldenretriever: add “ghost” files (@arturi)
 - [ ] provider: improve UI, add icons for file types (@arturi)
 - [ ] provider: improve UI, add icons for file types (@arturi)
 - [ ] test: add https://github.com/pa11y/pa11y for automated accessibility testing?
 - [ ] test: add https://github.com/pa11y/pa11y for automated accessibility testing?
 - [ ] test: add tests for `npm pack`
 - [ ] test: add tests for `npm pack`
 - [ ] tus: Review “tus: Remove old upload and events when starting a new upload.” b3cc48130e292f08c2a09f2f0adf6b6332bf7692 (@arturi)
 - [ ] tus: Review “tus: Remove old upload and events when starting a new upload.” b3cc48130e292f08c2a09f2f0adf6b6332bf7692 (@arturi)
+- [ ] webcam: URL.createObjectURL(MediaStream) is deprecated and will be removed soon: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/srcObject
 
 
 ## 0.21.0
 ## 0.21.0
 
 

+ 4 - 4
README.md

@@ -81,8 +81,8 @@ If you like, you can also use a pre-built bundle, for example from [unpkg CDN](h
 ``` html
 ``` html
 <script>
 <script>
   var uppy = Uppy.Core()
   var uppy = Uppy.Core()
-  uppy.use(Uppy.DragDrop, {target: '.UppyDragDrop'})
-  uppy.use(Uppy.Tus, {endpoint: '//master.tus.io/files/'})
+  uppy.use(Uppy.DragDrop, { target: '.UppyDragDrop' })
+  uppy.use(Uppy.Tus, { endpoint: '//master.tus.io/files/' })
   uppy.run()
   uppy.run()
 </script>
 </script>
 ```
 ```
@@ -119,13 +119,13 @@ If you like, you can also use a pre-built bundle, for example from [unpkg CDN](h
   <img src="https://saucelabs.com/browser-matrix/transloadit-uppy.svg" alt="Sauce Test Status"/>
   <img src="https://saucelabs.com/browser-matrix/transloadit-uppy.svg" alt="Sauce Test Status"/>
 </a>
 </a>
 
 
-Note: we aim to support IE10+ and recent versions of Safari, Edge, Chrome, Firefox and Opera. IE6 on the chart above means we recommend setting Uppy to target a `<form>` element, so when Uppy has not yet loaded or is not supported, upload still works. Even on the refrigerator browser. Or, yes, IE6.
+Note: we aim to support IE10+ and recent versions of Safari, Edge, Chrome, Firefox and Opera.
 
 
 ## FAQ
 ## FAQ
 
 
 ### React support?
 ### React support?
 
 
-Yep. Uppy-React component is in the works, in the meantime you can just use it as any other lib with React, [see here](https://github.com/transloadit/uppy/tree/uppy-react/src/uppy-react).
+Yep, see [Uppy React docs](https://uppy.io/docs/react/).
 
 
 ### Can I use it with Rails/Node/Go/PHP?
 ### Can I use it with Rails/Node/Go/PHP?
 
 

+ 10 - 3
src/core/Core.js

@@ -47,7 +47,8 @@ class Uppy {
       onBeforeFileAdded: (currentFile, files) => Promise.resolve(),
       onBeforeFileAdded: (currentFile, files) => Promise.resolve(),
       onBeforeUpload: (files, done) => Promise.resolve(),
       onBeforeUpload: (files, done) => Promise.resolve(),
       locale: defaultLocale,
       locale: defaultLocale,
-      store: new DefaultStore()
+      store: new DefaultStore(),
+      thumbnailGeneration: true
     }
     }
 
 
     // Merge default options with the ones set by user
     // Merge default options with the ones set by user
@@ -394,8 +395,14 @@ class Uppy {
    */
    */
   generatePreview (file) {
   generatePreview (file) {
     if (Utils.isPreviewSupported(file.type) && !file.isRemote) {
     if (Utils.isPreviewSupported(file.type) && !file.isRemote) {
-      Utils.createThumbnail(file, 200).then((thumbnail) => {
-        this.setPreviewURL(file.id, thumbnail)
+      let previewPromise
+      if (this.opts.thumbnailGeneration === true) {
+        previewPromise = Utils.createThumbnail(file, 200)
+      } else {
+        previewPromise = Promise.resolve(URL.createObjectURL(file.data))
+      }
+      previewPromise.then((preview) => {
+        this.setPreviewURL(file.id, preview)
       }).catch((err) => {
       }).catch((err) => {
         console.warn(err.stack || err.message)
         console.warn(err.stack || err.message)
       })
       })

File diff suppressed because it is too large
+ 0 - 0
src/core/Core.test.js


+ 39 - 2
src/core/Utils.js

@@ -217,7 +217,6 @@ function getProportionalHeight (img, width) {
  */
  */
 function createThumbnail (file, targetWidth) {
 function createThumbnail (file, targetWidth) {
   const originalUrl = URL.createObjectURL(file.data)
   const originalUrl = URL.createObjectURL(file.data)
-
   const onload = new Promise((resolve, reject) => {
   const onload = new Promise((resolve, reject) => {
     const image = new Image()
     const image = new Image()
     image.src = originalUrl
     image.src = originalUrl
@@ -526,6 +525,43 @@ function settle (promises) {
   })
   })
 }
 }
 
 
+/**
+ * Limit the amount of simultaneously pending Promises.
+ * Returns a function that, when passed a function `fn`,
+ * will make sure that at most `limit` calls to `fn` are pending.
+ *
+ * @param {number} limit
+ * @return {function()}
+ */
+function limitPromises (limit) {
+  let pending = 0
+  const queue = []
+  return (fn) => {
+    return (...args) => {
+      const call = () => {
+        pending++
+        const promise = fn(...args)
+        promise.then(onfinish, onfinish)
+        return promise
+      }
+
+      if (pending >= limit) {
+        return new Promise((resolve, reject) => {
+          queue.push(() => {
+            call().then(resolve, reject)
+          })
+        })
+      }
+      return call()
+    }
+  }
+  function onfinish () {
+    pending--
+    const next = queue.shift()
+    if (next) next()
+  }
+}
+
 module.exports = {
 module.exports = {
   generateFileID,
   generateFileID,
   toArray,
   toArray,
@@ -553,5 +589,6 @@ module.exports = {
   findAllDOMElements,
   findAllDOMElements,
   getSocketHost,
   getSocketHost,
   emitSocketProgress,
   emitSocketProgress,
-  settle
+  settle,
+  limitPromises
 }
 }

+ 56 - 0
src/core/Utils.test.js

@@ -261,6 +261,16 @@ describe('core/utils', () => {
   })
   })
 
 
   describe('createThumbnail', () => {
   describe('createThumbnail', () => {
+    const RealCreateObjectUrl = global.URL.createObjectURL
+
+    beforeEach(() => {
+      global.URL.createObjectURL = jest.fn().mockReturnValue('newUrl')
+    })
+
+    afterEach(() => {
+      global.URL.createObjectURL = RealCreateObjectUrl
+    })
+
     xit(
     xit(
       'should create a thumbnail of the specified image at the specified width',
       'should create a thumbnail of the specified image at the specified width',
       () => {}
       () => {}
@@ -370,4 +380,50 @@ describe('core/utils', () => {
       })
       })
     })
     })
   })
   })
+
+  describe('limitPromises', () => {
+    let pending = 0
+    function fn () {
+      pending++
+      return new Promise((resolve) => setTimeout(resolve, 10))
+        .then(() => pending--)
+    }
+
+    it('should run at most N promises at the same time', () => {
+      const limit = utils.limitPromises(4)
+      const fn2 = limit(fn)
+
+      const result = Promise.all([
+        fn2(), fn2(), fn2(), fn2(),
+        fn2(), fn2(), fn2(), fn2(),
+        fn2(), fn2()
+      ])
+
+      expect(pending).toBe(4)
+      setTimeout(() => {
+        expect(pending).toBe(4)
+      }, 10)
+
+      return result.then(() => {
+        expect(pending).toBe(0)
+      })
+    })
+
+    it('should accept Infinity as limit', () => {
+      const limit = utils.limitPromises(Infinity)
+      const fn2 = limit(fn)
+
+      const result = Promise.all([
+        fn2(), fn2(), fn2(), fn2(),
+        fn2(), fn2(), fn2(), fn2(),
+        fn2(), fn2()
+      ])
+
+      expect(pending).toBe(10)
+
+      return result.then(() => {
+        expect(pending).toBe(0)
+      })
+    })
+  })
 })
 })

+ 3 - 1
src/generic-provider-views/Browser.js

@@ -17,7 +17,7 @@ module.exports = (props) => {
         <div class="Browser-search" aria-hidden="${!props.isSearchVisible}">
         <div class="Browser-search" aria-hidden="${!props.isSearchVisible}">
           <input type="text" class="Browser-searchInput" placeholder="Search"
           <input type="text" class="Browser-searchInput" placeholder="Search"
                  onkeyup=${props.filterQuery} value="${props.filterInput}"/>
                  onkeyup=${props.filterQuery} value="${props.filterInput}"/>
-          <button type="button" class="Browser-searchClose" 
+          <button type="button" class="Browser-searchClose"
                   onclick=${props.toggleSearch}>
                   onclick=${props.toggleSearch}>
             <svg class="UppyIcon" viewBox="0 0 19 19">
             <svg class="UppyIcon" viewBox="0 0 19 19">
               <path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z"/>
               <path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z"/>
@@ -52,6 +52,8 @@ module.exports = (props) => {
           sortByDate: props.sortByDate,
           sortByDate: props.sortByDate,
           handleFileClick: props.addFile,
           handleFileClick: props.addFile,
           handleFolderClick: props.getNextFolder,
           handleFolderClick: props.getNextFolder,
+          isChecked: props.isChecked,
+          toggleCheckbox: props.toggleCheckbox,
           getItemName: props.getItemName,
           getItemName: props.getItemName,
           getItemIcon: props.getItemIcon,
           getItemIcon: props.getItemIcon,
           handleScroll: props.handleScroll,
           handleScroll: props.handleScroll,

+ 11 - 0
src/generic-provider-views/Table.js

@@ -18,11 +18,19 @@ module.exports = (props) => {
     <table class="BrowserTable" onscroll=${props.handleScroll}>
     <table class="BrowserTable" onscroll=${props.handleScroll}>
       <tbody role="listbox" aria-label="List of files from ${props.title}">
       <tbody role="listbox" aria-label="List of files from ${props.title}">
         ${props.folders.map((folder) => {
         ${props.folders.map((folder) => {
+          let isDisabled = false
+          let isChecked = props.isChecked(folder)
+          if (isChecked) {
+            isDisabled = isChecked.loading
+          }
           return Row({
           return Row({
             title: props.getItemName(folder),
             title: props.getItemName(folder),
             active: props.activeRow(folder),
             active: props.activeRow(folder),
             getItemIcon: () => props.getItemIcon(folder),
             getItemIcon: () => props.getItemIcon(folder),
             handleClick: () => props.handleFolderClick(folder),
             handleClick: () => props.handleFolderClick(folder),
+            isDisabled: isDisabled,
+            isChecked: isChecked,
+            handleCheckboxClick: (e) => props.toggleCheckbox(e, folder),
             columns: props.columns
             columns: props.columns
           })
           })
         })}
         })}
@@ -32,6 +40,9 @@ module.exports = (props) => {
             active: props.activeRow(file),
             active: props.activeRow(file),
             getItemIcon: () => props.getItemIcon(file),
             getItemIcon: () => props.getItemIcon(file),
             handleClick: () => props.handleFileClick(file),
             handleClick: () => props.handleFileClick(file),
+            isDisabled: false,
+            isChecked: props.isChecked(file),
+            handleCheckboxClick: (e) => props.toggleCheckbox(e, file),
             columns: props.columns
             columns: props.columns
           })
           })
         })}
         })}

+ 3 - 0
src/generic-provider-views/TableRow.js

@@ -9,6 +9,9 @@ module.exports = (props) => {
 
 
   return html`
   return html`
     <tr onclick=${props.handleClick} onkeydown=${handleKeyDown} class=${classes} role="option" tabindex="0">
     <tr onclick=${props.handleClick} onkeydown=${handleKeyDown} class=${classes} role="option" tabindex="0">
+      <td onclick=${props.handleCheckboxClick} class="BrowserTable-column">
+        <input type="checkbox" checked=${props.isChecked} disabled=${props.isDisabled} />
+      </td>
       ${Column({
       ${Column({
         getItemIcon: props.getItemIcon,
         getItemIcon: props.getItemIcon,
         value: props.title
         value: props.title

+ 204 - 35
src/generic-provider-views/index.js

@@ -53,6 +53,7 @@ module.exports = class View {
     this.opts = Object.assign({}, defaultOptions, opts)
     this.opts = Object.assign({}, defaultOptions, opts)
 
 
     // Logic
     // Logic
+    this.updateFolderState = this.updateFolderState.bind(this)
     this.addFile = this.addFile.bind(this)
     this.addFile = this.addFile.bind(this)
     this.filterItems = this.filterItems.bind(this)
     this.filterItems = this.filterItems.bind(this)
     this.filterQuery = this.filterQuery.bind(this)
     this.filterQuery = this.filterQuery.bind(this)
@@ -66,21 +67,19 @@ module.exports = class View {
     this.sortByTitle = this.sortByTitle.bind(this)
     this.sortByTitle = this.sortByTitle.bind(this)
     this.sortByDate = this.sortByDate.bind(this)
     this.sortByDate = this.sortByDate.bind(this)
     this.isActiveRow = this.isActiveRow.bind(this)
     this.isActiveRow = this.isActiveRow.bind(this)
+    this.isChecked = this.isChecked.bind(this)
+    this.toggleCheckbox = this.toggleCheckbox.bind(this)
     this.handleError = this.handleError.bind(this)
     this.handleError = this.handleError.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
 
 
+    this.plugin.core.on('core:file-removed', this.updateFolderState)
+
     // Visual
     // Visual
     this.render = this.render.bind(this)
     this.render = this.render.bind(this)
   }
   }
 
 
-  /**
-   * Little shorthand to update the state with the plugin's state
-   */
-  updateState (newState) {
-    let stateId = this.plugin.stateId
-    const {state} = this.plugin.core
-
-    this.plugin.core.setState({[stateId]: Object.assign({}, state[stateId], newState)})
+  tearDown () {
+    this.plugin.core.off('core:file-removed', this.updateFolderState)
   }
   }
 
 
   _updateFilesAndFolders (res, files, folders) {
   _updateFilesAndFolders (res, files, folders) {
@@ -92,18 +91,18 @@ module.exports = class View {
       }
       }
     })
     })
 
 
-    this.updateState({ folders, files })
+    this.plugin.setPluginState({ folders, files })
   }
   }
 
 
   checkAuth () {
   checkAuth () {
-    this.updateState({ checkAuthInProgress: true })
+    this.plugin.setPluginState({ checkAuthInProgress: true })
     this.Provider.checkAuth()
     this.Provider.checkAuth()
       .then((authenticated) => {
       .then((authenticated) => {
-        this.updateState({ checkAuthInProgress: false })
+        this.plugin.setPluginState({ checkAuthInProgress: false })
         this.plugin.onAuth(authenticated)
         this.plugin.onAuth(authenticated)
       })
       })
       .catch((err) => {
       .catch((err) => {
-        this.updateState({ checkAuthInProgress: false })
+        this.plugin.setPluginState({ checkAuthInProgress: false })
         this.handleError(err)
         this.handleError(err)
       })
       })
   }
   }
@@ -121,7 +120,7 @@ module.exports = class View {
         let files = []
         let files = []
         let updatedDirectories
         let updatedDirectories
 
 
-        const state = this.plugin.core.getState()[this.plugin.stateId]
+        const state = this.plugin.getPluginState()
         const index = state.directories.findIndex((dir) => id === dir.id)
         const index = state.directories.findIndex((dir) => id === dir.id)
 
 
         if (index !== -1) {
         if (index !== -1) {
@@ -131,7 +130,7 @@ module.exports = class View {
         }
         }
 
 
         this._updateFilesAndFolders(res, files, folders)
         this._updateFilesAndFolders(res, files, folders)
-        this.updateState({ directories: updatedDirectories })
+        this.plugin.setPluginState({ directories: updatedDirectories })
       },
       },
       this.handleError)
       this.handleError)
   }
   }
@@ -144,9 +143,10 @@ module.exports = class View {
   getNextFolder (folder) {
   getNextFolder (folder) {
     let id = this.plugin.getItemRequestPath(folder)
     let id = this.plugin.getItemRequestPath(folder)
     this.getFolder(id, this.plugin.getItemName(folder))
     this.getFolder(id, this.plugin.getItemName(folder))
+    this.lastCheckbox = undefined
   }
   }
 
 
-  addFile (file) {
+  addFile (file, isCheckbox = false) {
     const tagFile = {
     const tagFile = {
       source: this.plugin.id,
       source: this.plugin.id,
       data: this.plugin.getItemData(file),
       data: this.plugin.getItemData(file),
@@ -171,6 +171,9 @@ module.exports = class View {
       }
       }
       this.plugin.core.log('Adding remote file')
       this.plugin.core.log('Adding remote file')
       this.plugin.core.addFile(tagFile)
       this.plugin.core.addFile(tagFile)
+      if (!isCheckbox) {
+        this.plugin.core.getPlugin('Dashboard').hideAllPanels()
+      }
     })
     })
   }
   }
 
 
@@ -188,23 +191,23 @@ module.exports = class View {
             folders: [],
             folders: [],
             directories: []
             directories: []
           }
           }
-          this.updateState(newState)
+          this.plugin.setPluginState(newState)
         }
         }
       }).catch(this.handleError)
       }).catch(this.handleError)
   }
   }
 
 
   filterQuery (e) {
   filterQuery (e) {
-    const state = this.plugin.core.getState()[this.plugin.stateId]
-    this.updateState(Object.assign({}, state, {
+    const state = this.plugin.getPluginState()
+    this.plugin.setPluginState(Object.assign({}, state, {
       filterInput: e.target.value
       filterInput: e.target.value
     }))
     }))
   }
   }
 
 
   toggleSearch () {
   toggleSearch () {
-    const state = this.plugin.core.getState()[this.plugin.stateId]
+    const state = this.plugin.getPluginState()
     const searchInputEl = document.querySelector('.Browser-searchInput')
     const searchInputEl = document.querySelector('.Browser-searchInput')
 
 
-    this.updateState(Object.assign({}, state, {
+    this.plugin.setPluginState(Object.assign({}, state, {
       isSearchVisible: !state.isSearchVisible,
       isSearchVisible: !state.isSearchVisible,
       filterInput: ''
       filterInput: ''
     }))
     }))
@@ -216,14 +219,14 @@ module.exports = class View {
   }
   }
 
 
   filterItems (items) {
   filterItems (items) {
-    const state = this.plugin.core.getState()[this.plugin.stateId]
+    const state = this.plugin.getPluginState()
     return items.filter((folder) => {
     return items.filter((folder) => {
       return this.plugin.getItemName(folder).toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
       return this.plugin.getItemName(folder).toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
     })
     })
   }
   }
 
 
   sortByTitle () {
   sortByTitle () {
-    const state = Object.assign({}, this.plugin.core.getState()[this.plugin.stateId])
+    const state = Object.assign({}, this.plugin.getPluginState())
     const {files, folders, sorting} = state
     const {files, folders, sorting} = state
 
 
     let sortedFiles = files.sort((fileA, fileB) => {
     let sortedFiles = files.sort((fileA, fileB) => {
@@ -240,7 +243,7 @@ module.exports = class View {
       return this.plugin.getItemName(folderA).localeCompare(this.plugin.getItemName(folderB))
       return this.plugin.getItemName(folderA).localeCompare(this.plugin.getItemName(folderB))
     })
     })
 
 
-    this.updateState(Object.assign({}, state, {
+    this.plugin.setPluginState(Object.assign({}, state, {
       files: sortedFiles,
       files: sortedFiles,
       folders: sortedFolders,
       folders: sortedFolders,
       sorting: (sorting === 'titleDescending') ? 'titleAscending' : 'titleDescending'
       sorting: (sorting === 'titleDescending') ? 'titleAscending' : 'titleDescending'
@@ -248,7 +251,7 @@ module.exports = class View {
   }
   }
 
 
   sortByDate () {
   sortByDate () {
-    const state = Object.assign({}, this.plugin.core.getState()[this.plugin.stateId])
+    const state = Object.assign({}, this.plugin.getPluginState())
     const {files, folders, sorting} = state
     const {files, folders, sorting} = state
 
 
     let sortedFiles = files.sort((fileA, fileB) => {
     let sortedFiles = files.sort((fileA, fileB) => {
@@ -272,7 +275,7 @@ module.exports = class View {
       return a > b ? 1 : a < b ? -1 : 0
       return a > b ? 1 : a < b ? -1 : 0
     })
     })
 
 
-    this.updateState(Object.assign({}, state, {
+    this.plugin.setPluginState(Object.assign({}, state, {
       files: sortedFiles,
       files: sortedFiles,
       folders: sortedFolders,
       folders: sortedFolders,
       sorting: (sorting === 'dateDescending') ? 'dateAscending' : 'dateDescending'
       sorting: (sorting === 'dateDescending') ? 'dateAscending' : 'dateDescending'
@@ -280,7 +283,7 @@ module.exports = class View {
   }
   }
 
 
   sortBySize () {
   sortBySize () {
-    const state = Object.assign({}, this.plugin.core.getState()[this.plugin.stateId])
+    const state = Object.assign({}, this.plugin.getPluginState())
     const {files, sorting} = state
     const {files, sorting} = state
 
 
     // check that plugin supports file sizes
     // check that plugin supports file sizes
@@ -298,19 +301,183 @@ module.exports = class View {
       return a > b ? 1 : a < b ? -1 : 0
       return a > b ? 1 : a < b ? -1 : 0
     })
     })
 
 
-    this.updateState(Object.assign({}, state, {
+    this.plugin.setPluginState(Object.assign({}, state, {
       files: sortedFiles,
       files: sortedFiles,
       sorting: (sorting === 'sizeDescending') ? 'sizeAscending' : 'sizeDescending'
       sorting: (sorting === 'sizeDescending') ? 'sizeAscending' : 'sizeDescending'
     }))
     }))
   }
   }
 
 
   isActiveRow (file) {
   isActiveRow (file) {
-    return this.plugin.core.getState()[this.plugin.stateId].activeRow === this.plugin.getItemId(file)
+    return this.plugin.getPluginState().activeRow === this.plugin.getItemId(file)
+  }
+
+  isChecked (item) {
+    const itemId = this.providerFileToId(item)
+    if (this.plugin.isFolder(item)) {
+      const state = this.plugin.getPluginState()
+      const folders = state.selectedFolders || {}
+      if (itemId in folders) {
+        return folders[itemId]
+      }
+      return false
+    }
+    return (itemId in this.plugin.core.getState().files)
+  }
+
+  /**
+   * Adds all files found inside of specified folder.
+   *
+   * Uses separated state while folder contents are being fetched and
+   * mantains list of selected folders, which are separated from files.
+   */
+  addFolder (folder) {
+    const folderId = this.providerFileToId(folder)
+    let state = this.plugin.getPluginState()
+    let folders = state.selectedFolders || {}
+    if (folderId in folders && folders[folderId].loading) {
+      return
+    }
+    folders[folderId] = {loading: true, files: []}
+    this.plugin.setPluginState({selectedFolders: folders})
+    this.Provider.list(this.plugin.getItemRequestPath(folder)).then((res) => {
+      let files = []
+      this.plugin.getItemSubList(res).forEach((item) => {
+        if (!this.plugin.isFolder(item)) {
+          this.addFile(item, true)
+          files.push(this.providerFileToId(item))
+        }
+      })
+      state = this.plugin.getPluginState()
+      state.selectedFolders[folderId] = {loading: false, files: files}
+      this.plugin.setPluginState({selectedFolders: folders})
+      const dashboard = this.plugin.core.getPlugin('Dashboard')
+      let message
+      if (files.length) {
+        message = dashboard.i18n('folderAdded', {
+          smart_count: files.length, folder: this.plugin.getItemName(folder)
+        })
+      } else {
+        message = dashboard.i18n('emptyFolderAdded')
+      }
+      this.plugin.core.info(message)
+    }).catch((e) => {
+      state = this.plugin.getPluginState()
+      delete state.selectedFolders[folderId]
+      this.plugin.setPluginState({selectedFolders: state.selectedFolders})
+      this.handleError(e)
+    })
+  }
+
+  removeFolder (folderId) {
+    let state = this.plugin.getPluginState()
+    let folders = state.selectedFolders || {}
+    if (!(folderId in folders)) {
+      return
+    }
+    let folder = folders[folderId]
+    if (folder.loading) {
+      return
+    }
+    // deepcopy the files before iteration because the
+    // original array constantly gets mutated during
+    // the iteration by updateFolderState as each file
+    // is removed and 'core:file-removed' is emitted.
+    const files = folder.files.concat([])
+    for (const fileId of files) {
+      if (fileId in this.plugin.core.getState().files) {
+        this.plugin.core.removeFile(fileId)
+      }
+    }
+    delete folders[folderId]
+    this.plugin.setPluginState({selectedFolders: folders})
+  }
+
+  /**
+   * Updates selected folders state everytime file is being removed.
+   *
+   * Note that this is only important when files are getting removed from the
+   * main screen, and will do nothing when you uncheck folder directly, since
+   * it's already been done in removeFolder method.
+   */
+  updateFolderState (fileId) {
+    let state = this.plugin.getPluginState()
+    let folders = state.selectedFolders || {}
+    for (let folderId in folders) {
+      let folder = folders[folderId]
+      if (folder.loading) {
+        continue
+      }
+      let i = folder.files.indexOf(fileId)
+      if (i > -1) {
+        folder.files.splice(i, 1)
+      }
+      if (!folder.files.length) {
+        delete folders[folderId]
+      }
+    }
+    this.plugin.setPluginState({selectedFolders: folders})
+  }
+
+  /**
+   * Toggles file/folder checkbox to on/off state while updating files list.
+   *
+   * Note that some extra complexity comes from supporting shift+click to
+   * toggle multiple checkboxes at once, which is done by getting all files
+   * in between last checked file and current one, and applying an on/off state
+   * for all of them, depending on current file state.
+   */
+  toggleCheckbox (e, file) {
+    e.stopPropagation()
+    e.preventDefault()
+    let { folders, files, filterInput } = this.plugin.getPluginState()
+    let items = folders.concat(files)
+    if (filterInput !== '') {
+      items = this.filterItems(items)
+    }
+    let itemsToToggle = [file]
+    if (this.lastCheckbox && e.shiftKey) {
+      let prevIndex = items.indexOf(this.lastCheckbox)
+      let currentIndex = items.indexOf(file)
+      if (prevIndex < currentIndex) {
+        itemsToToggle = items.slice(prevIndex, currentIndex + 1)
+      } else {
+        itemsToToggle = items.slice(currentIndex, prevIndex + 1)
+      }
+    }
+    this.lastCheckbox = file
+    if (this.isChecked(file)) {
+      for (let item of itemsToToggle) {
+        const itemId = this.providerFileToId(item)
+        if (this.plugin.isFolder(item)) {
+          this.removeFolder(itemId)
+        } else {
+          if (itemId in this.plugin.core.getState().files) {
+            this.plugin.core.removeFile(itemId)
+          }
+        }
+      }
+    } else {
+      for (let item of itemsToToggle) {
+        if (this.plugin.isFolder(item)) {
+          this.addFolder(item)
+        } else {
+          this.addFile(item, true)
+        }
+      }
+    }
+  }
+
+  providerFileToId (file) {
+    return Utils.generateFileID({
+      data: this.plugin.getItemData(file),
+      name: this.plugin.getItemName(file) || this.plugin.getItemId(file),
+      type: this.plugin.getMimeType(file)
+    })
   }
   }
 
 
   handleDemoAuth () {
   handleDemoAuth () {
-    const state = this.plugin.core.getState()[this.plugin.stateId]
-    this.updateState({}, state, {
+    const state = this.plugin.getPluginState()
+    this.plugin.setPluginState({}, state, {
       authenticated: true
       authenticated: true
     })
     })
   }
   }
@@ -360,7 +527,7 @@ module.exports = class View {
     if (scrollPos < 50 && path && !this._isHandlingScroll) {
     if (scrollPos < 50 && path && !this._isHandlingScroll) {
       this.Provider.list(path)
       this.Provider.list(path)
         .then((res) => {
         .then((res) => {
-          const { files, folders } = this.plugin.core.getState()[this.plugin.stateId]
+          const { files, folders } = this.plugin.getPluginState()
           this._updateFilesAndFolders(res, files, folders)
           this._updateFilesAndFolders(res, files, folders)
         }).catch(this.handleError)
         }).catch(this.handleError)
         .then(() => { this._isHandlingScroll = false }) // always called
         .then(() => { this._isHandlingScroll = false }) // always called
@@ -373,12 +540,12 @@ module.exports = class View {
   _loaderWrapper (promise, then, catch_) {
   _loaderWrapper (promise, then, catch_) {
     promise
     promise
       .then(then).catch(catch_)
       .then(then).catch(catch_)
-      .then(() => this.updateState({ loading: false })) // always called.
-    this.updateState({ loading: true })
+      .then(() => this.plugin.setPluginState({ loading: false })) // always called.
+    this.plugin.setPluginState({ loading: true })
   }
   }
 
 
   render (state) {
   render (state) {
-    const { authenticated, checkAuthInProgress, loading } = state[this.plugin.stateId]
+    const { authenticated, checkAuthInProgress, loading } = this.plugin.getPluginState()
 
 
     if (loading) {
     if (loading) {
       return LoaderView()
       return LoaderView()
@@ -395,7 +562,7 @@ module.exports = class View {
       })
       })
     }
     }
 
 
-    const browserProps = Object.assign({}, state[this.plugin.stateId], {
+    const browserProps = Object.assign({}, this.plugin.getPluginState(), {
       getNextFolder: this.getNextFolder,
       getNextFolder: this.getNextFolder,
       getFolder: this.getFolder,
       getFolder: this.getFolder,
       addFile: this.addFile,
       addFile: this.addFile,
@@ -407,6 +574,8 @@ module.exports = class View {
       logout: this.logout,
       logout: this.logout,
       demo: this.plugin.opts.demo,
       demo: this.plugin.opts.demo,
       isActiveRow: this.isActiveRow,
       isActiveRow: this.isActiveRow,
+      isChecked: this.isChecked,
+      toggleCheckbox: this.toggleCheckbox,
       getItemName: this.plugin.getItemName,
       getItemName: this.plugin.getItemName,
       getItemIcon: this.plugin.getItemIcon,
       getItemIcon: this.plugin.getItemIcon,
       handleScroll: this.handleScroll,
       handleScroll: this.handleScroll,

+ 17 - 21
src/plugins/Dashboard/index.js

@@ -51,7 +51,12 @@ module.exports = class DashboardUI extends Plugin {
         browse: 'browse',
         browse: 'browse',
         fileProgress: 'File progress: upload speed and ETA',
         fileProgress: 'File progress: upload speed and ETA',
         numberOfSelectedFiles: 'Number of selected files',
         numberOfSelectedFiles: 'Number of selected files',
-        uploadAllNewFiles: 'Upload all new files'
+        uploadAllNewFiles: 'Upload all new files',
+        emptyFolderAdded: 'No files were added from empty folder',
+        folderAdded: {
+          0: 'Added %{smart_count} file from %{folder}',
+          1: 'Added %{smart_count} files from %{folder}'
+        }
       }
       }
     }
     }
 
 
@@ -80,7 +85,7 @@ module.exports = class DashboardUI extends Plugin {
     this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
     this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
 
 
     this.translator = new Translator({locale: this.locale})
     this.translator = new Translator({locale: this.locale})
-    this.containerWidth = this.translator.translate.bind(this.translator)
+    this.i18n = this.translator.translate.bind(this.translator)
 
 
     this.closeModal = this.closeModal.bind(this)
     this.closeModal = this.closeModal.bind(this)
     this.requestCloseModal = this.requestCloseModal.bind(this)
     this.requestCloseModal = this.requestCloseModal.bind(this)
@@ -136,7 +141,7 @@ module.exports = class DashboardUI extends Plugin {
       targets: newTargets
       targets: newTargets
     })
     })
 
 
-    return this.target
+    return this.el
   }
   }
 
 
   hideAllPanels () {
   hideAllPanels () {
@@ -157,10 +162,6 @@ module.exports = class DashboardUI extends Plugin {
     })
     })
   }
   }
 
 
-  // setModalElement (element) {
-  //   this.modal = element
-  // }
-
   requestCloseModal () {
   requestCloseModal () {
     if (this.opts.onRequestCloseModal) {
     if (this.opts.onRequestCloseModal) {
       return this.opts.onRequestCloseModal()
       return this.opts.onRequestCloseModal()
@@ -170,14 +171,14 @@ module.exports = class DashboardUI extends Plugin {
   }
   }
 
 
   getFocusableNodes () {
   getFocusableNodes () {
-    const nodes = this.modal.querySelectorAll(FOCUSABLE_ELEMENTS)
+    const nodes = this.el.querySelectorAll(FOCUSABLE_ELEMENTS)
     return Object.keys(nodes).map((key) => nodes[key])
     return Object.keys(nodes).map((key) => nodes[key])
   }
   }
 
 
   setFocusToFirstNode () {
   setFocusToFirstNode () {
     const focusableNodes = this.getFocusableNodes()
     const focusableNodes = this.getFocusableNodes()
-    console.log(focusableNodes)
-    console.log(focusableNodes[0])
+    // console.log(focusableNodes)
+    // console.log(focusableNodes[0])
     if (focusableNodes.length) focusableNodes[0].focus()
     if (focusableNodes.length) focusableNodes[0].focus()
   }
   }
 
 
@@ -209,9 +210,8 @@ module.exports = class DashboardUI extends Plugin {
     document.body.classList.add('is-UppyDashboard-open')
     document.body.classList.add('is-UppyDashboard-open')
     document.body.style.top = `-${this.savedDocumentScrollPosition}px`
     document.body.style.top = `-${this.savedDocumentScrollPosition}px`
 
 
-    this.setFocusToFirstNode()
-
-    this.updateDashboardElWidth()
+    setTimeout(this.setFocusToFirstNode, 100)
+    setTimeout(this.updateDashboardElWidth, 100)
     // to be sure, sometimes when the function runs, container size is still 0
     // to be sure, sometimes when the function runs, container size is still 0
     // setTimeout(this.updateDashboardElWidth, 500)
     // setTimeout(this.updateDashboardElWidth, 500)
   }
   }
@@ -276,21 +276,19 @@ module.exports = class DashboardUI extends Plugin {
   }
   }
 
 
   actions () {
   actions () {
-    this.core.on('core:file-added', this.hideAllPanels)
     this.core.on('dashboard:file-card', this.handleFileCard)
     this.core.on('dashboard:file-card', this.handleFileCard)
 
 
     window.addEventListener('resize', this.updateDashboardElWidth)
     window.addEventListener('resize', this.updateDashboardElWidth)
   }
   }
 
 
   removeActions () {
   removeActions () {
-    window.removeEventListener('resize', this.updateDashboardElWidth)
-
-    this.core.off('core:file-added', this.hideAllPanels)
     this.core.off('dashboard:file-card', this.handleFileCard)
     this.core.off('dashboard:file-card', this.handleFileCard)
+
+    window.removeEventListener('resize', this.updateDashboardElWidth)
   }
   }
 
 
   updateDashboardElWidth () {
   updateDashboardElWidth () {
-    const dashboardEl = this.target.querySelector('.UppyDashboard-inner')
+    const dashboardEl = this.el.querySelector('.UppyDashboard-inner')
     this.core.log(`Dashboard width: ${dashboardEl.offsetWidth}`)
     this.core.log(`Dashboard width: ${dashboardEl.offsetWidth}`)
 
 
     this.setPluginState({
     this.setPluginState({
@@ -424,7 +422,7 @@ module.exports = class DashboardUI extends Plugin {
       showPanel: this.showPanel,
       showPanel: this.showPanel,
       hideAllPanels: this.hideAllPanels,
       hideAllPanels: this.hideAllPanels,
       log: this.core.log,
       log: this.core.log,
-      i18n: this.containerWidth,
+      i18n: this.i18n,
       pauseAll: this.pauseAll,
       pauseAll: this.pauseAll,
       resumeAll: this.resumeAll,
       resumeAll: this.resumeAll,
       addFile: this.core.addFile,
       addFile: this.core.addFile,
@@ -492,8 +490,6 @@ module.exports = class DashboardUI extends Plugin {
 
 
     this.initEvents()
     this.initEvents()
     this.actions()
     this.actions()
-
-    this.modal = document.querySelector('.UppyDashboard--modal')
   }
   }
 
 
   uninstall () {
   uninstall () {

+ 11 - 38
src/plugins/DragDrop/index.js

@@ -26,10 +26,6 @@ module.exports = class DragDrop extends Plugin {
       strings: {
       strings: {
         dropHereOr: 'Drop files here or',
         dropHereOr: 'Drop files here or',
         browse: 'browse'
         browse: 'browse'
-        // selectedFiles: {
-        //   0: '%{smart_count} file selected',
-        //   1: '%{smart_count} files selected'
-        // }
       }
       }
     }
     }
 
 
@@ -58,8 +54,9 @@ module.exports = class DragDrop extends Plugin {
 
 
     // Bind `this` to class methods
     // Bind `this` to class methods
     this.handleDrop = this.handleDrop.bind(this)
     this.handleDrop = this.handleDrop.bind(this)
+    this.onBrowseClick = this.onBrowseClick.bind(this)
+    this.onInputChange = this.onInputChange.bind(this)
     this.checkDragDropSupport = this.checkDragDropSupport.bind(this)
     this.checkDragDropSupport = this.checkDragDropSupport.bind(this)
-    this.handleInputChange = this.handleInputChange.bind(this)
     this.render = this.render.bind(this)
     this.render = this.render.bind(this)
   }
   }
 
 
@@ -98,7 +95,7 @@ module.exports = class DragDrop extends Plugin {
     })
     })
   }
   }
 
 
-  handleInputChange (ev) {
+  onInputChange (ev) {
     this.core.log('[DragDrop] Files selected through input')
     this.core.log('[DragDrop] Files selected through input')
 
 
     const files = toArray(ev.target.files)
     const files = toArray(ev.target.files)
@@ -113,14 +110,12 @@ module.exports = class DragDrop extends Plugin {
     })
     })
   }
   }
 
 
-  render (state) {
-    const onSelect = (ev) => {
-      const input = this.target.querySelector('.uppy-DragDrop-input')
-      input.click()
-    }
-
-    // const selectedFilesCount = Object.keys(state.files).length
+  onBrowseClick (ev) {
+    const input = this.el.querySelector('.uppy-DragDrop-input')
+    input.click()
+  }
 
 
+  render (state) {
     return html`
     return html`
       <div class="Uppy UppyTheme--default uppy-DragDrop-container ${this.isDragDropSupported ? 'is-dragdrop-supported' : ''}"
       <div class="Uppy UppyTheme--default uppy-DragDrop-container ${this.isDragDropSupported ? 'is-dragdrop-supported' : ''}"
            style="width: ${this.opts.width}; height: ${this.opts.height};">
            style="width: ${this.opts.width}; height: ${this.opts.height};">
@@ -133,8 +128,8 @@ module.exports = class DragDrop extends Plugin {
                  name="files[]"
                  name="files[]"
                  multiple="true"
                  multiple="true"
                  value=""
                  value=""
-                 onchange=${this.handleInputChange.bind(this)} />
-          <label class="uppy-DragDrop-label" onclick=${onSelect}>
+                 onchange=${this.onInputChange} />
+          <label class="uppy-DragDrop-label" onclick=${this.onBrowseClick}>
             ${this.i18n('dropHereOr')}
             ${this.i18n('dropHereOr')}
             <span class="uppy-DragDrop-dragText">${this.i18n('browse')}</span>
             <span class="uppy-DragDrop-dragText">${this.i18n('browse')}</span>
           </label>
           </label>
@@ -144,36 +139,14 @@ module.exports = class DragDrop extends Plugin {
     `
     `
   }
   }
 
 
-  // ${selectedFilesCount > 0
-  // ? html`<div class="uppy-DragDrop-selectedCount">
-  //     ${this.i18n('selectedFiles', {'smart_count': selectedFilesCount})}
-  //   </div>`
-  // : ''}
-
   install () {
   install () {
     const target = this.opts.target
     const target = this.opts.target
     if (target) {
     if (target) {
       this.mount(target, this)
       this.mount(target, this)
     }
     }
-  }
-
-  uninstall () {
-    this.unmount()
-  }
-
-  mount (...args) {
-    super.mount(...args)
-
-    const dndContainer = this.target.querySelector('.uppy-DragDrop-container')
-    this.removeDragDropListener = dragDrop(dndContainer, (files) => {
+    this.removeDragDropListener = dragDrop(this.el, (files) => {
       this.handleDrop(files)
       this.handleDrop(files)
       this.core.log(files)
       this.core.log(files)
     })
     })
   }
   }
-
-  unmount (...args) {
-    this.removeDragDropListener()
-
-    super.unmount(...args)
-  }
 }
 }

+ 12 - 16
src/plugins/Dropbox/index.js

@@ -12,7 +12,6 @@ module.exports = class Dropbox extends Plugin {
     this.type = 'acquirer'
     this.type = 'acquirer'
     this.id = this.opts.id || 'Dropbox'
     this.id = this.opts.id || 'Dropbox'
     this.title = 'Dropbox'
     this.title = 'Dropbox'
-    this.stateId = 'dropbox'
     this.icon = () => html`
     this.icon = () => html`
       <svg class="UppyIcon" width="128" height="118" viewBox="0 0 128 118">
       <svg class="UppyIcon" width="128" height="118" viewBox="0 0 128 118">
         <path d="M38.145.777L1.108 24.96l25.608 20.507 37.344-23.06z"/>
         <path d="M38.145.777L1.108 24.96l25.608 20.507 37.344-23.06z"/>
@@ -23,7 +22,7 @@ module.exports = class Dropbox extends Plugin {
 
 
     // writing out the key explicitly for readability the key used to store
     // writing out the key explicitly for readability the key used to store
     // the provider instance must be equal to this.id.
     // the provider instance must be equal to this.id.
-    this.Dropbox = new Provider(core, {
+    this[this.id] = new Provider(core, {
       host: this.opts.host,
       host: this.opts.host,
       provider: 'dropbox'
       provider: 'dropbox'
     })
     })
@@ -31,7 +30,7 @@ module.exports = class Dropbox extends Plugin {
     this.files = []
     this.files = []
 
 
     this.onAuth = this.onAuth.bind(this)
     this.onAuth = this.onAuth.bind(this)
-    // Visual
+
     this.render = this.render.bind(this)
     this.render = this.render.bind(this)
 
 
     // set default options
     // set default options
@@ -44,18 +43,14 @@ module.exports = class Dropbox extends Plugin {
   install () {
   install () {
     this.view = new View(this)
     this.view = new View(this)
     // Set default state
     // Set default state
-    this.core.setState({
-      // writing out the key explicitly for readability the key used to store
-      // the plugin state must be equal to this.stateId.
-      dropbox: {
-        authenticated: false,
-        files: [],
-        folders: [],
-        directories: [],
-        activeRow: -1,
-        filterInput: '',
-        isSearchVisible: false
-      }
+    this.setPluginState({
+      authenticated: false,
+      files: [],
+      folders: [],
+      directories: [],
+      activeRow: -1,
+      filterInput: '',
+      isSearchVisible: false
     })
     })
 
 
     const target = this.opts.target
     const target = this.opts.target
@@ -65,11 +60,12 @@ module.exports = class Dropbox extends Plugin {
   }
   }
 
 
   uninstall () {
   uninstall () {
+    this.view.tearDown()
     this.unmount()
     this.unmount()
   }
   }
 
 
   onAuth (authenticated) {
   onAuth (authenticated) {
-    this.view.updateState({authenticated})
+    this.setPluginState({ authenticated })
     if (authenticated) {
     if (authenticated) {
       this.view.getFolder()
       this.view.getFolder()
     }
     }

+ 1 - 1
src/plugins/GoldenRetriever/IndexedDBStore.js

@@ -86,7 +86,7 @@ class IndexedDBStore {
       this.ready = IndexedDBStore.cleanup()
       this.ready = IndexedDBStore.cleanup()
         .then(createConnection, createConnection)
         .then(createConnection, createConnection)
     } else {
     } else {
-      this.ready = createConnection
+      this.ready = createConnection()
     }
     }
   }
   }
 
 

+ 11 - 15
src/plugins/GoogleDrive/index.js

@@ -11,7 +11,6 @@ module.exports = class GoogleDrive extends Plugin {
     this.type = 'acquirer'
     this.type = 'acquirer'
     this.id = this.opts.id || 'GoogleDrive'
     this.id = this.opts.id || 'GoogleDrive'
     this.title = 'Google Drive'
     this.title = 'Google Drive'
-    this.stateId = 'googleDrive'
     this.icon = () => html`
     this.icon = () => html`
       <svg aria-hidden="true" class="UppyIcon UppyModalTab-icon" width="28" height="28" viewBox="0 0 16 16">
       <svg aria-hidden="true" class="UppyIcon UppyModalTab-icon" width="28" height="28" viewBox="0 0 16 16">
         <path d="M2.955 14.93l2.667-4.62H16l-2.667 4.62H2.955zm2.378-4.62l-2.666 4.62L0 10.31l5.19-8.99 2.666 4.62-2.523 4.37zm10.523-.25h-5.333l-5.19-8.99h5.334l5.19 8.99z"/>
         <path d="M2.955 14.93l2.667-4.62H16l-2.667 4.62H2.955zm2.378-4.62l-2.666 4.62L0 10.31l5.19-8.99 2.666 4.62-2.523 4.37zm10.523-.25h-5.333l-5.19-8.99h5.334l5.19 8.99z"/>
@@ -20,7 +19,7 @@ module.exports = class GoogleDrive extends Plugin {
 
 
     // writing out the key explicitly for readability the key used to store
     // writing out the key explicitly for readability the key used to store
     // the provider instance must be equal to this.id.
     // the provider instance must be equal to this.id.
-    this.GoogleDrive = new Provider(core, {
+    this[this.id] = new Provider(core, {
       host: this.opts.host,
       host: this.opts.host,
       provider: 'drive',
       provider: 'drive',
       authProvider: 'google'
       authProvider: 'google'
@@ -42,18 +41,14 @@ module.exports = class GoogleDrive extends Plugin {
   install () {
   install () {
     this.view = new View(this)
     this.view = new View(this)
     // Set default state for Google Drive
     // Set default state for Google Drive
-    this.core.setState({
-      // writing out the key explicitly for readability the key used to store
-      // the plugin state must be equal to this.stateId.
-      googleDrive: {
-        authenticated: false,
-        files: [],
-        folders: [],
-        directories: [],
-        activeRow: -1,
-        filterInput: '',
-        isSearchVisible: false
-      }
+    this.setPluginState({
+      authenticated: false,
+      files: [],
+      folders: [],
+      directories: [],
+      activeRow: -1,
+      filterInput: '',
+      isSearchVisible: false
     })
     })
 
 
     const target = this.opts.target
     const target = this.opts.target
@@ -63,11 +58,12 @@ module.exports = class GoogleDrive extends Plugin {
   }
   }
 
 
   uninstall () {
   uninstall () {
+    this.view.tearDown()
     this.unmount()
     this.unmount()
   }
   }
 
 
   onAuth (authenticated) {
   onAuth (authenticated) {
-    this.view.updateState({authenticated})
+    this.setPluginState({ authenticated })
     if (authenticated) {
     if (authenticated) {
       this.view.getFolder('root')
       this.view.getFolder('root')
     }
     }

+ 13 - 19
src/plugins/Instagram/index.js

@@ -11,20 +11,17 @@ module.exports = class Instagram extends Plugin {
     this.type = 'acquirer'
     this.type = 'acquirer'
     this.id = this.opts.id || 'Instagram'
     this.id = this.opts.id || 'Instagram'
     this.title = 'Instagram'
     this.title = 'Instagram'
-    this.stateId = 'instagram'
     this.icon = () => html`
     this.icon = () => html`
       <svg aria-hidden="true" class="UppyIcon UppyModalTab-icon" width="28" height="28" viewBox="0 0 512 512">
       <svg aria-hidden="true" class="UppyIcon UppyModalTab-icon" width="28" height="28" viewBox="0 0 512 512">
-        <path
-          d="M256,49.471c67.266,0,75.233.257,101.8,1.469,24.562,1.121,37.9,5.224,46.778,8.674a78.052,78.052,0,0,1,28.966,18.845,78.052,78.052,0,0,1,18.845,28.966c3.45,8.877,7.554,22.216,8.674,46.778,1.212,26.565,1.469,34.532,1.469,101.8s-0.257,75.233-1.469,101.8c-1.121,24.562-5.225,37.9-8.674,46.778a83.427,83.427,0,0,1-47.811,47.811c-8.877,3.45-22.216,7.554-46.778,8.674-26.56,1.212-34.527,1.469-101.8,1.469s-75.237-.257-101.8-1.469c-24.562-1.121-37.9-5.225-46.778-8.674a78.051,78.051,0,0,1-28.966-18.845,78.053,78.053,0,0,1-18.845-28.966c-3.45-8.877-7.554-22.216-8.674-46.778-1.212-26.564-1.469-34.532-1.469-101.8s0.257-75.233,1.469-101.8c1.121-24.562,5.224-37.9,8.674-46.778A78.052,78.052,0,0,1,78.458,78.458a78.053,78.053,0,0,1,28.966-18.845c8.877-3.45,22.216-7.554,46.778-8.674,26.565-1.212,34.532-1.469,101.8-1.469m0-45.391c-68.418,0-77,.29-103.866,1.516-26.815,1.224-45.127,5.482-61.151,11.71a123.488,123.488,0,0,0-44.62,29.057A123.488,123.488,0,0,0,17.3,90.982C11.077,107.007,6.819,125.319,5.6,152.134,4.369,179,4.079,187.582,4.079,256S4.369,333,5.6,359.866c1.224,26.815,5.482,45.127,11.71,61.151a123.489,123.489,0,0,0,29.057,44.62,123.486,123.486,0,0,0,44.62,29.057c16.025,6.228,34.337,10.486,61.151,11.71,26.87,1.226,35.449,1.516,103.866,1.516s77-.29,103.866-1.516c26.815-1.224,45.127-5.482,61.151-11.71a128.817,128.817,0,0,0,73.677-73.677c6.228-16.025,10.486-34.337,11.71-61.151,1.226-26.87,1.516-35.449,1.516-103.866s-0.29-77-1.516-103.866c-1.224-26.815-5.482-45.127-11.71-61.151a123.486,123.486,0,0,0-29.057-44.62A123.487,123.487,0,0,0,421.018,17.3C404.993,11.077,386.681,6.819,359.866,5.6,333,4.369,324.418,4.079,256,4.079h0Z"/>
-        <path
-          d="M256,126.635A129.365,129.365,0,1,0,385.365,256,129.365,129.365,0,0,0,256,126.635Zm0,213.338A83.973,83.973,0,1,1,339.974,256,83.974,83.974,0,0,1,256,339.973Z"/>
+        <path d="M256,49.471c67.266,0,75.233.257,101.8,1.469,24.562,1.121,37.9,5.224,46.778,8.674a78.052,78.052,0,0,1,28.966,18.845,78.052,78.052,0,0,1,18.845,28.966c3.45,8.877,7.554,22.216,8.674,46.778,1.212,26.565,1.469,34.532,1.469,101.8s-0.257,75.233-1.469,101.8c-1.121,24.562-5.225,37.9-8.674,46.778a83.427,83.427,0,0,1-47.811,47.811c-8.877,3.45-22.216,7.554-46.778,8.674-26.56,1.212-34.527,1.469-101.8,1.469s-75.237-.257-101.8-1.469c-24.562-1.121-37.9-5.225-46.778-8.674a78.051,78.051,0,0,1-28.966-18.845,78.053,78.053,0,0,1-18.845-28.966c-3.45-8.877-7.554-22.216-8.674-46.778-1.212-26.564-1.469-34.532-1.469-101.8s0.257-75.233,1.469-101.8c1.121-24.562,5.224-37.9,8.674-46.778A78.052,78.052,0,0,1,78.458,78.458a78.053,78.053,0,0,1,28.966-18.845c8.877-3.45,22.216-7.554,46.778-8.674,26.565-1.212,34.532-1.469,101.8-1.469m0-45.391c-68.418,0-77,.29-103.866,1.516-26.815,1.224-45.127,5.482-61.151,11.71a123.488,123.488,0,0,0-44.62,29.057A123.488,123.488,0,0,0,17.3,90.982C11.077,107.007,6.819,125.319,5.6,152.134,4.369,179,4.079,187.582,4.079,256S4.369,333,5.6,359.866c1.224,26.815,5.482,45.127,11.71,61.151a123.489,123.489,0,0,0,29.057,44.62,123.486,123.486,0,0,0,44.62,29.057c16.025,6.228,34.337,10.486,61.151,11.71,26.87,1.226,35.449,1.516,103.866,1.516s77-.29,103.866-1.516c26.815-1.224,45.127-5.482,61.151-11.71a128.817,128.817,0,0,0,73.677-73.677c6.228-16.025,10.486-34.337,11.71-61.151,1.226-26.87,1.516-35.449,1.516-103.866s-0.29-77-1.516-103.866c-1.224-26.815-5.482-45.127-11.71-61.151a123.486,123.486,0,0,0-29.057-44.62A123.487,123.487,0,0,0,421.018,17.3C404.993,11.077,386.681,6.819,359.866,5.6,333,4.369,324.418,4.079,256,4.079h0Z"/>
+        <path d="M256,126.635A129.365,129.365,0,1,0,385.365,256,129.365,129.365,0,0,0,256,126.635Zm0,213.338A83.973,83.973,0,1,1,339.974,256,83.974,83.974,0,0,1,256,339.973Z"/>
         <circle cx="390.476" cy="121.524" r="30.23"/>
         <circle cx="390.476" cy="121.524" r="30.23"/>
       </svg>
       </svg>
     `
     `
 
 
     // writing out the key explicitly for readability the key used to store
     // writing out the key explicitly for readability the key used to store
     // the provider instance must be equal to this.id.
     // the provider instance must be equal to this.id.
-    this.Instagram = new Provider(core, {
+    this[this.id] = new Provider(core, {
       host: this.opts.host,
       host: this.opts.host,
       provider: 'instagram',
       provider: 'instagram',
       authProvider: 'instagram'
       authProvider: 'instagram'
@@ -48,18 +45,14 @@ module.exports = class Instagram extends Plugin {
       viewType: 'grid'
       viewType: 'grid'
     })
     })
     // Set default state for Google Drive
     // Set default state for Google Drive
-    this.core.setState({
-      // writing out the key explicitly for readability the key used to store
-      // the plugin state must be equal to this.stateId.
-      instagram: {
-        authenticated: false,
-        files: [],
-        folders: [],
-        directories: [],
-        activeRow: -1,
-        filterInput: '',
-        isSearchVisible: false
-      }
+    this.setPluginState({
+      authenticated: false,
+      files: [],
+      folders: [],
+      directories: [],
+      activeRow: -1,
+      filterInput: '',
+      isSearchVisible: false
     })
     })
 
 
     const target = this.opts.target
     const target = this.opts.target
@@ -69,11 +62,12 @@ module.exports = class Instagram extends Plugin {
   }
   }
 
 
   uninstall () {
   uninstall () {
+    this.view.tearDown()
     this.unmount()
     this.unmount()
   }
   }
 
 
   onAuth (authenticated) {
   onAuth (authenticated) {
-    this.view.updateState({authenticated})
+    this.setPluginState({ authenticated })
     if (authenticated) {
     if (authenticated) {
       this.view.getFolder('recent')
       this.view.getFolder('recent')
     }
     }

+ 10 - 15
src/plugins/Plugin.js

@@ -1,5 +1,5 @@
 const yo = require('yo-yo')
 const yo = require('yo-yo')
-// const nanoraf = require('nanoraf')
+const nanoraf = require('nanoraf')
 const { findDOMElement } = require('../core/Utils')
 const { findDOMElement } = require('../core/Utils')
 const getFormData = require('get-form-data')
 const getFormData = require('get-form-data')
 
 
@@ -62,15 +62,12 @@ module.exports = class Plugin {
 
 
     const targetElement = findDOMElement(target)
     const targetElement = findDOMElement(target)
 
 
-    // Set up nanoraf.
-    // this.updateUI = nanoraf((state) => {
-    //   this.el = yo.update(this.el, this.render(state))
-    // })
-    this.updateUI = (state) => {
-      this.el = yo.update(this.el, this.render(state))
-    }
-
     if (targetElement) {
     if (targetElement) {
+      // Set up nanoraf.
+      this.updateUI = nanoraf((state) => {
+        this.el = yo.update(this.el, this.render(state))
+      })
+
       this.core.log(`Installing ${callerPluginName} to a DOM element`)
       this.core.log(`Installing ${callerPluginName} to a DOM element`)
 
 
       // attempt to extract meta from form element
       // attempt to extract meta from form element
@@ -87,9 +84,7 @@ module.exports = class Plugin {
       this.el = plugin.render(this.core.state)
       this.el = plugin.render(this.core.state)
       targetElement.appendChild(this.el)
       targetElement.appendChild(this.el)
 
 
-      this.target = targetElement
-
-      return targetElement
+      return this.el
     }
     }
 
 
     let targetPlugin
     let targetPlugin
@@ -111,8 +106,8 @@ module.exports = class Plugin {
     if (targetPlugin) {
     if (targetPlugin) {
       const targetPluginName = targetPlugin.id
       const targetPluginName = targetPlugin.id
       this.core.log(`Installing ${callerPluginName} to ${targetPluginName}`)
       this.core.log(`Installing ${callerPluginName} to ${targetPluginName}`)
-      this.target = targetPlugin
-      return targetPlugin.addTarget(plugin)
+      this.el = targetPlugin.addTarget(plugin)
+      return this.el
     }
     }
 
 
     this.core.log(`Not installing ${callerPluginName}`)
     this.core.log(`Not installing ${callerPluginName}`)
@@ -131,7 +126,7 @@ module.exports = class Plugin {
     if (this.el && this.el.parentNode) {
     if (this.el && this.el.parentNode) {
       this.el.parentNode.removeChild(this.el)
       this.el.parentNode.removeChild(this.el)
     }
     }
-    this.target = null
+    // this.target = null
   }
   }
 
 
   install () {
   install () {

+ 7 - 11
src/plugins/Plugin.test.js

@@ -171,11 +171,6 @@ describe('Plugin', () => {
       expect(Plugin.prototype.mount.length).toBe(2)
       expect(Plugin.prototype.mount.length).toBe(2)
     })
     })
 
 
-    it('adds updateUI method', () => {
-      plugin.mount(mockTarget, mockPlugin)
-      expect(typeof plugin.updateUI).toBe('function')
-    })
-
     xit('sets `el` property when state has changed', () => {
     xit('sets `el` property when state has changed', () => {
       expect.assertions(4)
       expect.assertions(4)
 
 
@@ -258,15 +253,16 @@ describe('Plugin', () => {
         expect(mockElement.appendChild.mock.calls[0][0]).toEqual({ el: 'lo' })
         expect(mockElement.appendChild.mock.calls[0][0]).toEqual({ el: 'lo' })
       })
       })
 
 
+      it('adds updateUI method', () => {
+        plugin.mount(mockTarget, mockPlugin)
+        expect(typeof plugin.updateUI).toBe('function')
+      })
+
       it('returns the target DOM element', () => {
       it('returns the target DOM element', () => {
-        plugin = new Plugin(mockCore, {replaceTargetContent: true})
+        plugin = new Plugin(mockCore)
         plugin.render = () => {}
         plugin.render = () => {}
         const target = plugin.mount(mockTarget, mockPlugin)
         const target = plugin.mount(mockTarget, mockPlugin)
-        expect(target).toEqual({
-          nodeName: 'FORM',
-          innerHTML: '',
-          appendChild
-        })
+        expect(target).toEqual({ el: 'lo' })
       })
       })
     })
     })
 
 

+ 173 - 0
src/plugins/Transloadit/index.test.js

@@ -0,0 +1,173 @@
+const Core = require('../../core')
+const Transloadit = require('./')
+
+describe('Transloadit', () => {
+  it('Throws errors if options are missing', () => {
+    const uppy = new Core()
+
+    expect(() => {
+      uppy.use(Transloadit, { params: {} })
+    }).toThrowError(/The `params\.auth\.key` option is required/)
+  })
+
+  it('Accepts a JSON string as `params` for signature authentication', () => {
+    const uppy = new Core()
+
+    expect(() => {
+      uppy.use(Transloadit, {
+        params: 'not json'
+      })
+    }).toThrowError(/The `params` option is a malformed JSON string/)
+
+    expect(() => {
+      uppy.use(Transloadit, {
+        params: '{"template_id":"some template id string"}'
+      })
+    }).toThrowError(/The `params\.auth\.key` option is required/)
+    expect(() => {
+      uppy.use(Transloadit, {
+        params: '{"auth":{"key":"some auth key string"},"template_id":"some template id string"}'
+      })
+    }).not.toThrowError(/The `params\.auth\.key` option is required/)
+  })
+
+  it('Validates response from getAssemblyOptions()', () => {
+    const uppy = new Core({ autoProceed: false })
+
+    uppy.use(Transloadit, {
+      getAssemblyOptions: (file) => {
+        expect(file.name).toBe('testfile')
+        return {
+          params: '{"some":"json"}'
+        }
+      }
+    })
+
+    const data = Buffer.alloc(4000)
+    data.size = data.byteLength
+    return uppy.addFile({
+      name: 'testfile',
+      data
+    }).then(() => {
+      return uppy.upload().then(() => {
+        throw new Error('should have rejected')
+      }, (err) => {
+        expect(err.message).toMatch(/The `params\.auth\.key` option is required/)
+      })
+    })
+  })
+
+  it('Uses different assemblies for different params', () => {
+    const uppy = new Core({ autoProceed: false })
+
+    uppy.use(Transloadit, {
+      getAssemblyOptions: (file) => ({
+        params: {
+          auth: { key: 'fake key' },
+          steps: {
+            fake_step: { data: file.name }
+          }
+        }
+      })
+    })
+
+    const tl = uppy.getPlugin('Transloadit')
+    const files = ['a.png', 'b.png', 'c.png', 'd.png']
+    let i = 0
+    tl.client.createAssembly = (opts) => {
+      expect(opts.params.steps.fake_step.data).toEqual(files[i])
+      i++
+      // Short-circuit upload
+      return Promise.reject('short-circuit') // eslint-disable-line prefer-promise-reject-errors
+    }
+
+    const data = Buffer.alloc(10)
+    data.size = data.byteLength
+
+    return Promise.all([
+      uppy.addFile({ name: 'a.png', data }),
+      uppy.addFile({ name: 'b.png', data }),
+      uppy.addFile({ name: 'c.png', data }),
+      uppy.addFile({ name: 'd.png', data })
+    ]).then(() => {
+      return uppy.upload().then(() => {
+        throw new Error('upload should have been rejected')
+      }, () => {
+        expect(i).toBe(4)
+      })
+    })
+  })
+
+  it('Should merge files with same parameters into one assembly', () => {
+    const uppy = new Core({ autoProceed: false })
+
+    uppy.use(Transloadit, {
+      getAssemblyOptions: (file) => ({
+        params: {
+          auth: { key: 'fake key' },
+          steps: {
+            fake_step: { data: file.size }
+          }
+        }
+      })
+    })
+
+    const tl = uppy.getPlugin('Transloadit')
+    const assemblies = [
+      { data: 10, files: ['a.png', 'b.png', 'c.png'] },
+      { data: 20, files: ['d.png'] }
+    ]
+    let i = 0
+    tl.client.createAssembly = (opts) => {
+      const assembly = assemblies[i]
+      expect(opts.params.steps.fake_step.data).toBe(assembly.data)
+      i++
+      // Short-circuit upload
+      return Promise.reject('short-circuit') // eslint-disable-line prefer-promise-reject-errors
+    }
+
+    const data = Buffer.alloc(10)
+    data.size = data.byteLength
+    const data2 = Buffer.alloc(20)
+    data2.size = data2.byteLength
+
+    return Promise.all([
+      uppy.addFile({ name: 'a.png', data }),
+      uppy.addFile({ name: 'b.png', data }),
+      uppy.addFile({ name: 'c.png', data }),
+      uppy.addFile({ name: 'd.png', data: data2 })
+    ]).then(() => {
+      return uppy.upload().then(() => {
+        throw new Error('Upload should have been rejected')
+      }, () => {
+        expect(i).toBe(2)
+      })
+    })
+  })
+
+  it('Does not create an assembly if no files are being uploaded', () => {
+    const uppy = new Core()
+    uppy.use(Transloadit, {
+      getAssemblyOptions () {
+        throw new Error('should not create assembly')
+      }
+    })
+    uppy.run()
+
+    return uppy.upload()
+  })
+
+  it('Creates an assembly if no files are being uploaded but `alwaysRunAssembly` is enabled', () => {
+    const uppy = new Core()
+    uppy.use(Transloadit, {
+      alwaysRunAssembly: true,
+      getAssemblyOptions (file) {
+        // should call getAssemblyOptions with `null`
+        expect(file).toBe(null)
+        return Promise.reject('short-circuited') // eslint-disable-line prefer-promise-reject-errors
+      }
+    })
+
+    return expect(uppy.upload()).rejects.toBe('short-circuited')
+  })
+})

+ 4 - 1
src/plugins/Webcam/index.js

@@ -124,6 +124,7 @@ module.exports = class Webcam extends Plugin {
     return this.mediaDevices.getUserMedia(constraints)
     return this.mediaDevices.getUserMedia(constraints)
       .then((stream) => {
       .then((stream) => {
         this.stream = stream
         this.stream = stream
+        console.log(stream)
         this.streamSrc = URL.createObjectURL(this.stream)
         this.streamSrc = URL.createObjectURL(this.stream)
         this.setPluginState({
         this.setPluginState({
           cameraReady: true
           cameraReady: true
@@ -191,7 +192,7 @@ module.exports = class Webcam extends Plugin {
   }
   }
 
 
   getVideoElement () {
   getVideoElement () {
-    return this.target.querySelector('.UppyWebcam-video')
+    return this.el.querySelector('.UppyWebcam-video')
   }
   }
 
 
   oneTwoThreeSmile () {
   oneTwoThreeSmile () {
@@ -230,6 +231,8 @@ module.exports = class Webcam extends Plugin {
     }).then((tagFile) => {
     }).then((tagFile) => {
       this.captureInProgress = false
       this.captureInProgress = false
       this.core.addFile(tagFile)
       this.core.addFile(tagFile)
+      const dashboard = this.core.getPlugin('Dashboard')
+      if (dashboard) dashboard.hideAllPanels()
     }, (error) => {
     }, (error) => {
       this.captureInProgress = false
       this.captureInProgress = false
       throw error
       throw error

+ 19 - 5
src/plugins/XHRUpload.js

@@ -5,7 +5,8 @@ const UppySocket = require('../core/UppySocket')
 const {
 const {
   emitSocketProgress,
   emitSocketProgress,
   getSocketHost,
   getSocketHost,
-  settle
+  settle,
+  limitPromises
 } = require('../core/Utils')
 } = require('../core/Utils')
 
 
 module.exports = class XHRUpload extends Plugin {
 module.exports = class XHRUpload extends Plugin {
@@ -32,6 +33,7 @@ module.exports = class XHRUpload extends Plugin {
       headers: {},
       headers: {},
       locale: defaultLocale,
       locale: defaultLocale,
       timeout: 30 * 1000,
       timeout: 30 * 1000,
+      limit: 0,
       getResponseData (xhr) {
       getResponseData (xhr) {
         return JSON.parse(xhr.response)
         return JSON.parse(xhr.response)
       },
       },
@@ -50,6 +52,13 @@ module.exports = class XHRUpload extends Plugin {
     this.i18n = this.translator.translate.bind(this.translator)
     this.i18n = this.translator.translate.bind(this.translator)
 
 
     this.handleUpload = this.handleUpload.bind(this)
     this.handleUpload = this.handleUpload.bind(this)
+
+    // Simultaneous upload limiting is shared across all uploads with this plugin.
+    if (typeof this.opts.limit === 'number' && this.opts.limit !== 0) {
+      this.limitUploads = limitPromises(this.opts.limit)
+    } else {
+      this.limitUploads = (fn) => fn
+    }
   }
   }
 
 
   getOptions (file) {
   getOptions (file) {
@@ -248,19 +257,24 @@ module.exports = class XHRUpload extends Plugin {
   }
   }
 
 
   uploadFiles (files) {
   uploadFiles (files) {
-    const promises = files.map((file, i) => {
+    const actions = files.map((file, i) => {
       const current = parseInt(i, 10) + 1
       const current = parseInt(i, 10) + 1
       const total = files.length
       const total = files.length
 
 
       if (file.error) {
       if (file.error) {
-        return Promise.reject(new Error(file.error))
+        return () => Promise.reject(new Error(file.error))
       } else if (file.isRemote) {
       } else if (file.isRemote) {
-        return this.uploadRemote(file, current, total)
+        return this.uploadRemote.bind(this, file, current, total)
       } else {
       } else {
-        return this.upload(file, current, total)
+        return this.upload.bind(this, file, current, total)
       }
       }
     })
     })
 
 
+    const promises = actions.map((action) => {
+      const limitedAction = this.limitUploads(action)
+      return limitedAction()
+    })
+
     return settle(promises)
     return settle(promises)
   }
   }
 
 

+ 5 - 0
src/scss/_provider.scss

@@ -318,6 +318,11 @@
   margin-right: 3px;
   margin-right: 3px;
 }
 }
 
 
+.BrowserTable-column input {
+  margin-top: 0;
+  cursor: pointer;
+}
+
 // .BrowserTable-itemButton {
 // .BrowserTable-itemButton {
 //   @include reset-button();
 //   @include reset-button();
 //   cursor: pointer;
 //   cursor: pointer;

+ 136 - 0
website/src/_posts/2017-10-0.21.md

@@ -0,0 +1,136 @@
+---
+title: "Uppy 0.21: End to End Tests, Accessibility, Additional Security for Uppy Server"
+date: 2017-11-24
+author: arturi
+published: true
+---
+
+Hi all! We’ve been busy with Halloween, but also another release, so here is Uppy `0.21`! This one improves accessibility, features new end-to-end tests and improved security in Uppy Server.
+
+<!-- more -->
+
+## Accessibility
+
+One of the goals we’ve set out to achieve with Uppy was to make an accessibe file upload widget, and in this release we’ve achieved some more progress in this area:
+
+* Dashboard modal dialog now handles focus better (it’s trapped inside the modal while open);
+* First button is in focus right after the modal dialog is open;
+* More (aria-)labels have been added;
+* Files from remote providers are now selectable with a keyboard.
+
+We plan to continue gradually improving in the accessibility area.
+
+See [#414 PR](https://github.com/transloadit/uppy/pull/414) for details.
+
+## Returning `{ successful, failed }` from `uppy.upload()`
+
+**⚠️ Breaking change**
+
+`uppy.upload()` promise now resolves to a `result` object with two arrays of files: `{ successful, failed }`. This lets you handle succesful and failed uploads in one go:  
+
+```js
+uppy.upload().then((result) => {
+  console.info('Successful uploads:', result.successful)
+  if (result.failed.length > 0) {
+    console.error('Errors:')
+    result.failed.forEach((file) => {
+      console.error(file.error)
+    })
+  }
+})
+
+// or
+
+uppy.on('core:complete', ({ successful, failed }) => {
+  if (failed.length === 0) {
+    console.log('UPLOAD SUCCESSFUL!!!')
+  } else {
+    console.warn('UPLOAD FAILED!!!')
+  }
+  console.log('successful files:', successful)
+  console.log('failed files:', failed)
+})
+```
+
+See [`uppy.upload()`](https://uppy.io/docs/uppy/#uppy-upload) and [`core:complete`](https://uppy.io/docs/uppy/#core-complete) in docs, as well as [#404 PR](https://github.com/transloadit/uppy/pull/404) for more details.
+
+## End to end tests
+
+We’ve refactored end to end tests to use [Webdriver.io](http://webdriver.io), and for the occasion added tests for Edge, Safari, Android and iOS. Now tests on Travis and Sauce Labs (thanks for the open source tier!) run smoothly and it’s easier to alter them or write new ones.
+
+## More secure Uppy Server
+
+We made sure access tokens from third-party providers, such as Google Drive or Instagram, are not stored on the server with Uppy Server, and kept in your browser instead. Then, when you want to pick a file from your Instagram, the token is used to make a request. Even though they live on the browser, these tokens are encrypted with JWT on the server side, before they are being sent to the client. So they can only be decrypted and understood on the server side.
+
+Data validation is also now done during intiation of an upload, to prevent corrupt data from triggering funny behaviours on the server. :)
+
+And, while we were at this, we also made sure all sensitive data is masked in request logs.
+
+## Migration to Dropbox v2 API
+
+Since the [deprecation of Dropbox v1 API](https://blogs.dropbox.com/developers/2017/09/api-v1-shutdown-details/), uppy/uppy-server have now been updated to use the v2 API. It is implemented to work closely as its previous implementation so there should be no worries.
+
+## Custom plugin id
+
+Up until now you could only use a plugin once with an Uppy instance. Now you can pass custom `id` for any plugin (though this has to be manually implemented in any new plugin). This allows using, for example, two `StatusBar`s, one inside the `Dashboard`, and one somewhere on the page, visible even when `Dashboard` is closed.
+
+Can be used like this:
+
+```js
+// one StatusBar comes included in the Dashboard, another will be mounted on the page
+.use(Dashboard {...})
+.use(StatusBar, { id: 'PageStatusBar', target: 'body' }
+```
+
+See [#418 PR](https://github.com/transloadit/uppy/pull/418) for details.
+
+## Misc good stuff
+
+- Migrated Dropbox in Uppy Server to use v2 API.
+- Fixed generating thumbnails for images with transparent background.
+- We are now using [`tinyify`](https://github.com/goto-bus-stop/tinyify)(by our own [@goto-bus-stop](https://github.com/goto-bus-stop)) for the Uppy bundle to make it smaller and more efficient.
+- Instead of restarting only the file upload itself, start an entirely new upload for retries. Fixes retrying uploads with processing plugins.
+- The S3 plugin now includes XHRUpload. **⚠️ Breaking change: you should remove `.use(XHRUpload)` when using S3.**
+- XHRUpload now includes a timeout `opts.timeout = 30000`, after which it errors and offers a retry (retry UI supported in Dashboard), see [#378](https://github.com/transloadit/uppy/pull/378) for more.
+- Renamed `RestoreFiles` → `GoldenRetriever`, and `Tus10` → `Tus`. **⚠️ Breaking change: please make sure to use the new names when setting up plugins**.
+- The Webcam plugin has been refactored. There’s no flash fallback now, it works in modern browsers only, and the Webcam tab won’t appear in the Dashboard if a camera is not supported on the device.
+
+## Full Changelog
+
+Here is the full list of changes for version `0.21.0` (and patches `0.20.1`, `0.20.2`, `0.20.3`):
+
+- accessibility: add tabindex="0" to buttons and tabs, aria-labels, focus (#414 / @arturi)
+- core: allow setting custom `id` for plugins to allow a plugin to be used multiple times (#418 / @arturi)
+- core: do not check isPreviewSupported for unknown filetypes (#417 / @sadovnychyi)
+- core: refactor `uppy-base` (#382 / @goto-bus-stop)
+- core: remove functions from state object (#408 / @goto-bus-stop)
+- core: return `{ successful, failed }` from `uppy.upload()` (#404 / @goto-bus-stop)
+- core: update state with error messages rather than error objects (#406 / @richardwillars)
+- core: use `tinyify` for the unpkg bundle. (#371 / @goto-bus-stop)
+- dashboard: Fix pasting files, default `image` file name, add type to meta, file type refactor (#395 / @arturi)
+- dragdrop: Fix of the .uppy-DragDrop-inner spacing on small screens (#405 / @nqst)
+- react: fix `uppy` PropType, closes (#416 / @goto-bus-stop)
+- s3: automatically wrap XHRUpload. Users should remove `.use(XHRUpload)` when using S3. (#408 / @goto-bus-stop)
+- test: refactored end-to-end tests to not use website, switched to Webdriver.io, added tests for Edge, Safari, Android and iOS (#410 / @arturi)
+- tus: Rename Tus10 → Tus (#285 / @goto-bus-stop)
+- uppy-serer: mask sensitive data from request logs (@ifedapoolarewaju)
+- uppy-server: add request body validators (@ifedapoolarewaju)
+- uppy-server: migrate dropbox to use v2 API (#386 / @ifedapoolarewaju)
+- uppy-server: store tokens in user’s browser only (@ifedapoolarewaju)
+- webcam: only show the webcam tab when browser support is available (media recorder API) (#421 / @arturi, @goto-bus-stop)
+- webcam: simplify and refactor webcam plugin (modern browser APIs only) (#382 / @goto-bus-stop)
+- xhrupload: set a timeout in the onprogress event handler to detect stale network (#378 / @goto-bus-stop)
+- uppy-server: allow flexible whitelist endpoint protocols (@ifedapoolarewaju)
+- core: Start a completely new upload when retrying. (#390 / @goto-bus-stop)
+- dashboard: Show errors that occurred during processing on the file items. (#391 / @goto-bus-stop)
+- transloadit: Mark files as having errored if their assembly fails. (#392 / @goto-bus-stop)
+- core: Clear file upload progress when an upload starts. (#393 / @goto-bus-stop)
+- tus: Clean up `tus.Upload` instance and events when an upload starts, finishes, or fails. (#390 / @goto-bus-stop)
+- docs: fix `getMetaFromForm` documentation (@arturi)
+- core: fix generating thumbnails for images with transparent background (#380 / @goto-bus-stop)
+- transloadit: use Translator class for localised strings (#383 / @goto-bus-stop)
+- goldenretriever: don't crash when required server-side (#384 / @goto-bus-stop)
+- redux: add plugin for syncing uppy state with a Redux store (#376 / @richardwillars)
+
+
+The Uppy Team

+ 4 - 0
website/src/docs/xhrupload.md

@@ -108,5 +108,9 @@ Set to `0` to disable this check.
 
 
 The default is 30 seconds.
 The default is 30 seconds.
 
 
+### `limit: 0`
+
+Limit the amount of uploads going on at the same time. Passing `0` means no limit.
+
 [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
 [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
 [XHR.timeout]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/timeout
 [XHR.timeout]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/timeout

+ 1 - 8
website/themes/uppy/layout/layout.ejs

@@ -47,14 +47,7 @@
           </ul>
           </ul>
         </nav>
         </nav>
 
 
-        <a href="https://github.com/transloadit/uppy" class="github-corner" aria-label="View source on Github">
-          <svg width="70" height="70" viewBox="0 0 250 250" style="fill:#E0217D; color:#fff; position: absolute; top: 0; border: 0; right: 0;" aria-hidden="true">
-            <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
-            <path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path>
-            <path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path>
-          </svg>
-        </a>
-        <style></style>
+        <a href="https://github.com/transloadit/uppy" class="github-corner" aria-label="View source on Github"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:#e02177; color:#fff; position: absolute; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a><style>.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}</style>
       </header>
       </header>
 
 
       <main class="MainContent js-MainContent" id="main">
       <main class="MainContent js-MainContent" id="main">

+ 0 - 6
website/themes/uppy/source/css/_common.scss

@@ -342,12 +342,6 @@ a.button {
   }
   }
 }
 }
 
 
-/**
-* GitHub corner
-*/
-
-.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
-
 /**
 /**
 * BuildBadge
 * BuildBadge
 */
 */

Some files were not shown because too many files changed in this diff