Browse Source

Merge branch 'master' into improvement/hide-action-buttons

Artur Paikin 6 years ago
parent
commit
618a543734

+ 24 - 15
CHANGELOG.md

@@ -41,15 +41,14 @@ PRs are welcome! Please do open an issue to discuss first if it's a big feature,
 - [ ] statusbar: add option to always show
 - [ ] 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
-- [ ] WordPress plugin
+- [ ] WordPress plugin https://www.producthunt.com/posts/uppy-io#comment-559327 (“And Gravity forms”)
 - [ ] 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
 - [ ] feature: React Native support
 - [ ] 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
-- [ ] It would be nice in the long run to have a dynamic package builder here right on the website where you can select the plugins you need/want and it builds and downloads a minified version of them?
-Sort of like jQuery UI: https://jqueryui.com/download/
+- [ ] It would be nice in the long run to have a dynamic package builder here right on the website where you can select the plugins you need/want and it builds and downloads a minified version of them? Sort of like jQuery UI: https://jqueryui.com/download/
 - [ ] test: add https://github.com/pa11y/pa11y for automated accessibility testing?
 - [ ] test: add tests for `npm pack`
 - [ ] test: add deepFreeze to test that state in not mutated anywhere by accident #320
@@ -73,22 +72,25 @@ Sort of like jQuery UI: https://jqueryui.com/download/
 
 What we need to do to release Uppy 1.0
 
-- [ ] 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 in multiple browsers and mobile devices again
-- [x] QA: test uppy server. benchmarks / stress test. multiple connections, different setups, large files (10 GB)
-- [ ] QA: tests for some plugins
+- [ ] website: big release blog post
+- [ ] ~refactoring: Make `uppy-server` module live in main Uppy repo in `./server` as a second stage todo (after Lerna is done and we're happy) (@ife)
+- [ ] QA: manually test in multiple browsers and mobile devices again (SauceLabs can do Android/iOS too) (@nqst)
+- [ ] QA: add one integration test that uses a Webpack and React/Redux environment (e.g. via `create-react-app`) (@goto-bus-stop)
+- [ ] QA: add one integration test that uses a Provider (investigate if possible with a dedicated Google Drive API key for uppy server, so _with_ oauth dance) (@ife)
+- [ ] QA: add one integration test that uses more exotic (tus) options such as `useFastRemoteRetry` (@arturi)
+- [ ] QA: make it so that all integration tests use `npm pack` and `npm install` first (@ife)
+- [ ] feature: preset for Transloadit that mimics jQuery SDK, check https://github.com/transloadit/jquery-sdk docs (@goto-bus-stop)
+- [ ] feature: basic React Native support (@arturi owner+ios, @ife android)
+- [ ] refactoring: split uppy into small packages, Lerna.js repo? and figure out how to share styles (during work, maybe add PR warning in `.github/*`? use `git mv` for everything) (@goto-bus-stop, @arturi)
 - [x] docs: on using plugins, all options, list of plugins, i18n
-- [ ] feature: preset for Transloadit that mimics jQuery SDK, check https://github.com/transloadit/jquery-sdk docs
-- [ ] refactoring: possibly add CSS-in-JS, style encapsulation
-- [x] 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: split uppy into small packages, lerna repo?
-- [x] QA: tests for core and utils
-- [ ] feature: basic Reacte Native support
-- [x] feature: Redux and ReduxDevTools support (currently mirrors Uppy state to Redux)
 - [x] feature: beta file recovering after closed tab / browser crash
 - [x] feature: easy integration with React (UppyReact components)
 - [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
+- [x] feature: Redux and ReduxDevTools support (currently mirrors Uppy state to Redux)
 - [x] feature: restrictions: by size, number of files, file type
+- [x] QA: test uppy server. benchmarks / stress test. multiple connections, different setups, large files (10 GB)
+- [x] QA: tests for core and utils
+- [x] 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
 - [x] refactoring: webcam plugin
 - [x] uppy-server: add uppy-server to main API service to scale it horizontally. for the standalone server, we could write the script to support multiple clusters. Not sure how required or neccessary this may be for Transloadit's API service.
 - [x] uppy-server: better error handling, general cleanup (remove unused code. etc)
@@ -119,16 +121,23 @@ To Be Released: 2018-05-31.
 - [ ] uppy-server: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB)
 - [ ] uppy-server: document docker image setup for uppy-server (@ifedapoolarewaju)
 - [ ] xhrupload: emit a final `upload-progress` event in the XHRUpload plugin just before firing `upload-complete` (tus-js-client already handles this internally) (@arturi)
-- [ ] core: add more mime-to-extension mappings from https://github.com/micnic/mime.json/blob/master/index.json (which ones?) # (@arturi, @goto-bus-stop)
+- [x] core: add more mime-to-extension mappings from https://github.com/micnic/mime.json/blob/master/index.json (which ones?) (#806 /@arturi, @goto-bus-stop)
 - [ ] providers: select files only after “select” is pressed, don’t add them right away when they are checked (keep a list of fileIds in state?); better UI + solves issue with autoProceed uploading in background, which is weird; re-read https://github.com/transloadit/uppy/pull/419#issuecomment-345210519 (@arturi, @goto-bus-stop)
 - [x] tus: add `filename` and `filetype`, so that tus servers knows what headers to set  https://github.com/tus/tus-js-client/commit/ebc5189eac35956c9f975ead26de90c896dbe360 (#844 / @vith)
 - [ ] core: look into utilizing https://github.com/que-etc/resize-observer-polyfill for responsive components. See also https://github.com/transloadit/uppy/issues/750
 - [x] core: ⚠️ **breaking** removed .run() (to solve issues like #756), update ddocs (#793 / goto-bus-stop)
 - [ ] core: use Browserslist config to share between PostCSS, Autoprefixer and Babel https://github.com/browserslist/browserslist, https://github.com/amilajack/eslint-plugin-compat (@arturi)
 - [ ] core: utilize https://github.com/jonathantneal/postcss-preset-env, maybe https://github.com/jonathantneal/postcss-normalize (@arturi)
+- [ ] core: addFile not passing restrictions shouldn’t throw when called from UI
 - [ ] docs: improve on React docs https://uppy.io/docs/react/, add small example for each component maybe? Dashboard, DragDrop, ProgressBar? No need to make separate pages for all of them, just headings on the same page. Right now docs are confusing, because they focus on DashboardModal. Also problems with syntax highlight on https://uppy.io/docs/react/dashboard-modal/ (@goto-bus-stop)
 - [x] docs: individual React component pages, more plugin options, better groups (#845 / @goto-bus-stop)
 - [x] core: ⚠️ **breaking** Changed some of the strings that we were concatenating in Preact, now their interpolation is handled by the Translator instead. This is important for languages that have different word order than English. (#845 / @goto-bus-stop)
+  Changed strings:
+    - core: `failedToUpload` needs to contain `%{file}`, substituted by the name of the file that failed
+    - dashboard: `dropPaste` and `dropPasteImport` need to contain `%{browse}`, substituted by the "browse" text button
+    - dashboard: `editing` needs to contain `%{file}`, substituted by the name of the file being edited
+    - dashboard: `fileSource` and `importFrom` need to contain `%{name}`, substituted by the name of the provider
+    - dragdrop: `dropHereOr` needs to contain `%{browse}`, substituted by the "browse" text button
 - [ ] core: customizing metadata fields, boolean metadata; see #809, #454 and related (@arturi)
 - [x] providers: Add user/account names to Uppy provider views (61bf0a7 / @ifedapoolarewaju)
 - [x] s3: implement multipart uploads (#726 / @goto-bus-stop)

+ 409 - 0
package-lock.json

@@ -101,6 +101,12 @@
       "integrity": "sha1-yRNQTT3CgQr61VW1ma6uwsxMZ2g=",
       "dev": true
     },
+    "@types/node": {
+      "version": "10.1.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-10.1.3.tgz",
+      "integrity": "sha512-GiCx7dRvta0hbxXoJFAUxz+CKX6bZSCKjM5slq2vPp/5zwK01T4ibYZkGr6EN4F2QmxDQR76/ZHg6q+7iFWCWw==",
+      "dev": true
+    },
     "JSONStream": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.1.tgz",
@@ -2182,6 +2188,12 @@
         }
       }
     },
+    "boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=",
+      "dev": true
+    },
     "boom": {
       "version": "2.10.1",
       "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
@@ -3249,6 +3261,20 @@
       "integrity": "sha1-7tY7usnqSaDiagljFAWLA7CN1is=",
       "dev": true
     },
+    "cheerio": {
+      "version": "1.0.0-rc.2",
+      "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz",
+      "integrity": "sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=",
+      "dev": true,
+      "requires": {
+        "css-select": "~1.2.0",
+        "dom-serializer": "~0.1.0",
+        "entities": "~1.1.1",
+        "htmlparser2": "^3.9.1",
+        "lodash": "^4.15.0",
+        "parse5": "^3.0.1"
+      }
+    },
     "chokidar": {
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
@@ -3953,12 +3979,30 @@
         "css": "^2.0.0"
       }
     },
+    "css-select": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
+      "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
+      "dev": true,
+      "requires": {
+        "boolbase": "~1.0.0",
+        "css-what": "2.1",
+        "domutils": "1.5.1",
+        "nth-check": "~1.0.1"
+      }
+    },
     "css-value": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/css-value/-/css-value-0.0.1.tgz",
       "integrity": "sha1-Xv1sLupeof1rasV+wEJ7GEUkJOo=",
       "dev": true
     },
+    "css-what": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz",
+      "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=",
+      "dev": true
+    },
     "cssnano": {
       "version": "3.10.0",
       "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.10.0.tgz",
@@ -4454,6 +4498,12 @@
         }
       }
     },
+    "discontinuous-range": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
+      "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=",
+      "dev": true
+    },
     "dns-prefetch-control": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz",
@@ -4478,12 +4528,36 @@
         }
       }
     },
+    "dom-serializer": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz",
+      "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=",
+      "dev": true,
+      "requires": {
+        "domelementtype": "~1.1.1",
+        "entities": "~1.1.1"
+      },
+      "dependencies": {
+        "domelementtype": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz",
+          "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=",
+          "dev": true
+        }
+      }
+    },
     "domain-browser": {
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz",
       "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=",
       "dev": true
     },
+    "domelementtype": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz",
+      "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=",
+      "dev": true
+    },
     "domexception": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
@@ -4493,6 +4567,25 @@
         "webidl-conversions": "^4.0.2"
       }
     },
+    "domhandler": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
+      "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
+      "dev": true,
+      "requires": {
+        "domelementtype": "1"
+      }
+    },
+    "domutils": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
+      "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
+      "dev": true,
+      "requires": {
+        "dom-serializer": "0",
+        "domelementtype": "1"
+      }
+    },
     "dont-sniff-mimetype": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.0.0.tgz",
@@ -4742,6 +4835,12 @@
         "has-binary2": "~1.0.2"
       }
     },
+    "entities": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz",
+      "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=",
+      "dev": true
+    },
     "envify": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/envify/-/envify-4.1.0.tgz",
@@ -4760,6 +4859,76 @@
         }
       }
     },
+    "enzyme": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.3.0.tgz",
+      "integrity": "sha512-l8csyPyLmtxskTz6pX9W8eDOyH1ckEtDttXk/vlFWCjv00SkjTjtoUrogqp4yEvMyneU9dUJoOLnqFoiHb8IHA==",
+      "dev": true,
+      "requires": {
+        "cheerio": "^1.0.0-rc.2",
+        "function.prototype.name": "^1.0.3",
+        "has": "^1.0.1",
+        "is-boolean-object": "^1.0.0",
+        "is-callable": "^1.1.3",
+        "is-number-object": "^1.0.3",
+        "is-string": "^1.0.4",
+        "is-subset": "^0.1.1",
+        "lodash": "^4.17.4",
+        "object-inspect": "^1.5.0",
+        "object-is": "^1.0.1",
+        "object.assign": "^4.1.0",
+        "object.entries": "^1.0.4",
+        "object.values": "^1.0.4",
+        "raf": "^3.4.0",
+        "rst-selector-parser": "^2.2.3"
+      },
+      "dependencies": {
+        "function-bind": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+          "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+          "dev": true
+        },
+        "object.assign": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
+          "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
+          "dev": true,
+          "requires": {
+            "define-properties": "^1.1.2",
+            "function-bind": "^1.1.1",
+            "has-symbols": "^1.0.0",
+            "object-keys": "^1.0.11"
+          }
+        }
+      }
+    },
+    "enzyme-adapter-react-16": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.1.1.tgz",
+      "integrity": "sha512-kC8pAtU2Jk3OJ0EG8Y2813dg9Ol0TXi7UNxHzHiWs30Jo/hj7alc//G1YpKUsPP1oKl9X+Lkx+WlGJpPYA+nvw==",
+      "dev": true,
+      "requires": {
+        "enzyme-adapter-utils": "^1.3.0",
+        "lodash": "^4.17.4",
+        "object.assign": "^4.0.4",
+        "object.values": "^1.0.4",
+        "prop-types": "^15.6.0",
+        "react-reconciler": "^0.7.0",
+        "react-test-renderer": "^16.0.0-0"
+      }
+    },
+    "enzyme-adapter-utils": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.3.0.tgz",
+      "integrity": "sha512-vVXSt6uDv230DIv+ebCG66T1Pm36Kv+m74L1TrF4kaE7e1V7Q/LcxO0QRkajk5cA6R3uu9wJf5h13wOTezTbjA==",
+      "dev": true,
+      "requires": {
+        "lodash": "^4.17.4",
+        "object.assign": "^4.0.4",
+        "prop-types": "^15.6.0"
+      }
+    },
     "error-ex": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz",
@@ -7498,6 +7667,25 @@
       "integrity": "sha1-FhdnFMgBeY5Ojyz391KUZ7tKV3E=",
       "dev": true
     },
+    "function.prototype.name": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.0.tgz",
+      "integrity": "sha512-Bs0VRrTz4ghD8pTmbJQD1mZ8A/mN0ur/jGz+A6FBxPDUPkm1tNfF6bhTYPA7i7aF4lZJVr+OXTNNrnnIl58Wfg==",
+      "dev": true,
+      "requires": {
+        "define-properties": "^1.1.2",
+        "function-bind": "^1.1.1",
+        "is-callable": "^1.1.3"
+      },
+      "dependencies": {
+        "function-bind": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+          "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+          "dev": true
+        }
+      }
+    },
     "functional-red-black-tree": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
@@ -7917,6 +8105,12 @@
       "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.1.tgz",
       "integrity": "sha512-JkaetveU7hFbqnAC1EV1sF4rlojU2D4Usc5CmS69l6NfmPDnpnFUegzFg33eDkkpNCxZ0mQp65HwUDrNFS/8MA=="
     },
+    "has-symbols": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz",
+      "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=",
+      "dev": true
+    },
     "has-to-string-tag-x": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz",
@@ -8110,6 +8304,20 @@
       "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=",
       "dev": true
     },
+    "htmlparser2": {
+      "version": "3.9.2",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz",
+      "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=",
+      "dev": true,
+      "requires": {
+        "domelementtype": "^1.3.0",
+        "domhandler": "^2.3.0",
+        "domutils": "^1.5.1",
+        "entities": "^1.1.1",
+        "inherits": "^2.0.1",
+        "readable-stream": "^2.0.2"
+      }
+    },
     "http-errors": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.1.tgz",
@@ -8395,6 +8603,12 @@
         "binary-extensions": "^1.0.0"
       }
     },
+    "is-boolean-object": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.0.0.tgz",
+      "integrity": "sha1-mPiygDBoQhmpXzdc+9iM40Bd/5M=",
+      "dev": true
+    },
     "is-buffer": {
       "version": "1.1.5",
       "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz",
@@ -8600,6 +8814,12 @@
         "lodash.isfinite": "^3.3.2"
       }
     },
+    "is-number-object": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.3.tgz",
+      "integrity": "sha1-8mWrian0RQNO9q/xWo8AsA9VF5k=",
+      "dev": true
+    },
     "is-obj": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
@@ -8760,6 +8980,12 @@
       "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.4.tgz",
       "integrity": "sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ="
     },
+    "is-subset": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
+      "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
+      "dev": true
+    },
     "is-svg": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.1.0.tgz",
@@ -11353,6 +11579,12 @@
       "integrity": "sha1-9HGh2khr5g9quVXRcRVSPdHSVdU=",
       "dev": true
     },
+    "lodash.flattendeep": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+      "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
+      "dev": true
+    },
     "lodash.includes": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -11994,6 +12226,26 @@
       "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
       "dev": true
     },
+    "nearley": {
+      "version": "2.13.0",
+      "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.13.0.tgz",
+      "integrity": "sha512-ioYYogSaZhFlCpRizQgY3UT3G1qFXmHGY/5ozoFE3dMfiCRAeJfh+IPE3/eh9gCZvqLhPCWb4bLt7Bqzo+1mLQ==",
+      "dev": true,
+      "requires": {
+        "nomnom": "~1.6.2",
+        "railroad-diagrams": "^1.0.0",
+        "randexp": "0.4.6",
+        "semver": "^5.4.1"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
+          "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
+          "dev": true
+        }
+      }
+    },
     "negotiator": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
@@ -12767,6 +13019,24 @@
       "integrity": "sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=",
       "dev": true
     },
+    "nomnom": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.6.2.tgz",
+      "integrity": "sha1-hKZqJgF0QI/Ft3oY+IjszET7aXE=",
+      "dev": true,
+      "requires": {
+        "colors": "0.5.x",
+        "underscore": "~1.4.4"
+      },
+      "dependencies": {
+        "colors": {
+          "version": "0.5.1",
+          "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz",
+          "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=",
+          "dev": true
+        }
+      }
+    },
     "nopt": {
       "version": "3.0.6",
       "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
@@ -12971,6 +13241,15 @@
         "set-blocking": "~2.0.0"
       }
     },
+    "nth-check": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz",
+      "integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=",
+      "dev": true,
+      "requires": {
+        "boolbase": "~1.0.0"
+      }
+    },
     "num2fraction": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
@@ -13016,6 +13295,18 @@
         "to-property-key-x": "^2.0.1"
       }
     },
+    "object-inspect": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz",
+      "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==",
+      "dev": true
+    },
+    "object-is": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz",
+      "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=",
+      "dev": true
+    },
     "object-keys": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz",
@@ -13350,6 +13641,15 @@
         "error-ex": "^1.2.0"
       }
     },
+    "parse5": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
+      "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "parseqs": {
       "version": "0.0.5",
       "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
@@ -14477,6 +14777,39 @@
       "integrity": "sha1-EIOSF/bBNiuJGUBE0psjP9fzLwE=",
       "dev": true
     },
+    "raf": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.0.tgz",
+      "integrity": "sha512-pDP/NMRAXoTfrhCfyfSEwJAKLaxBU9eApMeBPB1TkDouZmvPerIClV8lTAd+uF8ZiTaVl69e1FCxQrAd/VTjGw==",
+      "dev": true,
+      "requires": {
+        "performance-now": "^2.1.0"
+      },
+      "dependencies": {
+        "performance-now": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+          "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
+          "dev": true
+        }
+      }
+    },
+    "railroad-diagrams": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
+      "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=",
+      "dev": true
+    },
+    "randexp": {
+      "version": "0.4.6",
+      "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
+      "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
+      "dev": true,
+      "requires": {
+        "discontinuous-range": "1.0.0",
+        "ret": "~0.1.10"
+      }
+    },
     "random-bytes": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@@ -14617,6 +14950,60 @@
         }
       }
     },
+    "react": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-16.4.0.tgz",
+      "integrity": "sha512-K0UrkLXSAekf5nJu89obKUM7o2vc6MMN9LYoKnCa+c+8MJRAT120xzPLENcWSRc7GYKIg0LlgJRDorrufdglQQ==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0"
+      }
+    },
+    "react-dom": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.4.0.tgz",
+      "integrity": "sha512-bbLd+HYpBEnYoNyxDe9XpSG2t9wypMohwQPvKw8Hov3nF7SJiJIgK56b46zHpBUpHb06a1iEuw7G3rbrsnNL6w==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0"
+      }
+    },
+    "react-is": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.4.0.tgz",
+      "integrity": "sha512-8ADZg/mBw+t2Fbr5Hm1K64v8q8Q6E+DprV5wQ5A8PSLW6XP0XJFMdUskVEW8efQ5oUgWHn8EYdHEPAMF0Co6hA==",
+      "dev": true
+    },
+    "react-reconciler": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.7.0.tgz",
+      "integrity": "sha512-50JwZ3yNyMS8fchN+jjWEJOH3Oze7UmhxeoJLn2j6f3NjpfCRbcmih83XTWmzqtar/ivd5f7tvQhvvhism2fgg==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0"
+      }
+    },
+    "react-test-renderer": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.4.0.tgz",
+      "integrity": "sha512-Seh1t9xFY6TKiV/hRlPzUkqX1xHOiKIMsctfU0cggo1ajsLjoIJFL520LlrxV+4/VIj+clrCeH6s/aVv/vTStg==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0",
+        "react-is": "^16.4.0"
+      }
+    },
     "read-all-stream": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz",
@@ -15158,6 +15545,12 @@
         "onetime": "^1.0.0"
       }
     },
+    "ret": {
+      "version": "0.1.15",
+      "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+      "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+      "dev": true
+    },
     "rgb2hex": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.1.0.tgz",
@@ -15205,6 +15598,16 @@
       "integrity": "sha1-IPbld0Ih5RkZdjndmsBLe63sls0=",
       "dev": true
     },
+    "rst-selector-parser": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",
+      "integrity": "sha1-gbIw6i/MYGbInjRy3nlChdmwPZE=",
+      "dev": true,
+      "requires": {
+        "lodash.flattendeep": "^4.4.0",
+        "nearley": "^2.7.10"
+      }
+    },
     "run-async": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz",
@@ -16889,6 +17292,12 @@
         }
       }
     },
+    "underscore": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz",
+      "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=",
+      "dev": true
+    },
     "uniq": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",

+ 4 - 0
package.json

@@ -63,6 +63,8 @@
     "chalk": "1.1.3",
     "cssnano": "^3.10.0",
     "disc": "^1.3.3",
+    "enzyme": "^3.3.0",
+    "enzyme-adapter-react-16": "^1.1.1",
     "eslint": "^3.19.0",
     "eslint-config-standard": "^10.2.1",
     "eslint-config-standard-preact": "^1.1.6",
@@ -89,6 +91,8 @@
     "postcss": "^6.0.16",
     "postcss-safe-important": "^1.1.0",
     "pre-commit": "^1.2.2",
+    "react": "^16.4.0",
+    "react-dom": "^16.4.0",
     "redux": "^3.7.2",
     "replace-x": "^1.5.0",
     "sass": "0.5.0",

+ 8 - 3
src/core/Core.js

@@ -172,7 +172,7 @@ class Uppy {
   }
 
   /**
-  * Back compat for when this.state is used instead of this.getState().
+  * Back compat for when uppy.state is used instead of uppy.getState().
   */
   get state () {
     return this.getState()
@@ -182,6 +182,10 @@ class Uppy {
   * Shorthand to set state for a specific file.
   */
   setFileState (fileID, state) {
+    if (!this.getState().files[fileID]) {
+      throw new Error(`Can’t set state for ${fileID} (the file could have been removed)`)
+    }
+
     this.setState({
       files: Object.assign({}, this.getState().files, {
         [fileID]: Object.assign({}, this.getState().files[fileID], state)
@@ -451,7 +455,7 @@ class Uppy {
   }
 
   removeFile (fileID) {
-    const { files, currentUploads } = this.state
+    const { files, currentUploads } = this.getState()
     const updatedFiles = Object.assign({}, files)
     const removedFile = updatedFiles[fileID]
     delete updatedFiles[fileID]
@@ -582,7 +586,8 @@ class Uppy {
 
     this.setState({
       files: {},
-      totalProgress: 0
+      totalProgress: 0,
+      error: null
     })
   }
 

+ 71 - 70
src/core/Core.test.js

@@ -160,7 +160,7 @@ describe('src/Core', () => {
         totalProgress: 0
       }
 
-      expect(core.state).toEqual(newState)
+      expect(core.getState()).toEqual(newState)
 
       expect(core.plugins.acquirer[0].mocks.update.mock.calls[1]).toEqual([
         newState
@@ -232,6 +232,7 @@ describe('src/Core', () => {
       capabilities: { resumableUploads: false },
       files: {},
       currentUploads: {},
+      error: null,
       foo: 'bar',
       info: { isHidden: true, message: '', type: 'info' },
       meta: {},
@@ -244,11 +245,11 @@ describe('src/Core', () => {
     const core = new Core()
     const id = core._createUpload([ 'a', 'b' ])
 
-    expect(core.state.currentUploads[id]).toBeDefined()
+    expect(core.getState().currentUploads[id]).toBeDefined()
 
     core.cancelAll()
 
-    expect(core.state.currentUploads[id]).toBeUndefined()
+    expect(core.getState().currentUploads[id]).toBeUndefined()
   })
 
   it('should close, reset and uninstall when the close method is called', () => {
@@ -271,6 +272,7 @@ describe('src/Core', () => {
       capabilities: { resumableUploads: false },
       files: {},
       currentUploads: {},
+      error: null,
       info: { isHidden: true, message: '', type: 'info' },
       meta: {},
       plugins: {},
@@ -338,7 +340,7 @@ describe('src/Core', () => {
 
       return core.upload()
         .then(() => {
-          const fileId = Object.keys(core.state.files)[0]
+          const fileId = Object.keys(core.getState().files)[0]
           expect(preprocessor1.mock.calls.length).toEqual(1)
 
           expect(preprocessor1.mock.calls[0][0].length).toEqual(1)
@@ -358,14 +360,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const file = core.getFile(fileId)
       core.emit('preprocess-progress', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -385,14 +387,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileID = Object.keys(core.state.files)[0]
-      const file = core.state.files[fileID]
+      const fileID = Object.keys(core.getState().files)[0]
+      const file = core.getFile(fileID)
       core.emit('preprocess-complete', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileID].progress).toEqual({
+      expect(core.getFile(fileID).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -465,14 +467,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const file = core.getFile(fileId)
       core.emit('postprocess-progress', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -492,14 +494,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
-      const file = core.state.files[fileId]
+      const fileId = Object.keys(core.getState().files)[0]
+      const file = core.getFile(fileId)
       core.emit('postprocess-complete', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -563,7 +565,7 @@ describe('src/Core', () => {
         data: fileData
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const newFile = {
         extension: 'jpg',
         id: fileId,
@@ -584,7 +586,7 @@ describe('src/Core', () => {
         source: 'jest',
         type: 'image/jpeg'
       }
-      expect(core.state.files[fileId]).toEqual(newFile)
+      expect(core.getFile(fileId)).toEqual(newFile)
       expect(fileAddedEventMock.mock.calls[0][0]).toEqual(newFile)
     })
 
@@ -621,7 +623,7 @@ describe('src/Core', () => {
         type: 'image/jpeg',
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
-      expect(Object.keys(core.state.files).length).toEqual(0)
+      expect(core.getFiles().length).toEqual(0)
     })
   })
 
@@ -735,8 +737,8 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
-      expect(Object.keys(core.state.files).length).toEqual(1)
+      const fileId = Object.keys(core.getState().files)[0]
+      expect(core.getFiles().length).toEqual(1)
       core.setState({
         totalProgress: 50
       })
@@ -744,9 +746,9 @@ describe('src/Core', () => {
       const file = core.getFile(fileId)
       core.removeFile(fileId)
 
-      expect(Object.keys(core.state.files).length).toEqual(0)
+      expect(core.getFiles().length).toEqual(0)
       expect(fileRemovedEventMock.mock.calls[0][0]).toEqual(file)
-      expect(core.state.totalProgress).toEqual(0)
+      expect(core.getState().totalProgress).toEqual(0)
     })
   })
 
@@ -767,7 +769,7 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       expect(core.getFile(fileId).name).toEqual('foo.jpg')
 
       expect(core.getFile('non existant file')).toEqual(undefined)
@@ -809,7 +811,7 @@ describe('src/Core', () => {
       })
       core.setMeta({ foo: 'bar', bur: 'mur' })
       core.setMeta({ boo: 'moo', bur: 'fur' })
-      expect(core.state.meta).toEqual({
+      expect(core.getState().meta).toEqual({
         foo: 'bar',
         foo2: 'bar2',
         boo: 'moo',
@@ -827,10 +829,10 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       core.setFileMeta(fileId, { foo: 'bar', bur: 'mur' })
       core.setFileMeta(fileId, { boo: 'moo', bur: 'fur' })
-      expect(core.state.files[fileId].meta).toEqual({
+      expect(core.getFile(fileId).meta).toEqual({
         name: 'foo.jpg',
         type: 'image/jpeg',
         foo: 'bar',
@@ -852,8 +854,8 @@ describe('src/Core', () => {
         },
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
-      const fileId = Object.keys(core.state.files)[0]
-      expect(core.state.files[fileId].meta).toEqual({
+      const fileId = Object.keys(core.getState().files)[0]
+      expect(core.getFile(fileId).meta).toEqual({
         name: 'foo.jpg',
         type: 'image/jpeg',
         foo2: 'bar2',
@@ -873,13 +875,13 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const file = core.getFile(fileId)
       core._calculateProgress(file, {
         bytesUploaded: 12345,
         bytesTotal: 17175
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 71,
         bytesUploaded: 12345,
         bytesTotal: 17175,
@@ -891,7 +893,7 @@ describe('src/Core', () => {
         bytesUploaded: 17175,
         bytesTotal: 17175
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 100,
         bytesUploaded: 17175,
         bytesTotal: 17175,
@@ -916,25 +918,22 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId1 = Object.keys(core.state.files)[0]
-      const fileId2 = Object.keys(core.state.files)[1]
-      const file1 = core.state.files[fileId1]
-      const file2 = core.state.files[fileId2]
-      core.state.files[fileId1].progress.uploadStarted = new Date()
-      core.state.files[fileId2].progress.uploadStarted = new Date()
+      const [file1, file2] = core.getFiles()
+      core.setFileState(file1.id, { progress: Object.assign({}, file1.progress, { uploadStarted: new Date() }) })
+      core.setFileState(file2.id, { progress: Object.assign({}, file2.progress, { uploadStarted: new Date() }) })
 
-      core._calculateProgress(file1, {
+      core._calculateProgress(core.getFile(file1.id), {
         bytesUploaded: 12345,
         bytesTotal: 17175
       })
 
-      core._calculateProgress(file2, {
+      core._calculateProgress(core.getFile(file2.id), {
         bytesUploaded: 10201,
         bytesTotal: 17175
       })
 
       core._calculateTotalProgress()
-      expect(core.state.totalProgress).toEqual(65)
+      expect(core.getState().totalProgress).toEqual(65)
     })
 
     it('should reset the progress', () => {
@@ -955,44 +954,41 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId1 = Object.keys(core.state.files)[0]
-      const fileId2 = Object.keys(core.state.files)[1]
-      const file1 = core.state.files[fileId1]
-      const file2 = core.state.files[fileId2]
-      core.state.files[fileId1].progress.uploadStarted = new Date()
-      core.state.files[fileId2].progress.uploadStarted = new Date()
+      const [file1, file2] = core.getFiles()
+      core.setFileState(file1.id, { progress: Object.assign({}, file1.progress, { uploadStarted: new Date() }) })
+      core.setFileState(file2.id, { progress: Object.assign({}, file2.progress, { uploadStarted: new Date() }) })
 
-      core._calculateProgress(file1, {
+      core._calculateProgress(core.getFile(file1.id), {
         bytesUploaded: 12345,
         bytesTotal: 17175
       })
 
-      core._calculateProgress(file2, {
+      core._calculateProgress(core.getFile(file2.id), {
         bytesUploaded: 10201,
         bytesTotal: 17175
       })
 
       core._calculateTotalProgress()
 
-      expect(core.state.totalProgress).toEqual(65)
+      expect(core.getState().totalProgress).toEqual(65)
 
       core.resetProgress()
 
-      expect(core.state.files[fileId1].progress).toEqual({
+      expect(core.getFile(file1.id).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
         uploadComplete: false,
         uploadStarted: false
       })
-      expect(core.state.files[fileId2].progress).toEqual({
+      expect(core.getFile(file2.id).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
         uploadComplete: false,
         uploadStarted: false
       })
-      expect(core.state.totalProgress).toEqual(0)
+      expect(core.getState().totalProgress).toEqual(0)
       expect(resetProgressEvent.mock.calls.length).toEqual(1)
     })
   })
@@ -1023,7 +1019,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('You can only upload 1 file'))
-        expect(core.state.info.message).toEqual('You can only upload 1 file')
+        expect(core.getState().info.message).toEqual('You can only upload 1 file')
       }
     })
 
@@ -1047,7 +1043,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('You can only upload: image/gif, image/png'))
-        expect(core.state.info.message).toEqual('You can only upload: image/gif, image/png')
+        expect(core.getState().info.message).toEqual('You can only upload: image/gif, image/png')
       }
     })
 
@@ -1069,7 +1065,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('You can only upload: .gif, .jpg, .jpeg'))
-        expect(core.state.info.message).toEqual('You can only upload: .gif, .jpg, .jpeg')
+        expect(core.getState().info.message).toEqual('You can only upload: .gif, .jpg, .jpeg')
       }
     })
 
@@ -1091,7 +1087,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('This file exceeds maximum allowed size of 1.2 KB'))
-        expect(core.state.info.message).toEqual('This file exceeds maximum allowed size of 1.2 KB')
+        expect(core.getState().info.message).toEqual('This file exceeds maximum allowed size of 1.2 KB')
       }
     })
   })
@@ -1100,23 +1096,28 @@ describe('src/Core', () => {
     it('should update the state when receiving the error event', () => {
       const core = new Core()
       core.emit('error', new Error('foooooo'))
-      expect(core.state.error).toEqual('foooooo')
+      expect(core.getState().error).toEqual('foooooo')
     })
 
     it('should update the state when receiving the upload-error event', () => {
       const core = new Core()
-      core.state.files['fileId'] = {
-        name: 'filename'
-      }
-      core.emit('upload-error', core.state.files['fileId'], new Error('this is the error'))
-      expect(core.state.info).toEqual({'message': 'Failed to upload filename', 'details': 'this is the error', 'isHidden': false, 'type': 'error'})
+      core.setState({
+        files: {
+          fileId: {
+            id: 'fileId',
+            name: 'filename'
+          }
+        }
+      })
+      core.emit('upload-error', core.getFile('fileId'), new Error('this is the error'))
+      expect(core.getState().info).toEqual({'message': 'Failed to upload filename', 'details': 'this is the error', 'isHidden': false, 'type': 'error'})
     })
 
     it('should reset the error state when receiving the upload event', () => {
       const core = new Core()
       core.emit('error', { foo: 'bar' })
       core.emit('upload')
-      expect(core.state.error).toEqual(null)
+      expect(core.getState().error).toEqual(null)
     })
   })
 
@@ -1174,7 +1175,7 @@ describe('src/Core', () => {
       core.on('info-visible', infoVisibleEvent)
 
       core.info('This is the message', 'info', 0)
-      expect(core.state.info).toEqual({
+      expect(core.getState().info).toEqual({
         isHidden: false,
         type: 'info',
         message: 'This is the message',
@@ -1195,7 +1196,7 @@ describe('src/Core', () => {
           foo: 'bar'
         }
       }, 'warning', 0)
-      expect(core.state.info).toEqual({
+      expect(core.getState().info).toEqual({
         isHidden: false,
         type: 'warning',
         message: 'This is the message',
@@ -1219,7 +1220,7 @@ describe('src/Core', () => {
       expect(infoHiddenEvent.mock.calls.length).toEqual(0)
       setTimeout(() => {
         expect(infoHiddenEvent.mock.calls.length).toEqual(1)
-        expect(core.state.info).toEqual({
+        expect(core.getState().info).toEqual({
           isHidden: true,
           type: 'info',
           message: 'This is the message',
@@ -1241,7 +1242,7 @@ describe('src/Core', () => {
       expect(infoHiddenEvent.mock.calls.length).toEqual(0)
       core.hideInfo()
       expect(infoHiddenEvent.mock.calls.length).toEqual(1)
-      expect(core.state.info).toEqual({
+      expect(core.getState().info).toEqual({
         isHidden: true,
         type: 'info',
         message: 'This is the message',
@@ -1260,15 +1261,15 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      core._createUpload(Object.keys(core.state.files))
-      const uploadId = Object.keys(core.state.currentUploads)[0]
+      core._createUpload(Object.keys(core.getState().files))
+      const uploadId = Object.keys(core.getState().currentUploads)[0]
       const currentUploadsState = {}
       currentUploadsState[uploadId] = {
-        fileIDs: Object.keys(core.state.files),
+        fileIDs: Object.keys(core.getState().files),
         step: 0,
         result: {}
       }
-      expect(core.state.currentUploads).toEqual(currentUploadsState)
+      expect(core.getState().currentUploads).toEqual(currentUploadsState)
     })
   })
 

+ 3 - 2
src/core/Plugin.js

@@ -44,11 +44,12 @@ module.exports = class Plugin {
   }
 
   getPluginState () {
-    return this.uppy.state.plugins[this.id]
+    const { plugins } = this.uppy.getState()
+    return plugins[this.id]
   }
 
   setPluginState (update) {
-    const plugins = Object.assign({}, this.uppy.state.plugins)
+    const plugins = Object.assign({}, this.uppy.getState().plugins)
     plugins[this.id] = Object.assign({}, plugins[this.id], update)
 
     this.uppy.setState({

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

@@ -380,7 +380,7 @@ module.exports = class Dashboard extends Plugin {
 
   render (state) {
     const pluginState = this.getPluginState()
-    const files = state.files
+    const { files, capabilities } = state
 
     const newFiles = Object.keys(files).filter((file) => {
       return !files[file].progress.uploadStarted

+ 8 - 9
src/plugins/GoldenRetriever/index.js

@@ -65,11 +65,9 @@ module.exports = class GoldenRetriever extends Plugin {
   getWaitingFiles () {
     const waitingFiles = {}
 
-    const allFiles = this.uppy.state.files
-    Object.keys(allFiles).forEach((fileID) => {
-      const file = this.uppy.getFile(fileID)
+    this.uppy.getFiles().forEach((file) => {
       if (!file.progress || !file.progress.uploadStarted) {
-        waitingFiles[fileID] = file
+        waitingFiles[file.id] = file
       }
     })
 
@@ -84,7 +82,7 @@ module.exports = class GoldenRetriever extends Plugin {
   getUploadingFiles () {
     const uploadingFiles = {}
 
-    const { currentUploads } = this.uppy.state
+    const { currentUploads } = this.uppy.getState()
     if (currentUploads) {
       const uploadIDs = Object.keys(currentUploads)
       uploadIDs.forEach((uploadID) => {
@@ -112,8 +110,9 @@ module.exports = class GoldenRetriever extends Plugin {
       Object.assign(pluginData, data)
     })
 
+    const { currentUploads } = this.uppy.getState()
     this.MetaDataStore.save({
-      currentUploads: this.uppy.state.currentUploads,
+      currentUploads: currentUploads,
       files: filesToSave,
       pluginData: pluginData
     })
@@ -122,7 +121,7 @@ module.exports = class GoldenRetriever extends Plugin {
   loadFileBlobsFromServiceWorker () {
     this.ServiceWorkerStore.list().then((blobs) => {
       const numberOfFilesRecovered = Object.keys(blobs).length
-      const numberOfFilesTryingToRecover = Object.keys(this.uppy.state.files).length
+      const numberOfFilesTryingToRecover = this.uppy.getFiles().length
       if (numberOfFilesRecovered === numberOfFilesTryingToRecover) {
         this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`)
         this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
@@ -154,7 +153,7 @@ module.exports = class GoldenRetriever extends Plugin {
 
   onBlobsLoaded (blobs) {
     const obsoleteBlobs = []
-    const updatedFiles = Object.assign({}, this.uppy.state.files)
+    const updatedFiles = Object.assign({}, this.uppy.getState().files)
     Object.keys(blobs).forEach((fileID) => {
       const originalFile = this.uppy.getFile(fileID)
       if (!originalFile) {
@@ -204,7 +203,7 @@ module.exports = class GoldenRetriever extends Plugin {
   install () {
     this.loadFilesStateFromLocalStorage()
 
-    if (Object.keys(this.uppy.state.files).length > 0) {
+    if (this.uppy.getFiles().length > 0) {
       if (this.ServiceWorkerStore) {
         this.uppy.log('[GoldenRetriever] Attempting to load files from Service Worker...')
         this.loadFileBlobsFromServiceWorker()

+ 4 - 4
src/plugins/ReduxDevTools.js

@@ -40,13 +40,13 @@ module.exports = class ReduxDevTools extends Plugin {
             return
           case 'IMPORT_STATE':
             const computedStates = message.payload.nextLiftedState.computedStates
-            this.uppy.state = Object.assign({}, this.uppy.state, computedStates[computedStates.length - 1].state)
-            this.uppy.updateAll(this.uppy.state)
+            this.uppy.store.state = Object.assign({}, this.uppy.getState(), computedStates[computedStates.length - 1].state)
+            this.uppy.updateAll(this.uppy.getState())
             return
           case 'JUMP_TO_STATE':
           case 'JUMP_TO_ACTION':
-            this.uppy.store.state = Object.assign({}, this.uppy.state, JSON.parse(message.state))
-            this.uppy.updateAll(this.uppy.state)
+            this.uppy.store.state = Object.assign({}, this.uppy.getState(), JSON.parse(message.state))
+            this.uppy.updateAll(this.uppy.getState())
         }
       }
     })

+ 2 - 7
src/plugins/ThumbnailGenerator/index.js

@@ -153,13 +153,8 @@ module.exports = class ThumbnailGenerator extends Plugin {
    * Set the preview URL for a file.
    */
   setPreviewURL (fileID, preview) {
-    const { files } = this.uppy.state
-    this.uppy.setState({
-      files: Object.assign({}, files, {
-        [fileID]: Object.assign({}, files[fileID], {
-          preview: preview
-        })
-      })
+    this.uppy.setFileState(fileID, {
+      preview: preview
     })
   }
 

+ 4 - 4
src/plugins/ThumbnailGenerator/index.test.js

@@ -186,13 +186,13 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
             }
           }
         },
-        setState: jest.fn()
+        setFileState: jest.fn()
       }
       const plugin = new ThumbnailGeneratorPlugin(core)
       plugin.setPreviewURL('file1', 'moo')
-      expect(core.setState).toHaveBeenCalledTimes(1)
-      expect(core.setState).toHaveBeenCalledWith({
-        files: { file1: { preview: 'moo' }, file2: { preview: 'boo' } }
+      expect(core.setFileState).toHaveBeenCalledTimes(1)
+      expect(core.setFileState).toHaveBeenCalledWith('file1', {
+        preview: 'moo'
       })
     })
   })

+ 11 - 16
src/plugins/Transloadit/index.js

@@ -216,7 +216,7 @@ module.exports = class Transloadit extends Plugin {
         return newFile
       }
 
-      const files = Object.assign({}, this.uppy.state.files)
+      const files = Object.assign({}, this.uppy.getState().files)
       fileIDs.forEach((id) => {
         files[id] = attachAssemblyMetadata(files[id], assembly)
       })
@@ -272,23 +272,21 @@ module.exports = class Transloadit extends Plugin {
   }
 
   findFile (uploadedFile) {
-    const files = this.uppy.state.files
-    for (const id in files) {
-      if (!files.hasOwnProperty(id)) {
-        continue
-      }
+    const files = this.uppy.getFiles()
+    for (let i = 0; i < files.length; i++) {
+      const file = files[i]
       // Completed file upload.
-      if (files[id].uploadURL === uploadedFile.tus_upload_url) {
-        return files[id]
+      if (file.uploadURL === uploadedFile.tus_upload_url) {
+        return file
       }
       // In-progress file upload.
-      if (files[id].tus && files[id].tus.uploadUrl === uploadedFile.tus_upload_url) {
-        return files[id]
+      if (file.tus && file.tus.uploadUrl === uploadedFile.tus_upload_url) {
+        return file
       }
       if (!uploadedFile.is_tus_file) {
         // Fingers-crossed check for non-tus uploads, eg imported from S3.
-        if (files[id].name === uploadedFile.name && files[id].size === uploadedFile.size) {
-          return files[id]
+        if (file.name === uploadedFile.name && file.size === uploadedFile.size) {
+          return file
         }
       }
     }
@@ -852,10 +850,7 @@ module.exports = class Transloadit extends Plugin {
   }
 
   getAssemblyFiles (assemblyID) {
-    const fileIDs = Object.keys(this.uppy.state.files)
-    return fileIDs.map((fileID) => {
-      return this.uppy.getFile(fileID)
-    }).filter((file) => {
+    return this.uppy.getFiles().filter((file) => {
       return file && file.transloadit && file.transloadit.assembly === assemblyID
     })
   }

+ 2 - 10
src/plugins/Tus.js

@@ -85,7 +85,7 @@ module.exports = class Tus extends Plugin {
   }
 
   handleResetProgress () {
-    const files = Object.assign({}, this.uppy.state.files)
+    const files = Object.assign({}, this.uppy.getState().files)
     Object.keys(files).forEach((fileID) => {
       // Only clone the file object if it has a Tus `uploadUrl` attached.
       if (files[fileID].tus && files[fileID].tus.uploadUrl) {
@@ -346,13 +346,6 @@ module.exports = class Tus extends Plugin {
     })
   }
 
-  updateFile (file) {
-    const files = Object.assign({}, this.uppy.state.files, {
-      [file.id]: file
-    })
-    this.uppy.setState({ files })
-  }
-
   onReceiveUploadUrl (file, uploadURL) {
     const currentFile = this.uppy.getFile(file.id)
     if (!currentFile) return
@@ -360,12 +353,11 @@ module.exports = class Tus extends Plugin {
     // or resume: false in options
     if ((!currentFile.tus || currentFile.tus.uploadUrl !== uploadURL) && this.opts.resume) {
       this.uppy.log('[Tus] Storing upload url')
-      const newFile = Object.assign({}, currentFile, {
+      this.uppy.setFileState(currentFile.id, {
         tus: Object.assign({}, currentFile.tus, {
           uploadUrl: uploadURL
         })
       })
-      this.updateFile(newFile)
     }
   }
 

+ 18 - 8
src/plugins/XHRUpload.js

@@ -102,15 +102,16 @@ module.exports = class XHRUpload extends Plugin {
   }
 
   getOptions (file) {
+    const overrides = this.uppy.getState().xhrUpload
     const opts = Object.assign({},
       this.opts,
-      this.uppy.state.xhrUpload || {},
+      overrides || {},
       file.xhrUpload || {}
     )
     opts.headers = {}
     Object.assign(opts.headers, this.opts.headers)
-    if (this.uppy.state.xhrUpload) {
-      Object.assign(opts.headers, this.uppy.state.xhrUpload.headers)
+    if (overrides) {
+      Object.assign(opts.headers, overrides.headers)
     }
     if (file.xhrUpload) {
       Object.assign(opts.headers, file.xhrUpload.headers)
@@ -126,6 +127,8 @@ module.exports = class XHRUpload extends Plugin {
   createProgressTimeout (timeout, timeoutHandler) {
     const uppy = this.uppy
     const self = this
+    let isDone = false
+
     function onTimedOut () {
       uppy.log(`[XHRUpload] timed out`)
       const error = new Error(self.i18n('timedOut', { seconds: Math.ceil(timeout / 1000) }))
@@ -134,17 +137,24 @@ module.exports = class XHRUpload extends Plugin {
 
     let aliveTimer = null
     function progress () {
+      // Some browsers fire another progress event when the upload is
+      // cancelled, so we have to ignore progress after the timer was
+      // told to stop.
+      if (isDone) return
+
       if (timeout > 0) {
-        done()
+        if (aliveTimer) clearTimeout(aliveTimer)
         aliveTimer = setTimeout(onTimedOut, timeout)
       }
     }
 
     function done () {
+      uppy.log(`[XHRUpload] timer done`)
       if (aliveTimer) {
         clearTimeout(aliveTimer)
         aliveTimer = null
       }
+      isDone = true
     }
 
     return {
@@ -281,8 +291,7 @@ module.exports = class XHRUpload extends Plugin {
       })
 
       this.uppy.on('cancel-all', () => {
-        // const files = this.uppy.getState().files
-        // if (!files[file.id]) return
+        timer.done()
         xhr.abort()
       })
     })
@@ -387,8 +396,8 @@ module.exports = class XHRUpload extends Plugin {
         files.forEach((file) => {
           this.uppy.emit('upload-progress', file, {
             uploader: this,
-            bytesUploaded: ev.loaded,
-            bytesTotal: ev.total
+            bytesUploaded: ev.loaded / ev.total * file.size,
+            bytesTotal: file.size
           })
         })
       })
@@ -419,6 +428,7 @@ module.exports = class XHRUpload extends Plugin {
       })
 
       this.uppy.on('cancel-all', () => {
+        timer.done()
         xhr.abort()
       })
 

+ 1 - 1
src/react/Dashboard.js

@@ -20,7 +20,7 @@ class Dashboard extends React.Component {
     delete options.uppy
     uppy.use(DashboardPlugin, options)
 
-    this.plugin = uppy.getPlugin('Dashboard')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/Dashboard.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/Dashboard', () => require('./__mocks__/DashboardPlugin'))
+
+const Dashboard = require('./Dashboard')
+
+describe('react <Dashboard />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <Dashboard
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 1 - 1
src/react/DashboardModal.js

@@ -28,7 +28,7 @@ class DashboardModal extends React.Component {
     delete options.uppy
     uppy.use(DashboardPlugin, options)
 
-    this.plugin = uppy.getPlugin('Dashboard')
+    this.plugin = uppy.getPlugin(options.id)
     if (this.props.open) {
       this.plugin.openModal()
     }

+ 81 - 0
src/react/DashboardModal.test.js

@@ -0,0 +1,81 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+jest.mock('../plugins/Dashboard', () => require('./__mocks__/DashboardPlugin'))
+
+const DashboardModal = require('./DashboardModal')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+beforeEach(() => {
+  Object.assign(require('../plugins/Dashboard').prototype, {
+    openModal: jest.fn(),
+    closeModal: jest.fn()
+  })
+})
+
+describe('react <DashboardModal />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <DashboardModal
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+
+  it('opens the modal using the `open={true}` prop', () => {
+    const uppy = Uppy()
+    const dash = mount((
+      <DashboardModal
+        uppy={uppy}
+        open={false}
+      />
+    ))
+    const { plugin } = dash.instance()
+
+    expect(plugin.openModal).not.toHaveBeenCalled()
+
+    dash.setProps({ open: true })
+
+    expect(plugin.openModal).toHaveBeenCalled()
+
+    dash.unmount()
+  })
+
+  it('closes the modal using the `open={false}` prop', () => {
+    const uppy = Uppy()
+    const dash = mount((
+      <DashboardModal
+        uppy={uppy}
+        open
+      />
+    ))
+    const { plugin } = dash.instance()
+
+    expect(plugin.openModal).toHaveBeenCalled()
+    expect(plugin.closeModal).not.toHaveBeenCalled()
+
+    dash.setProps({ open: false })
+
+    expect(plugin.closeModal).toHaveBeenCalled()
+
+    dash.unmount()
+  })
+})

+ 1 - 1
src/react/DragDrop.js

@@ -21,7 +21,7 @@ class DragDrop extends React.Component {
 
     uppy.use(DragDropPlugin, options)
 
-    this.plugin = uppy.getPlugin('DragDrop')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/DragDrop.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/DragDrop', () => require('./__mocks__/DragDropPlugin'))
+
+const DragDrop = require('./DragDrop')
+
+describe('react <DragDrop />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <DragDrop
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 1 - 1
src/react/ProgressBar.js

@@ -21,7 +21,7 @@ class ProgressBar extends React.Component {
 
     uppy.use(ProgressBarPlugin, options)
 
-    this.plugin = uppy.getPlugin('ProgressBar')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/ProgressBar.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/ProgressBar', () => require('./__mocks__/ProgressBarPlugin'))
+
+const ProgressBar = require('./ProgressBar')
+
+describe('react <ProgressBar />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <ProgressBar
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 1 - 1
src/react/StatusBar.js

@@ -22,7 +22,7 @@ class StatusBar extends React.Component {
 
     uppy.use(StatusBarPlugin, options)
 
-    this.plugin = uppy.getPlugin('StatusBar')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/StatusBar.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/StatusBar', () => require('./__mocks__/StatusBarPlugin'))
+
+const StatusBar = require('./StatusBar')
+
+describe('react <StatusBar />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <StatusBar
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 18 - 0
src/react/__mocks__/DashboardPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class Dashboard extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'orchestrator'
+  }
+
+  install () {
+    if (this.opts.onInstall) this.opts.onInstall()
+  }
+
+  uninstall () {
+    if (this.opts.onUninstall) this.opts.onUninstall()
+  }
+}

+ 18 - 0
src/react/__mocks__/DragDropPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class DragDrop extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'acquirer'
+  }
+
+  install () {
+    if (this.opts.onInstall) this.opts.onInstall()
+  }
+
+  uninstall () {
+    if (this.opts.onUninstall) this.opts.onUninstall()
+  }
+}

+ 18 - 0
src/react/__mocks__/ProgressBarPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class ProgressBar extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'progressindicator'
+  }
+
+  install () {
+    this.opts.onInstall()
+  }
+
+  uninstall () {
+    this.opts.onUninstall()
+  }
+}

+ 18 - 0
src/react/__mocks__/StatusBarPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class StatusBar extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'progressindicator'
+  }
+
+  install () {
+    this.opts.onInstall()
+  }
+
+  uninstall () {
+    this.opts.onUninstall()
+  }
+}

+ 4 - 3
src/server/RequestClient.js

@@ -10,13 +10,14 @@ module.exports = class RequestClient {
   }
 
   get hostname () {
-    const uppyServer = this.uppy.state.uppyServer || {}
+    const { uppyServer } = this.uppy.getState()
     const host = this.opts.host
-    return uppyServer[host] || host
+    return uppyServer && uppyServer[host] ? uppyServer[host] : host
   }
 
   onReceiveResponse (response) {
-    const uppyServer = this.uppy.state.uppyServer || {}
+    const state = this.uppy.getState()
+    const uppyServer = state.uppyServer || {}
     const host = this.opts.host
     const headers = response.headers
     // Store the self-identified domain name for the uppy-server we just hit.

+ 10 - 0
website/src/docs/uppy.md

@@ -244,6 +244,16 @@ uppy.addFile({
 
 If `uppy.opts.autoProceed === true`, Uppy will begin uploading automatically when files are added.
 
+### `uppy.removeFile(fileID)`
+
+Remove a file from Uppy.
+
+```js
+uppy.removeFile('uppyteamkongjpg1501851828779')
+```
+
+Removing a file that is already being uploaded cancels that upload.
+
 ### `uppy.getFile(fileID)`
 
 Get a specific file object by its ID.