Quellcode durchsuchen

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

Artur Paikin vor 7 Jahren
Ursprung
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
 - [ ] 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
 - [ ] 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 https://www.producthunt.com/posts/uppy-io#comment-559327 (“And Gravity forms”)
 - [ ] Transformations, cropping, filters for images, see #53
 - [ ] Transformations, cropping, filters for images, see #53
 - [ ] Prepare for (piwik-) tracking of usage of uppy ? see #83
 - [ ] Prepare for (piwik-) tracking of usage of uppy ? see #83
 - [ ] screenshot+screencast support similar to Webcam #148
 - [ ] screenshot+screencast support similar to Webcam #148
 - [ ] Webcam modes #198
 - [ ] Webcam modes #198
 - [ ] feature: React Native support
 - [ ] 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
 - [ ] 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 https://github.com/pa11y/pa11y for automated accessibility testing?
 - [ ] test: add tests for `npm pack`
 - [ ] test: add tests for `npm pack`
 - [ ] test: add deepFreeze to test that state in not mutated anywhere by accident #320
 - [ ] 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
 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
 - [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: beta file recovering after closed tab / browser crash
 - [x] feature: easy integration with React (UppyReact components)
 - [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: 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] 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] 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: 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)
 - [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: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB)
 - [ ] uppy-server: document docker image setup for uppy-server (@ifedapoolarewaju)
 - [ ] 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)
 - [ ] 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)
 - [ ] 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)
 - [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
 - [ ] 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)
 - [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: 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: 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)
 - [ ] 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] 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)
 - [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)
 - [ ] 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] providers: Add user/account names to Uppy provider views (61bf0a7 / @ifedapoolarewaju)
 - [x] s3: implement multipart uploads (#726 / @goto-bus-stop)
 - [x] s3: implement multipart uploads (#726 / @goto-bus-stop)

+ 409 - 0
package-lock.json

@@ -101,6 +101,12 @@
       "integrity": "sha1-yRNQTT3CgQr61VW1ma6uwsxMZ2g=",
       "integrity": "sha1-yRNQTT3CgQr61VW1ma6uwsxMZ2g=",
       "dev": true
       "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": {
     "JSONStream": {
       "version": "1.3.1",
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.1.tgz",
       "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": {
     "boom": {
       "version": "2.10.1",
       "version": "2.10.1",
       "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
       "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
@@ -3249,6 +3261,20 @@
       "integrity": "sha1-7tY7usnqSaDiagljFAWLA7CN1is=",
       "integrity": "sha1-7tY7usnqSaDiagljFAWLA7CN1is=",
       "dev": true
       "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": {
     "chokidar": {
       "version": "1.7.0",
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
@@ -3953,12 +3979,30 @@
         "css": "^2.0.0"
         "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": {
     "css-value": {
       "version": "0.0.1",
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/css-value/-/css-value-0.0.1.tgz",
       "resolved": "https://registry.npmjs.org/css-value/-/css-value-0.0.1.tgz",
       "integrity": "sha1-Xv1sLupeof1rasV+wEJ7GEUkJOo=",
       "integrity": "sha1-Xv1sLupeof1rasV+wEJ7GEUkJOo=",
       "dev": true
       "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": {
     "cssnano": {
       "version": "3.10.0",
       "version": "3.10.0",
       "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.10.0.tgz",
       "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": {
     "dns-prefetch-control": {
       "version": "0.1.0",
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz",
       "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": {
     "domain-browser": {
       "version": "1.1.7",
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz",
       "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz",
       "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=",
       "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=",
       "dev": true
       "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": {
     "domexception": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
@@ -4493,6 +4567,25 @@
         "webidl-conversions": "^4.0.2"
         "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": {
     "dont-sniff-mimetype": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.0.0.tgz",
@@ -4742,6 +4835,12 @@
         "has-binary2": "~1.0.2"
         "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": {
     "envify": {
       "version": "4.1.0",
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/envify/-/envify-4.1.0.tgz",
       "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": {
     "error-ex": {
       "version": "1.3.1",
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz",
@@ -7498,6 +7667,25 @@
       "integrity": "sha1-FhdnFMgBeY5Ojyz391KUZ7tKV3E=",
       "integrity": "sha1-FhdnFMgBeY5Ojyz391KUZ7tKV3E=",
       "dev": true
       "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": {
     "functional-red-black-tree": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
       "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",
       "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.1.tgz",
       "integrity": "sha512-JkaetveU7hFbqnAC1EV1sF4rlojU2D4Usc5CmS69l6NfmPDnpnFUegzFg33eDkkpNCxZ0mQp65HwUDrNFS/8MA=="
       "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": {
     "has-to-string-tag-x": {
       "version": "1.4.1",
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz",
       "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=",
       "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=",
       "dev": true
       "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": {
     "http-errors": {
       "version": "1.5.1",
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.1.tgz",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.1.tgz",
@@ -8395,6 +8603,12 @@
         "binary-extensions": "^1.0.0"
         "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": {
     "is-buffer": {
       "version": "1.1.5",
       "version": "1.1.5",
       "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz",
       "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz",
@@ -8600,6 +8814,12 @@
         "lodash.isfinite": "^3.3.2"
         "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": {
     "is-obj": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
       "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",
       "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.4.tgz",
       "integrity": "sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ="
       "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": {
     "is-svg": {
       "version": "2.1.0",
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.1.0.tgz",
       "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.1.0.tgz",
@@ -11353,6 +11579,12 @@
       "integrity": "sha1-9HGh2khr5g9quVXRcRVSPdHSVdU=",
       "integrity": "sha1-9HGh2khr5g9quVXRcRVSPdHSVdU=",
       "dev": true
       "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": {
     "lodash.includes": {
       "version": "4.3.0",
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
       "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -11994,6 +12226,26 @@
       "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
       "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
       "dev": true
       "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": {
     "negotiator": {
       "version": "0.6.1",
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
@@ -12767,6 +13019,24 @@
       "integrity": "sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=",
       "integrity": "sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=",
       "dev": true
       "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": {
     "nopt": {
       "version": "3.0.6",
       "version": "3.0.6",
       "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
       "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
@@ -12971,6 +13241,15 @@
         "set-blocking": "~2.0.0"
         "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": {
     "num2fraction": {
       "version": "1.2.2",
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
       "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
@@ -13016,6 +13295,18 @@
         "to-property-key-x": "^2.0.1"
         "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": {
     "object-keys": {
       "version": "1.0.11",
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz",
       "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz",
@@ -13350,6 +13641,15 @@
         "error-ex": "^1.2.0"
         "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": {
     "parseqs": {
       "version": "0.0.5",
       "version": "0.0.5",
       "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
       "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
@@ -14477,6 +14777,39 @@
       "integrity": "sha1-EIOSF/bBNiuJGUBE0psjP9fzLwE=",
       "integrity": "sha1-EIOSF/bBNiuJGUBE0psjP9fzLwE=",
       "dev": true
       "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": {
     "random-bytes": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
       "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": {
     "read-all-stream": {
       "version": "3.1.0",
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz",
       "resolved": "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz",
@@ -15158,6 +15545,12 @@
         "onetime": "^1.0.0"
         "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": {
     "rgb2hex": {
       "version": "0.1.0",
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.1.0.tgz",
       "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.1.0.tgz",
@@ -15205,6 +15598,16 @@
       "integrity": "sha1-IPbld0Ih5RkZdjndmsBLe63sls0=",
       "integrity": "sha1-IPbld0Ih5RkZdjndmsBLe63sls0=",
       "dev": true
       "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": {
     "run-async": {
       "version": "0.1.0",
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz",
       "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": {
     "uniq": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",

+ 4 - 0
package.json

@@ -63,6 +63,8 @@
     "chalk": "1.1.3",
     "chalk": "1.1.3",
     "cssnano": "^3.10.0",
     "cssnano": "^3.10.0",
     "disc": "^1.3.3",
     "disc": "^1.3.3",
+    "enzyme": "^3.3.0",
+    "enzyme-adapter-react-16": "^1.1.1",
     "eslint": "^3.19.0",
     "eslint": "^3.19.0",
     "eslint-config-standard": "^10.2.1",
     "eslint-config-standard": "^10.2.1",
     "eslint-config-standard-preact": "^1.1.6",
     "eslint-config-standard-preact": "^1.1.6",
@@ -89,6 +91,8 @@
     "postcss": "^6.0.16",
     "postcss": "^6.0.16",
     "postcss-safe-important": "^1.1.0",
     "postcss-safe-important": "^1.1.0",
     "pre-commit": "^1.2.2",
     "pre-commit": "^1.2.2",
+    "react": "^16.4.0",
+    "react-dom": "^16.4.0",
     "redux": "^3.7.2",
     "redux": "^3.7.2",
     "replace-x": "^1.5.0",
     "replace-x": "^1.5.0",
     "sass": "0.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 () {
   get state () {
     return this.getState()
     return this.getState()
@@ -182,6 +182,10 @@ class Uppy {
   * Shorthand to set state for a specific file.
   * Shorthand to set state for a specific file.
   */
   */
   setFileState (fileID, state) {
   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({
     this.setState({
       files: Object.assign({}, this.getState().files, {
       files: Object.assign({}, this.getState().files, {
         [fileID]: Object.assign({}, this.getState().files[fileID], state)
         [fileID]: Object.assign({}, this.getState().files[fileID], state)
@@ -451,7 +455,7 @@ class Uppy {
   }
   }
 
 
   removeFile (fileID) {
   removeFile (fileID) {
-    const { files, currentUploads } = this.state
+    const { files, currentUploads } = this.getState()
     const updatedFiles = Object.assign({}, files)
     const updatedFiles = Object.assign({}, files)
     const removedFile = updatedFiles[fileID]
     const removedFile = updatedFiles[fileID]
     delete updatedFiles[fileID]
     delete updatedFiles[fileID]
@@ -582,7 +586,8 @@ class Uppy {
 
 
     this.setState({
     this.setState({
       files: {},
       files: {},
-      totalProgress: 0
+      totalProgress: 0,
+      error: null
     })
     })
   }
   }
 
 

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

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

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

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

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

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

+ 4 - 4
src/plugins/ReduxDevTools.js

@@ -40,13 +40,13 @@ module.exports = class ReduxDevTools extends Plugin {
             return
             return
           case 'IMPORT_STATE':
           case 'IMPORT_STATE':
             const computedStates = message.payload.nextLiftedState.computedStates
             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
             return
           case 'JUMP_TO_STATE':
           case 'JUMP_TO_STATE':
           case 'JUMP_TO_ACTION':
           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.
    * Set the preview URL for a file.
    */
    */
   setPreviewURL (fileID, preview) {
   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)
       const plugin = new ThumbnailGeneratorPlugin(core)
       plugin.setPreviewURL('file1', 'moo')
       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
         return newFile
       }
       }
 
 
-      const files = Object.assign({}, this.uppy.state.files)
+      const files = Object.assign({}, this.uppy.getState().files)
       fileIDs.forEach((id) => {
       fileIDs.forEach((id) => {
         files[id] = attachAssemblyMetadata(files[id], assembly)
         files[id] = attachAssemblyMetadata(files[id], assembly)
       })
       })
@@ -272,23 +272,21 @@ module.exports = class Transloadit extends Plugin {
   }
   }
 
 
   findFile (uploadedFile) {
   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.
       // 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.
       // 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) {
       if (!uploadedFile.is_tus_file) {
         // Fingers-crossed check for non-tus uploads, eg imported from S3.
         // 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) {
   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
       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 () {
   handleResetProgress () {
-    const files = Object.assign({}, this.uppy.state.files)
+    const files = Object.assign({}, this.uppy.getState().files)
     Object.keys(files).forEach((fileID) => {
     Object.keys(files).forEach((fileID) => {
       // Only clone the file object if it has a Tus `uploadUrl` attached.
       // Only clone the file object if it has a Tus `uploadUrl` attached.
       if (files[fileID].tus && files[fileID].tus.uploadUrl) {
       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) {
   onReceiveUploadUrl (file, uploadURL) {
     const currentFile = this.uppy.getFile(file.id)
     const currentFile = this.uppy.getFile(file.id)
     if (!currentFile) return
     if (!currentFile) return
@@ -360,12 +353,11 @@ module.exports = class Tus extends Plugin {
     // or resume: false in options
     // or resume: false in options
     if ((!currentFile.tus || currentFile.tus.uploadUrl !== uploadURL) && this.opts.resume) {
     if ((!currentFile.tus || currentFile.tus.uploadUrl !== uploadURL) && this.opts.resume) {
       this.uppy.log('[Tus] Storing upload url')
       this.uppy.log('[Tus] Storing upload url')
-      const newFile = Object.assign({}, currentFile, {
+      this.uppy.setFileState(currentFile.id, {
         tus: Object.assign({}, currentFile.tus, {
         tus: Object.assign({}, currentFile.tus, {
           uploadUrl: uploadURL
           uploadUrl: uploadURL
         })
         })
       })
       })
-      this.updateFile(newFile)
     }
     }
   }
   }
 
 

+ 18 - 8
src/plugins/XHRUpload.js

@@ -102,15 +102,16 @@ module.exports = class XHRUpload extends Plugin {
   }
   }
 
 
   getOptions (file) {
   getOptions (file) {
+    const overrides = this.uppy.getState().xhrUpload
     const opts = Object.assign({},
     const opts = Object.assign({},
       this.opts,
       this.opts,
-      this.uppy.state.xhrUpload || {},
+      overrides || {},
       file.xhrUpload || {}
       file.xhrUpload || {}
     )
     )
     opts.headers = {}
     opts.headers = {}
     Object.assign(opts.headers, this.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) {
     if (file.xhrUpload) {
       Object.assign(opts.headers, file.xhrUpload.headers)
       Object.assign(opts.headers, file.xhrUpload.headers)
@@ -126,6 +127,8 @@ module.exports = class XHRUpload extends Plugin {
   createProgressTimeout (timeout, timeoutHandler) {
   createProgressTimeout (timeout, timeoutHandler) {
     const uppy = this.uppy
     const uppy = this.uppy
     const self = this
     const self = this
+    let isDone = false
+
     function onTimedOut () {
     function onTimedOut () {
       uppy.log(`[XHRUpload] timed out`)
       uppy.log(`[XHRUpload] timed out`)
       const error = new Error(self.i18n('timedOut', { seconds: Math.ceil(timeout / 1000) }))
       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
     let aliveTimer = null
     function progress () {
     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) {
       if (timeout > 0) {
-        done()
+        if (aliveTimer) clearTimeout(aliveTimer)
         aliveTimer = setTimeout(onTimedOut, timeout)
         aliveTimer = setTimeout(onTimedOut, timeout)
       }
       }
     }
     }
 
 
     function done () {
     function done () {
+      uppy.log(`[XHRUpload] timer done`)
       if (aliveTimer) {
       if (aliveTimer) {
         clearTimeout(aliveTimer)
         clearTimeout(aliveTimer)
         aliveTimer = null
         aliveTimer = null
       }
       }
+      isDone = true
     }
     }
 
 
     return {
     return {
@@ -281,8 +291,7 @@ module.exports = class XHRUpload extends Plugin {
       })
       })
 
 
       this.uppy.on('cancel-all', () => {
       this.uppy.on('cancel-all', () => {
-        // const files = this.uppy.getState().files
-        // if (!files[file.id]) return
+        timer.done()
         xhr.abort()
         xhr.abort()
       })
       })
     })
     })
@@ -387,8 +396,8 @@ module.exports = class XHRUpload extends Plugin {
         files.forEach((file) => {
         files.forEach((file) => {
           this.uppy.emit('upload-progress', file, {
           this.uppy.emit('upload-progress', file, {
             uploader: this,
             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', () => {
       this.uppy.on('cancel-all', () => {
+        timer.done()
         xhr.abort()
         xhr.abort()
       })
       })
 
 

+ 1 - 1
src/react/Dashboard.js

@@ -20,7 +20,7 @@ class Dashboard extends React.Component {
     delete options.uppy
     delete options.uppy
     uppy.use(DashboardPlugin, options)
     uppy.use(DashboardPlugin, options)
 
 
-    this.plugin = uppy.getPlugin('Dashboard')
+    this.plugin = uppy.getPlugin(options.id)
   }
   }
 
 
   componentWillUnmount () {
   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
     delete options.uppy
     uppy.use(DashboardPlugin, options)
     uppy.use(DashboardPlugin, options)
 
 
-    this.plugin = uppy.getPlugin('Dashboard')
+    this.plugin = uppy.getPlugin(options.id)
     if (this.props.open) {
     if (this.props.open) {
       this.plugin.openModal()
       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)
     uppy.use(DragDropPlugin, options)
 
 
-    this.plugin = uppy.getPlugin('DragDrop')
+    this.plugin = uppy.getPlugin(options.id)
   }
   }
 
 
   componentWillUnmount () {
   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)
     uppy.use(ProgressBarPlugin, options)
 
 
-    this.plugin = uppy.getPlugin('ProgressBar')
+    this.plugin = uppy.getPlugin(options.id)
   }
   }
 
 
   componentWillUnmount () {
   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)
     uppy.use(StatusBarPlugin, options)
 
 
-    this.plugin = uppy.getPlugin('StatusBar')
+    this.plugin = uppy.getPlugin(options.id)
   }
   }
 
 
   componentWillUnmount () {
   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 () {
   get hostname () {
-    const uppyServer = this.uppy.state.uppyServer || {}
+    const { uppyServer } = this.uppy.getState()
     const host = this.opts.host
     const host = this.opts.host
-    return uppyServer[host] || host
+    return uppyServer && uppyServer[host] ? uppyServer[host] : host
   }
   }
 
 
   onReceiveResponse (response) {
   onReceiveResponse (response) {
-    const uppyServer = this.uppy.state.uppyServer || {}
+    const state = this.uppy.getState()
+    const uppyServer = state.uppyServer || {}
     const host = this.opts.host
     const host = this.opts.host
     const headers = response.headers
     const headers = response.headers
     // Store the self-identified domain name for the uppy-server we just hit.
     // 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.
 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)`
 ### `uppy.getFile(fileID)`
 
 
 Get a specific file object by its ID.
 Get a specific file object by its ID.