Selaa lähdekoodia

companion,unsplash: Unsplash provider (#2431)

* unsplash[wip]: implement unsplash provider + plugin

* unsplash,provider-views,companion-client: fully working unsplash plugin

* unsplash: clean up plugin implementation

* provider-views: refactor CloseWrapper

* companion-client: encode search text before adding it to URL

Co-authored-by: Renée Kooi <renee@kooi.me>

* companion: use object spread over Object.assign

Co-authored-by: Renée Kooi <renee@kooi.me>

* zoom: fix provider-view import

* circle icon

* update locale strings and package-lock

* move truncateString to @uppy/utils and use it for Unsplash name-description conversion, adding .jpg

* use alt_description if description in null

* companion: handle name truncation internally

* companion: handling carriage for truncation + fix urlmeta import

Co-authored-by: Renée Kooi <renee@kooi.me>
Co-authored-by: Artur Paikin <artur@arturpaikin.com>
Ifedapo .A. Olarewaju 4 vuotta sitten
vanhempi
commit
d586a4cf53
53 muutettua tiedostoa jossa 1520 lisäystä ja 675 poistoa
  1. 1 1
      examples/custom-provider/client/MyCustomProvider.js
  2. 2 0
      examples/dev/Dashboard.js
  3. 10 0
      package-lock.json
  4. 1 0
      package.json
  5. 26 0
      packages/@uppy/companion-client/src/SearchProvider.js
  6. 2 0
      packages/@uppy/companion-client/src/index.js
  7. 4 0
      packages/@uppy/companion/src/companion.js
  8. 1 2
      packages/@uppy/companion/src/server/controllers/get.js
  9. 1 2
      packages/@uppy/companion/src/server/controllers/list.js
  10. 1 1
      packages/@uppy/companion/src/server/helpers/request.js
  11. 22 0
      packages/@uppy/companion/src/server/middlewares.js
  12. 33 0
      packages/@uppy/companion/src/server/provider/SearchProvider.js
  13. 11 1
      packages/@uppy/companion/src/server/provider/index.js
  14. 51 0
      packages/@uppy/companion/src/server/provider/unsplash/adapter.js
  15. 143 0
      packages/@uppy/companion/src/server/provider/unsplash/index.js
  16. 5 0
      packages/@uppy/companion/src/standalone/helper.js
  17. 3 0
      packages/@uppy/core/src/index.js
  18. 1 1
      packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js
  19. 1 1
      packages/@uppy/dropbox/src/index.js
  20. 1 1
      packages/@uppy/facebook/src/index.js
  21. 1 1
      packages/@uppy/google-drive/src/DriveProviderViews.js
  22. 1 1
      packages/@uppy/instagram/src/index.js
  23. 3 0
      packages/@uppy/locales/src/en_US.js
  24. 1 1
      packages/@uppy/onedrive/src/index.js
  25. 1 1
      packages/@uppy/provider-views/README.md
  26. 1 12
      packages/@uppy/provider-views/src/Browser.js
  27. 11 0
      packages/@uppy/provider-views/src/CloseWrapper.js
  28. 0 0
      packages/@uppy/provider-views/src/ProviderView/AuthView.js
  29. 22 0
      packages/@uppy/provider-views/src/ProviderView/Header.js
  30. 555 0
      packages/@uppy/provider-views/src/ProviderView/ProviderView.js
  31. 10 0
      packages/@uppy/provider-views/src/ProviderView/User.js
  32. 1 0
      packages/@uppy/provider-views/src/ProviderView/index.js
  33. 9 0
      packages/@uppy/provider-views/src/SearchProviderView/Header.js
  34. 37 0
      packages/@uppy/provider-views/src/SearchProviderView/InputView.js
  35. 247 0
      packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js
  36. 1 0
      packages/@uppy/provider-views/src/SearchProviderView/index.js
  37. 80 0
      packages/@uppy/provider-views/src/SharedHandler.js
  38. 5 647
      packages/@uppy/provider-views/src/index.js
  39. 1 0
      packages/@uppy/provider-views/src/style.scss
  40. 32 0
      packages/@uppy/provider-views/src/style/uppy-SearchProvider-input.scss
  41. 21 0
      packages/@uppy/unsplash/LICENSE
  42. 42 0
      packages/@uppy/unsplash/README.md
  43. 31 0
      packages/@uppy/unsplash/package.json
  44. 64 0
      packages/@uppy/unsplash/src/index.js
  45. 16 0
      packages/@uppy/unsplash/types/index.d.ts
  46. 2 0
      packages/@uppy/unsplash/types/index.test-d.ts
  47. 0 0
      packages/@uppy/utils/src/truncateString.js
  48. 0 0
      packages/@uppy/utils/src/truncateString.test.js
  49. 1 1
      packages/@uppy/zoom/src/index.js
  50. 1 0
      packages/uppy/index.js
  51. 1 0
      packages/uppy/index.mjs
  52. 1 0
      packages/uppy/package.json
  53. 1 1
      website/src/_posts/2020-03-custom-providers.md

+ 1 - 1
examples/custom-provider/client/MyCustomProvider.js

@@ -1,6 +1,6 @@
 const { Plugin } = require('@uppy/core')
 const { Provider } = require('@uppy/companion-client')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
 module.exports = class MyCustomProvider extends Plugin {

+ 2 - 0
examples/dev/Dashboard.js

@@ -5,6 +5,7 @@ const Facebook = require('@uppy/facebook/src')
 const OneDrive = require('@uppy/onedrive/src')
 const Dropbox = require('@uppy/dropbox/src')
 const GoogleDrive = require('@uppy/google-drive/src')
+const Unsplash = require('@uppy/unsplash/src')
 const Zoom = require('@uppy/zoom/src')
 const Url = require('@uppy/url/src')
 const Webcam = require('@uppy/webcam/src')
@@ -70,6 +71,7 @@ module.exports = () => {
     .use(OneDrive, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Zoom, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Url, { target: Dashboard, companionUrl: COMPANION_URL })
+    .use(Unsplash, { target: Dashboard, companionUrl: COMPANION_URL })
     .use(Webcam, {
       target: Dashboard,
       showVideoSourceDropdown: true,

+ 10 - 0
package-lock.json

@@ -8653,6 +8653,15 @@
         "tus-js-client": "^2.1.1"
       }
     },
+    "@uppy/unsplash": {
+      "version": "file:packages/@uppy/unsplash",
+      "requires": {
+        "@uppy/companion-client": "file:packages/@uppy/companion-client",
+        "@uppy/provider-views": "file:packages/@uppy/provider-views",
+        "@uppy/utils": "file:packages/@uppy/utils",
+        "preact": "8.2.9"
+      }
+    },
     "@uppy/url": {
       "version": "file:packages/@uppy/url",
       "requires": {
@@ -40578,6 +40587,7 @@
         "@uppy/thumbnail-generator": "file:packages/@uppy/thumbnail-generator",
         "@uppy/transloadit": "file:packages/@uppy/transloadit",
         "@uppy/tus": "file:packages/@uppy/tus",
+        "@uppy/unsplash": "file:packages/@uppy/unsplash",
         "@uppy/url": "file:packages/@uppy/url",
         "@uppy/webcam": "file:packages/@uppy/webcam",
         "@uppy/xhr-upload": "file:packages/@uppy/xhr-upload"

+ 1 - 0
package.json

@@ -68,6 +68,7 @@
     "@uppy/thumbnail-generator": "file:packages/@uppy/thumbnail-generator",
     "@uppy/transloadit": "file:packages/@uppy/transloadit",
     "@uppy/tus": "file:packages/@uppy/tus",
+    "@uppy/unsplash": "file:packages/@uppy/unsplash",
     "@uppy/url": "file:packages/@uppy/url",
     "@uppy/utils": "file:packages/@uppy/utils",
     "@uppy/webcam": "file:packages/@uppy/webcam",

+ 26 - 0
packages/@uppy/companion-client/src/SearchProvider.js

@@ -0,0 +1,26 @@
+'use strict'
+
+const RequestClient = require('./RequestClient')
+
+const _getName = (id) => {
+  return id.split('-').map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ')
+}
+
+module.exports = class SearchProvider extends RequestClient {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.provider = opts.provider
+    this.id = this.provider
+    this.name = this.opts.name || _getName(this.id)
+    this.pluginId = this.opts.pluginId
+  }
+
+  fileUrl (id) {
+    return `${this.hostname}/search/${this.id}/get/${id}`
+  }
+
+  search (text, queries) {
+    queries = queries ? `&${queries}` : ''
+    return this.get(`search/${this.id}/list?q=${encodeURIComponent(text)}${queries}`)
+  }
+}

+ 2 - 0
packages/@uppy/companion-client/src/index.js

@@ -6,10 +6,12 @@
 
 const RequestClient = require('./RequestClient')
 const Provider = require('./Provider')
+const SearchProvider = require('./SearchProvider')
 const Socket = require('./Socket')
 
 module.exports = {
   RequestClient,
   Provider,
+  SearchProvider,
   Socket
 }

+ 4 - 0
packages/@uppy/companion/src/companion.js

@@ -53,6 +53,7 @@ module.exports.app = (options = {}) => {
 
   options = merge({}, defaultOptions, options)
   const providers = providerManager.getDefaultProviders(options)
+  const searchProviders = providerManager.getSearchProviders()
   providerManager.addProviderOptions(options, grantConfig)
 
   const customProviders = options.customProviders
@@ -121,8 +122,11 @@ module.exports.app = (options = {}) => {
   app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
   app.post('/:providerName/get/:id', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.get)
   app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail)
+  app.get('/search/:searchProviderName/list', middlewares.hasSearchQuery, middlewares.loadSearchProviderToken, controllers.list)
+  app.post('/search/:searchProviderName/get/:id', middlewares.loadSearchProviderToken, controllers.get)
 
   app.param('providerName', providerManager.getProviderMiddleware(providers))
+  app.param('searchProviderName', providerManager.getProviderMiddleware(searchProviders))
 
   if (app.get('env') !== 'test') {
     jobs.startCleanUpJob(options.filePath)

+ 1 - 2
packages/@uppy/companion/src/server/controllers/get.js

@@ -3,9 +3,8 @@ const logger = require('../logger')
 const { errorToResponse } = require('../provider/error')
 
 function get (req, res, next) {
-  const providerName = req.params.providerName
   const id = req.params.id
-  const token = req.companion.providerTokens[providerName]
+  const token = req.companion.providerToken
   const provider = req.companion.provider
 
   // get the file size before proceeding

+ 1 - 2
packages/@uppy/companion/src/server/controllers/list.js

@@ -1,8 +1,7 @@
 const { errorToResponse } = require('../provider/error')
 
 function list ({ query, params, companion }, res, next) {
-  const providerName = params.providerName
-  const token = companion.providerTokens[providerName]
+  const token = companion.providerToken
 
   companion.provider.list({ companion, token, directory: params.id, query }, (err, data) => {
     if (err) {

+ 1 - 1
packages/@uppy/companion/src/server/helpers/request.js

@@ -157,7 +157,7 @@ class HttpsAgent extends https.Agent {
  *
  * @param {string} url
  * @param {boolean=} blockLocalIPs
- * @return {Promise}
+ * @return {Promise<{type: string, size: number}>}
  */
 exports.getURLMeta = (url, blockLocalIPs = false) => {
   return new Promise((resolve, reject) => {

+ 22 - 0
packages/@uppy/companion/src/server/middlewares.js

@@ -15,6 +15,15 @@ exports.hasSessionAndProvider = (req, res, next) => {
   return next()
 }
 
+exports.hasSearchQuery = (req, res, next) => {
+  if (typeof req.query.q !== 'string') {
+    logger.debug('search request has no search query', 'search.query.check', req.id)
+    return res.sendStatus(400)
+  }
+
+  return next()
+}
+
 exports.verifyToken = (req, res, next) => {
   const token = req.companion.authToken
   if (token == null) {
@@ -30,6 +39,7 @@ exports.verifyToken = (req, res, next) => {
     return res.sendStatus(401)
   }
   req.companion.providerTokens = payload
+  req.companion.providerToken = payload[providerName]
   next()
 }
 
@@ -49,3 +59,15 @@ exports.cookieAuthToken = (req, res, next) => {
   req.companion.authToken = req.cookies[`uppyAuthToken--${req.companion.provider.authProvider}`]
   return next()
 }
+
+exports.loadSearchProviderToken = (req, res, next) => {
+  const { searchProviders } = req.companion.options.providerOptions
+  const providerName = req.params.searchProviderName
+  if (!searchProviders || !searchProviders[providerName] || !searchProviders[providerName].key) {
+    logger.info(`unconfigured credentials for ${providerName}`, 'searchtoken.load.unset', req.id)
+    return res.sendStatus(501)
+  }
+
+  req.companion.providerToken = searchProviders[providerName].key
+  next()
+}

+ 33 - 0
packages/@uppy/companion/src/server/provider/SearchProvider.js

@@ -0,0 +1,33 @@
+/**
+ * SearchProvider interface defines the specifications of any Search provider implementation
+ */
+class SearchProvider {
+  /**
+   * list the files available based on the search query
+   * @param {object} options
+   * @param {function} cb
+   */
+  list (options, cb) {
+    throw new Error('method not implemented')
+  }
+
+  /**
+   * download a certain file from the provider files
+   * @param {object} options
+   * @param {function} cb
+   */
+  download (options, cb) {
+    throw new Error('method not implemented')
+  }
+
+  /**
+   * get the size of a certain file in the provider files
+   * @param {object} options
+   * @param {function} cb
+   */
+  size (options, cb) {
+    throw new Error('method not implemented')
+  }
+}
+
+module.exports = SearchProvider

+ 11 - 1
packages/@uppy/companion/src/server/provider/index.js

@@ -8,11 +8,14 @@ const drive = require('./drive')
 const instagram = require('./instagram/graph')
 const facebook = require('./facebook')
 const onedrive = require('./onedrive')
+const unsplash = require('./unsplash')
 const zoom = require('./zoom')
 const { getURLBuilder } = require('../helpers/utils')
 const logger = require('../logger')
 // eslint-disable-next-line
 const Provider = require('./Provider')
+// eslint-disable-next-line
+const SearchProvider = require('./SearchProvider')
 
 // leave here for now until Purest Providers gets updated with Zoom provider
 config.zoom = {
@@ -43,7 +46,7 @@ config.zoom = {
  * adds the desired provider module to the request object,
  * based on the providerName parameter specified
  *
- * @param {Object.<string, typeof Provider>} providers
+ * @param {Object.<string, (typeof Provider) | typeof SearchProvider>} providers
  */
 module.exports.getProviderMiddleware = (providers) => {
   /**
@@ -76,6 +79,13 @@ module.exports.getDefaultProviders = (companionOptions) => {
   return providers
 }
 
+/**
+ * @return {Object.<string, typeof SearchProvider>}
+ */
+module.exports.getSearchProviders = () => {
+  return { unsplash }
+}
+
 /**
  *
  * @typedef {{module: typeof Provider, config: object}} CustomProvider

+ 51 - 0
packages/@uppy/companion/src/server/provider/unsplash/adapter.js

@@ -0,0 +1,51 @@
+const querystring = require('querystring')
+
+exports.isFolder = (item) => {
+  return false
+}
+
+exports.getItemIcon = (item) => {
+  return item.urls.thumb
+}
+
+exports.getItemSubList = (item) => {
+  return item.results
+}
+
+exports.getItemName = (item) => {
+  const description = item.description || item.alt_description
+  if (description) {
+    return description.replace(/^([\S\s]{27})[\S\s]{3,}/, '$1...') + '.jpg'
+  }
+}
+
+exports.getMimeType = (item) => {
+  return 'image/jpeg'
+}
+
+exports.getItemId = (item) => {
+  return `${item.id}`
+}
+
+exports.getItemRequestPath = (item) => {
+  return `${item.id}`
+}
+
+exports.getItemModifiedDate = (item) => {
+  return item.created_at
+}
+
+exports.getItemThumbnailUrl = (item) => {
+  return item.urls.thumb
+}
+
+exports.getNextPageQuery = (currentQuery) => {
+  const newCursor = parseInt(currentQuery.cursor || 1) + 1
+  const query = {
+    ...currentQuery,
+    cursor: newCursor
+  }
+
+  delete query.q
+  return querystring.stringify(query)
+}

+ 143 - 0
packages/@uppy/companion/src/server/provider/unsplash/index.js

@@ -0,0 +1,143 @@
+const SearchProvider = require('../SearchProvider')
+const request = require('request')
+const { getURLMeta } = require('../../helpers/request')
+const logger = require('../../logger')
+const adapter = require('./adapter')
+const { ProviderApiError } = require('../error')
+const BASE_URL = 'https://api.unsplash.com'
+
+/**
+ * Adapter for API https://api.unsplash.com
+ */
+class Unsplash extends SearchProvider {
+  list ({ token, query = { cursor: null, q: null } }, done) {
+    const reqOpts = {
+      url: `${BASE_URL}/search/photos`,
+      method: 'GET',
+      json: true,
+      qs: {
+        per_page: 40,
+        query: query.q
+      },
+      headers: {
+        Authorization: `Client-ID ${token}`
+      }
+    }
+
+    if (query.cursor) {
+      reqOpts.qs.page = query.cursor
+    }
+
+    request(reqOpts, (err, resp, body) => {
+      if (err || resp.statusCode !== 200) {
+        err = this._error(err, resp)
+        logger.error(err, 'provider.unsplash.list.error')
+        return done(err)
+      } else {
+        done(null, this.adaptData(body, query))
+      }
+    })
+  }
+
+  download ({ id, token }, onData) {
+    const reqOpts = {
+      url: `${BASE_URL}/photos/${id}`,
+      method: 'GET',
+      json: true,
+      headers: {
+        Authorization: `Client-ID ${token}`
+      }
+    }
+
+    request(reqOpts, (err, resp, body) => {
+      if (err || resp.statusCode !== 200) {
+        err = this._error(err, resp)
+        logger.error(err, 'provider.unsplash.download.error')
+        onData(err)
+        return
+      }
+
+      const url = body.links.download
+      request.get(url)
+        .on('response', (resp) => {
+          if (resp.statusCode !== 200) {
+            onData(this._error(null, resp))
+          } else {
+            resp.on('data', (chunk) => onData(null, chunk))
+          }
+        })
+        .on('end', () => onData(null, null))
+        .on('error', (err) => {
+          logger.error(err, 'provider.unsplash.download.url.error')
+          onData(err)
+        })
+    })
+  }
+
+  size ({ id, token }, done) {
+    const reqOpts = {
+      url: `${BASE_URL}/photos/${id}`,
+      method: 'GET',
+      json: true,
+      headers: {
+        Authorization: `Client-ID ${token}`
+      }
+    }
+
+    request(reqOpts, (err, resp, body) => {
+      if (err || resp.statusCode !== 200) {
+        err = this._error(err, resp)
+        logger.error(err, 'provider.unsplash.size.error')
+        done(err)
+        return
+      }
+
+      getURLMeta(body.links.download)
+        .then(({ size }) => done(null, size))
+        .catch((err) => {
+          logger.error(err, 'provider.unsplash.size.error')
+          done()
+        })
+    })
+  }
+
+  adaptData (body, currentQuery) {
+    const data = {
+      searchedFor: currentQuery.q,
+      username: null,
+      items: []
+    }
+    const items = adapter.getItemSubList(body)
+    items.forEach((item) => {
+      data.items.push({
+        isFolder: adapter.isFolder(item),
+        icon: adapter.getItemIcon(item),
+        name: adapter.getItemName(item),
+        mimeType: adapter.getMimeType(item),
+        id: adapter.getItemId(item),
+        thumbnail: adapter.getItemThumbnailUrl(item),
+        requestPath: adapter.getItemRequestPath(item),
+        modifiedDate: adapter.getItemModifiedDate(item),
+        size: null
+      })
+    })
+
+    const pagesCount = body.total_pages
+    const currentPage = parseInt(currentQuery.cursor || 1)
+    const hasNextPage = currentPage < pagesCount
+    data.nextPageQuery = hasNextPage ? adapter.getNextPageQuery(currentQuery) : null
+    return data
+  }
+
+  _error (err, resp) {
+    if (resp) {
+      const fallbackMessage = `request to Unsplash returned ${resp.statusCode}`
+      const msg = resp.body && resp.body.errors ? `${resp.body.errors}` : fallbackMessage
+      return new ProviderApiError(msg, resp.statusCode)
+    }
+
+    return err
+  }
+}
+
+module.exports = Unsplash

+ 5 - 0
packages/@uppy/companion/src/standalone/helper.js

@@ -54,6 +54,11 @@ const getConfigFromEnv = () => {
         secret: getSecret('COMPANION_ZOOM_SECRET'),
         verificationToken: getSecret('COMPANION_ZOOM_VERIFICATION_TOKEN')
       },
+      searchProviders: {
+        unsplash: {
+          key: process.env.COMPANION_UNSPLASH_KEY
+        }
+      },
       s3: {
         key: process.env.COMPANION_AWS_KEY,
         secret: getSecret('COMPANION_AWS_SECRET'),

+ 3 - 0
packages/@uppy/core/src/index.js

@@ -81,6 +81,9 @@ class Uppy {
         loading: 'Loading...',
         authenticateWithTitle: 'Please authenticate with %{pluginName} to select files',
         authenticateWith: 'Connect to %{pluginName}',
+        searchImages: 'Search for images',
+        enterTextToSearch: 'Enter text to search for images',
+        backToSearch: 'Back to Search',
         emptyFolderAdded: 'No files were added from empty folder',
         folderAdded: {
           0: 'Added %{smart_count} file from %{folder}',

+ 1 - 1
packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js

@@ -1,6 +1,6 @@
 const { h } = require('preact')
 const prettierBytes = require('@transloadit/prettier-bytes')
-const truncateString = require('../../../utils/truncateString')
+const truncateString = require('@uppy/utils/lib/truncateString')
 
 const renderAcquirerIcon = (acquirer, props) =>
   <span title={props.i18n('fileSource', { name: acquirer.name })}>

+ 1 - 1
packages/@uppy/dropbox/src/index.js

@@ -1,6 +1,6 @@
 const { Plugin } = require('@uppy/core')
 const { Provider } = require('@uppy/companion-client')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
 module.exports = class Dropbox extends Plugin {

+ 1 - 1
packages/@uppy/facebook/src/index.js

@@ -1,6 +1,6 @@
 const { Plugin } = require('@uppy/core')
 const { Provider } = require('@uppy/companion-client')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
 module.exports = class Facebook extends Plugin {

+ 1 - 1
packages/@uppy/google-drive/src/DriveProviderViews.js

@@ -1,4 +1,4 @@
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 
 module.exports = class DriveProviderViews extends ProviderViews {
   toggleCheckbox (e, file) {

+ 1 - 1
packages/@uppy/instagram/src/index.js

@@ -1,6 +1,6 @@
 const { Plugin } = require('@uppy/core')
 const { Provider } = require('@uppy/companion-client')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
 module.exports = class Instagram extends Plugin {

+ 3 - 0
packages/@uppy/locales/src/en_US.js

@@ -16,6 +16,7 @@ en_US.strings = {
   authenticateWith: 'Connect to %{pluginName}',
   authenticateWithTitle: 'Please authenticate with %{pluginName} to select files',
   back: 'Back',
+  backToSearch: 'Back to Search',
   browse: 'browse',
   browseFiles: 'browse files',
   browseFolders: 'browse folders',
@@ -49,6 +50,7 @@ en_US.strings = {
   emptyFolderAdded: 'No files were added from empty folder',
   encoding: 'Encoding...',
   enterCorrectUrl: 'Incorrect URL: Please make sure you are entering a direct link to a file',
+  enterTextToSearch: 'Enter text to search for images',
   enterUrlToImport: 'Enter URL to import a file',
   exceedsSize: 'This file exceeds maximum allowed size of',
   exceedsSize2: '%{backwardsCompat} %{size}',
@@ -103,6 +105,7 @@ en_US.strings = {
   rotate: 'Rotate',
   save: 'Save',
   saveChanges: 'Save changes',
+  searchImages: 'Search for images',
   selectAllFilesFromFolderNamed: 'Select all files from folder %{name}',
   selectFileNamed: 'Select file %{name}',
   selectX: {

+ 1 - 1
packages/@uppy/onedrive/src/index.js

@@ -1,6 +1,6 @@
 const { Plugin } = require('@uppy/core')
 const { Provider } = require('@uppy/companion-client')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
 module.exports = class OneDrive extends Plugin {

+ 1 - 1
packages/@uppy/provider-views/README.md

@@ -13,7 +13,7 @@ Uppy is being developed by the folks at [Transloadit](https://transloadit.com),
 
 ```js
 const Plugin = require('@uppy/core/lib/plugin')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 
 class GoogleDrive extends Plugin {
   constructor () { /* snip */ }

+ 1 - 12
packages/@uppy/provider-views/src/Browser.js

@@ -1,5 +1,4 @@
 const classNames = require('classnames')
-const Breadcrumbs = require('./Breadcrumbs')
 const Filter = require('./Filter')
 const ItemList = require('./ItemList')
 const FooterActions = require('./FooterActions')
@@ -20,16 +19,7 @@ const Browser = (props) => {
     <div class={classNames('uppy-ProviderBrowser', `uppy-ProviderBrowser-viewType--${props.viewType}`)}>
       <div class="uppy-ProviderBrowser-header">
         <div class={classNames('uppy-ProviderBrowser-headerBar', !props.showBreadcrumbs && 'uppy-ProviderBrowser-headerBar--simple')}>
-          {props.showBreadcrumbs && Breadcrumbs({
-            getFolder: props.getFolder,
-            directories: props.directories,
-            breadcrumbsIcon: props.pluginIcon && props.pluginIcon(),
-            title: props.title
-          })}
-          <span class="uppy-ProviderBrowser-user">{props.username}</span>
-          <button type="button" onclick={props.logout} class="uppy-u-reset uppy-ProviderBrowser-userLogout">
-            {props.i18n('logOut')}
-          </button>
+          {props.headerComponent}
         </div>
       </div>
       {props.showFilter && <Filter {...props} />}
@@ -40,7 +30,6 @@ const Browser = (props) => {
         }]}
         folders={filteredFolders}
         files={filteredFiles}
-        activeRow={props.isActiveRow}
         sortByTitle={props.sortByTitle}
         sortByDate={props.sortByDate}
         isChecked={props.isChecked}

+ 11 - 0
packages/@uppy/provider-views/src/CloseWrapper.js

@@ -0,0 +1,11 @@
+const { h, Component } = require('preact')
+
+module.exports = class CloseWrapper extends Component {
+  componentWillUnmount () {
+    this.props.onUnmount()
+  }
+
+  render () {
+    return this.props.children[0]
+  }
+}

+ 0 - 0
packages/@uppy/provider-views/src/AuthView.js → packages/@uppy/provider-views/src/ProviderView/AuthView.js


+ 22 - 0
packages/@uppy/provider-views/src/ProviderView/Header.js

@@ -0,0 +1,22 @@
+const User = require('./User')
+const Breadcrumbs = require('../Breadcrumbs')
+
+module.exports = (props) => {
+  const components = []
+  if (props.showBreadcrumbs) {
+    components.push(Breadcrumbs({
+      getFolder: props.getFolder,
+      directories: props.directories,
+      breadcrumbsIcon: props.pluginIcon && props.pluginIcon(),
+      title: props.title
+    }))
+  }
+
+  components.push(User({
+    logout: props.logout,
+    username: props.username,
+    i18n: props.i18n
+  }))
+
+  return components
+}

+ 555 - 0
packages/@uppy/provider-views/src/ProviderView/ProviderView.js

@@ -0,0 +1,555 @@
+const { h } = require('preact')
+const AuthView = require('./AuthView')
+const Header = require('./Header')
+const Browser = require('../Browser')
+const LoaderView = require('../Loader')
+const generateFileID = require('@uppy/utils/lib/generateFileID')
+const getFileType = require('@uppy/utils/lib/getFileType')
+const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
+const SharedHandler = require('../SharedHandler')
+const CloseWrapper = require('../CloseWrapper')
+
+/**
+ * Array.prototype.findIndex ponyfill for old browsers.
+ */
+function findIndex (array, predicate) {
+  for (let i = 0; i < array.length; i++) {
+    if (predicate(array[i])) return i
+  }
+  return -1
+}
+
+// location.origin does not exist in IE
+function getOrigin () {
+  if ('origin' in location) {
+    return location.origin // eslint-disable-line compat/compat
+  }
+  return `${location.protocol}//${location.hostname}${location.port ? `:${location.port}` : ''}`
+}
+
+/**
+ * Class to easily generate generic views for Provider plugins
+ */
+module.exports = class ProviderView {
+  static VERSION = require('../../package.json').version
+
+  /**
+   * @param {object} plugin instance of the plugin
+   * @param {object} opts
+   */
+  constructor (plugin, opts) {
+    this.plugin = plugin
+    this.provider = opts.provider
+    this._sharedHandler = new SharedHandler(plugin)
+
+    // set default options
+    const defaultOptions = {
+      viewType: 'list',
+      showTitles: true,
+      showFilter: true,
+      showBreadcrumbs: true
+    }
+
+    // merge default options with the ones set by user
+    this.opts = { ...defaultOptions, ...opts }
+
+    // Logic
+    this.addFile = this.addFile.bind(this)
+    this.filterQuery = this.filterQuery.bind(this)
+    this.getFolder = this.getFolder.bind(this)
+    this.getNextFolder = this.getNextFolder.bind(this)
+    this.logout = this.logout.bind(this)
+    this.preFirstRender = this.preFirstRender.bind(this)
+    this.handleAuth = this.handleAuth.bind(this)
+    this.sortByTitle = this.sortByTitle.bind(this)
+    this.sortByDate = this.sortByDate.bind(this)
+    this.handleError = this.handleError.bind(this)
+    this.handleScroll = this.handleScroll.bind(this)
+    this.listAllFiles = this.listAllFiles.bind(this)
+    this.donePicking = this.donePicking.bind(this)
+    this.cancelPicking = this.cancelPicking.bind(this)
+    this.clearSelection = this.clearSelection.bind(this)
+
+    // Visual
+    this.render = this.render.bind(this)
+
+    this.clearSelection()
+
+    // Set default state for the plugin
+    this.plugin.setPluginState({
+      authenticated: false,
+      files: [],
+      folders: [],
+      directories: [],
+      filterInput: '',
+      isSearchVisible: false
+    })
+  }
+
+  tearDown () {
+    // Nothing.
+  }
+
+  _updateFilesAndFolders (res, files, folders) {
+    this.nextPagePath = res.nextPagePath
+    res.items.forEach((item) => {
+      if (item.isFolder) {
+        folders.push(item)
+      } else {
+        files.push(item)
+      }
+    })
+
+    this.plugin.setPluginState({ folders, files })
+  }
+
+  /**
+   * Called only the first time the provider view is rendered.
+   * Kind of like an init function.
+   */
+  preFirstRender () {
+    this.plugin.setPluginState({ didFirstRender: true })
+    this.plugin.onFirstRender()
+  }
+
+  /**
+   * Based on folder ID, fetch a new folder and update it to state
+   *
+   * @param  {string} id Folder id
+   * @returns {Promise}   Folders/files in folder
+   */
+  getFolder (id, name) {
+    return this._sharedHandler.loaderWrapper(
+      this.provider.list(id),
+      (res) => {
+        const folders = []
+        const files = []
+        let updatedDirectories
+
+        const state = this.plugin.getPluginState()
+        const index = findIndex(state.directories, (dir) => id === dir.id)
+
+        if (index !== -1) {
+          updatedDirectories = state.directories.slice(0, index + 1)
+        } else {
+          updatedDirectories = state.directories.concat([{ id, title: name }])
+        }
+
+        this.username = res.username || this.username
+        this._updateFilesAndFolders(res, files, folders)
+        this.plugin.setPluginState({ directories: updatedDirectories })
+      },
+      this.handleError)
+  }
+
+  /**
+   * Fetches new folder
+   *
+   * @param  {object} folder
+   * @param  {string} title Folder title
+   */
+  getNextFolder (folder) {
+    this.getFolder(folder.requestPath, folder.name)
+    this.lastCheckbox = undefined
+  }
+
+  addFile (file) {
+    const tagFile = {
+      id: this.providerFileToId(file),
+      source: this.plugin.id,
+      data: file,
+      name: file.name || file.id,
+      type: file.mimeType,
+      isRemote: true,
+      body: {
+        fileId: file.id
+      },
+      remote: {
+        companionUrl: this.plugin.opts.companionUrl,
+        url: `${this.provider.fileUrl(file.requestPath)}`,
+        body: {
+          fileId: file.id
+        },
+        providerOptions: this.provider.opts
+      }
+    }
+
+    const fileType = getFileType(tagFile)
+    // TODO Should we just always use the thumbnail URL if it exists?
+    if (fileType && isPreviewSupported(fileType)) {
+      tagFile.preview = file.thumbnail
+    }
+    this.plugin.uppy.log('Adding remote file')
+    try {
+      this.plugin.uppy.addFile(tagFile)
+      return true
+    } catch (err) {
+      if (!err.isRestriction) {
+        this.plugin.uppy.log(err)
+      }
+      return false
+    }
+  }
+
+  /**
+   * Removes session token on client side.
+   */
+  logout () {
+    this.provider.logout()
+      .then((res) => {
+        if (res.ok) {
+          if (!res.revoked) {
+            const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
+              provider: this.plugin.title,
+              url: res.manual_revoke_url
+            })
+            this.plugin.uppy.info(message, 'info', 7000)
+          }
+
+          const newState = {
+            authenticated: false,
+            files: [],
+            folders: [],
+            directories: []
+          }
+          this.plugin.setPluginState(newState)
+        }
+      }).catch(this.handleError)
+  }
+
+  filterQuery (e) {
+    const state = this.plugin.getPluginState()
+    this.plugin.setPluginState(Object.assign({}, state, {
+      filterInput: e ? e.target.value : ''
+    }))
+  }
+
+  sortByTitle () {
+    const state = Object.assign({}, this.plugin.getPluginState())
+    const { files, folders, sorting } = state
+
+    const sortedFiles = files.sort((fileA, fileB) => {
+      if (sorting === 'titleDescending') {
+        return fileB.name.localeCompare(fileA.name)
+      }
+      return fileA.name.localeCompare(fileB.name)
+    })
+
+    const sortedFolders = folders.sort((folderA, folderB) => {
+      if (sorting === 'titleDescending') {
+        return folderB.name.localeCompare(folderA.name)
+      }
+      return folderA.name.localeCompare(folderB.name)
+    })
+
+    this.plugin.setPluginState(Object.assign({}, state, {
+      files: sortedFiles,
+      folders: sortedFolders,
+      sorting: (sorting === 'titleDescending') ? 'titleAscending' : 'titleDescending'
+    }))
+  }
+
+  sortByDate () {
+    const state = Object.assign({}, this.plugin.getPluginState())
+    const { files, folders, sorting } = state
+
+    const sortedFiles = files.sort((fileA, fileB) => {
+      const a = new Date(fileA.modifiedDate)
+      const b = new Date(fileB.modifiedDate)
+
+      if (sorting === 'dateDescending') {
+        return a > b ? -1 : a < b ? 1 : 0
+      }
+      return a > b ? 1 : a < b ? -1 : 0
+    })
+
+    const sortedFolders = folders.sort((folderA, folderB) => {
+      const a = new Date(folderA.modifiedDate)
+      const b = new Date(folderB.modifiedDate)
+
+      if (sorting === 'dateDescending') {
+        return a > b ? -1 : a < b ? 1 : 0
+      }
+
+      return a > b ? 1 : a < b ? -1 : 0
+    })
+
+    this.plugin.setPluginState(Object.assign({}, state, {
+      files: sortedFiles,
+      folders: sortedFolders,
+      sorting: (sorting === 'dateDescending') ? 'dateAscending' : 'dateDescending'
+    }))
+  }
+
+  sortBySize () {
+    const state = Object.assign({}, this.plugin.getPluginState())
+    const { files, sorting } = state
+
+    // check that plugin supports file sizes
+    if (!files.length || !this.plugin.getItemData(files[0]).size) {
+      return
+    }
+
+    const sortedFiles = files.sort((fileA, fileB) => {
+      const a = fileA.size
+      const b = fileB.size
+
+      if (sorting === 'sizeDescending') {
+        return a > b ? -1 : a < b ? 1 : 0
+      }
+      return a > b ? 1 : a < b ? -1 : 0
+    })
+
+    this.plugin.setPluginState(Object.assign({}, state, {
+      files: sortedFiles,
+      sorting: (sorting === 'sizeDescending') ? 'sizeAscending' : 'sizeDescending'
+    }))
+  }
+
+  /**
+   * Adds all files found inside of specified folder.
+   *
+   * Uses separated state while folder contents are being fetched and
+   * mantains list of selected folders, which are separated from files.
+   */
+  addFolder (folder) {
+    const folderId = this.providerFileToId(folder)
+    const state = this.plugin.getPluginState()
+    const folders = { ...state.selectedFolders }
+    if (folderId in folders && folders[folderId].loading) {
+      return
+    }
+    folders[folderId] = { loading: true, files: [] }
+    this.plugin.setPluginState({ selectedFolders: { ...folders } })
+    return this.listAllFiles(folder.requestPath).then((files) => {
+      let count = 0
+      files.forEach((file) => {
+        const success = this.addFile(file)
+        if (success) count++
+      })
+      const ids = files.map(this.providerFileToId)
+      folders[folderId] = {
+        loading: false,
+        files: ids
+      }
+      this.plugin.setPluginState({ selectedFolders: folders })
+
+      let message
+      if (files.length) {
+        message = this.plugin.uppy.i18n('folderAdded', {
+          smart_count: count, folder: folder.name
+        })
+      } else {
+        message = this.plugin.uppy.i18n('emptyFolderAdded')
+      }
+      this.plugin.uppy.info(message)
+    }).catch((e) => {
+      const state = this.plugin.getPluginState()
+      const selectedFolders = { ...state.selectedFolders }
+      delete selectedFolders[folderId]
+      this.plugin.setPluginState({ selectedFolders })
+      this.handleError(e)
+    })
+  }
+
+  providerFileToId (file) {
+    return generateFileID({
+      data: file,
+      name: file.name || file.id,
+      type: file.mimeType
+    })
+  }
+
+  handleAuth () {
+    const authState = btoa(JSON.stringify({ origin: getOrigin() }))
+    const clientVersion = encodeURIComponent(`@uppy/provider-views=${ProviderView.VERSION}`)
+    const link = `${this.provider.authUrl()}?state=${authState}&uppyVersions=${clientVersion}`
+
+    const authWindow = window.open(link, '_blank')
+    const handleToken = (e) => {
+      if (!this._isOriginAllowed(e.origin, this.plugin.opts.companionAllowedHosts) || e.source !== authWindow) {
+        this.plugin.uppy.log(`rejecting event from ${e.origin} vs allowed pattern ${this.plugin.opts.companionAllowedHosts}`)
+        return
+      }
+
+      // Check if it's a string before doing the JSON.parse to maintain support
+      // for older Companion versions that used object references
+      const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
+
+      if (!data.token) {
+        this.plugin.uppy.log('did not receive token from auth window')
+        return
+      }
+
+      authWindow.close()
+      window.removeEventListener('message', handleToken)
+      this.provider.setAuthToken(data.token)
+      this.preFirstRender()
+    }
+    window.addEventListener('message', handleToken)
+  }
+
+  _isOriginAllowed (origin, allowedOrigin) {
+    const getRegex = (value) => {
+      if (typeof value === 'string') {
+        return new RegExp(`^${value}$`)
+      } else if (value instanceof RegExp) {
+        return value
+      }
+    }
+
+    const patterns = Array.isArray(allowedOrigin) ? allowedOrigin.map(getRegex) : [getRegex(allowedOrigin)]
+    return patterns
+      .filter((pattern) => pattern != null) // loose comparison to catch undefined
+      .some((pattern) => pattern.test(origin) || pattern.test(`${origin}/`)) // allowing for trailing '/'
+  }
+
+  handleError (error) {
+    const uppy = this.plugin.uppy
+    uppy.log(error.toString())
+    if (error.isAuthError) {
+      return
+    }
+    const message = uppy.i18n('companionError')
+    uppy.info({ message: message, details: error.toString() }, 'error', 5000)
+  }
+
+  handleScroll (e) {
+    const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
+    const path = this.nextPagePath || null
+
+    if (scrollPos < 50 && path && !this._isHandlingScroll) {
+      this.provider.list(path)
+        .then((res) => {
+          const { files, folders } = this.plugin.getPluginState()
+          this._updateFilesAndFolders(res, files, folders)
+        }).catch(this.handleError)
+        .then(() => { this._isHandlingScroll = false }) // always called
+
+      this._isHandlingScroll = true
+    }
+  }
+
+  listAllFiles (path, files = null) {
+    files = files || []
+    return new Promise((resolve, reject) => {
+      this.provider.list(path).then((res) => {
+        res.items.forEach((item) => {
+          if (!item.isFolder) {
+            files.push(item)
+          } else {
+            this.addFolder(item)
+          }
+        })
+        const moreFiles = res.nextPagePath || null
+        if (moreFiles) {
+          return this.listAllFiles(moreFiles, files)
+            .then((files) => resolve(files))
+            .catch(e => reject(e))
+        } else {
+          return resolve(files)
+        }
+      }).catch(e => reject(e))
+    })
+  }
+
+  donePicking () {
+    const { currentSelection } = this.plugin.getPluginState()
+    const promises = currentSelection.map((file) => {
+      if (file.isFolder) {
+        return this.addFolder(file)
+      } else {
+        return this.addFile(file)
+      }
+    })
+
+    this._sharedHandler.loaderWrapper(Promise.all(promises), () => {
+      this.clearSelection()
+    }, () => {})
+  }
+
+  cancelPicking () {
+    this.clearSelection()
+
+    const dashboard = this.plugin.uppy.getPlugin('Dashboard')
+    if (dashboard) dashboard.hideAllPanels()
+  }
+
+  clearSelection () {
+    this.plugin.setPluginState({ currentSelection: [] })
+  }
+
+  render (state, viewOptions = {}) {
+    const { authenticated, didFirstRender } = this.plugin.getPluginState()
+    if (!didFirstRender) {
+      this.preFirstRender()
+    }
+
+    // reload pluginState for "loading" attribute because it might
+    // have changed above.
+    if (this.plugin.getPluginState().loading) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <LoaderView i18n={this.plugin.uppy.i18n} />
+        </CloseWrapper>
+      )
+    }
+
+    if (!authenticated) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <AuthView
+            pluginName={this.plugin.title}
+            pluginIcon={this.plugin.icon}
+            handleAuth={this.handleAuth}
+            i18n={this.plugin.uppy.i18n}
+            i18nArray={this.plugin.uppy.i18nArray}
+          />
+        </CloseWrapper>
+      )
+    }
+
+    const targetViewOptions = { ...this.opts, ...viewOptions }
+    const headerProps = {
+      showBreadcrumbs: targetViewOptions.showBreadcrumbs,
+      getFolder: this.getFolder,
+      directories: this.plugin.getPluginState().directories,
+      pluginIcon: this.plugin.icon,
+      title: this.plugin.title,
+      logout: this.logout,
+      username: this.username,
+      i18n: this.plugin.uppy.i18n
+    }
+
+    const browserProps = Object.assign({}, this.plugin.getPluginState(), {
+      username: this.username,
+      getNextFolder: this.getNextFolder,
+      getFolder: this.getFolder,
+      filterItems: this._sharedHandler.filterItems,
+      filterQuery: this.filterQuery,
+      sortByTitle: this.sortByTitle,
+      sortByDate: this.sortByDate,
+      logout: this.logout,
+      isChecked: this._sharedHandler.isChecked,
+      toggleCheckbox: this._sharedHandler.toggleCheckbox,
+      handleScroll: this.handleScroll,
+      listAllFiles: this.listAllFiles,
+      done: this.donePicking,
+      cancel: this.cancelPicking,
+      headerComponent: Header(headerProps),
+      title: this.plugin.title,
+      viewType: targetViewOptions.viewType,
+      showTitles: targetViewOptions.showTitles,
+      showFilter: targetViewOptions.showFilter,
+      showBreadcrumbs: targetViewOptions.showBreadcrumbs,
+      pluginIcon: this.plugin.icon,
+      i18n: this.plugin.uppy.i18n
+    })
+
+    return (
+      <CloseWrapper onUnmount={this.clearSelection}>
+        <Browser {...browserProps} />
+      </CloseWrapper>
+    )
+  }
+}

+ 10 - 0
packages/@uppy/provider-views/src/ProviderView/User.js

@@ -0,0 +1,10 @@
+const { h } = require('preact')
+
+module.exports = (props) => {
+  return ([
+    <span class="uppy-ProviderBrowser-user" key="username">{props.username}</span>,
+    <button type="button" onclick={props.logout} class="uppy-u-reset uppy-ProviderBrowser-userLogout" key="logout">
+      {props.i18n('logOut')}
+    </button>
+  ])
+}

+ 1 - 0
packages/@uppy/provider-views/src/ProviderView/index.js

@@ -0,0 +1 @@
+module.exports = require('./ProviderView')

+ 9 - 0
packages/@uppy/provider-views/src/SearchProviderView/Header.js

@@ -0,0 +1,9 @@
+const { h } = require('preact')
+
+module.exports = (props) => {
+  return (
+    <button type="button" onclick={props.triggerSearchInput} class="uppy-u-reset uppy-ProviderBrowser-userLogout">
+      {props.i18n('backToSearch')}
+    </button>
+  )
+}

+ 37 - 0
packages/@uppy/provider-views/src/SearchProviderView/InputView.js

@@ -0,0 +1,37 @@
+const { h } = require('preact')
+
+module.exports = (props) => {
+  let input
+  const handleKeyPress = (ev) => {
+    if (ev.keyCode === 13) {
+      validateAndSearch()
+    }
+  }
+
+  const validateAndSearch = () => {
+    if (input.value) {
+      props.search(input.value)
+    }
+  }
+
+  return (
+    <div class="uppy-SearchProvider">
+      <input
+        class="uppy-u-reset uppy-c-textInput uppy-SearchProvider-input"
+        type="text"
+        aria-label={props.i18n('enterTextToSearch')}
+        placeholder={props.i18n('enterTextToSearch')}
+        onkeyup={handleKeyPress}
+        ref={(input_) => { input = input_ }}
+        data-uppy-super-focusable
+      />
+      <button
+        class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-SearchProvider-searchButton"
+        type="button"
+        onclick={validateAndSearch}
+      >
+        {props.i18n('searchImages')}
+      </button>
+    </div>
+  )
+}

+ 247 - 0
packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js

@@ -0,0 +1,247 @@
+const { h } = require('preact')
+const SearchInput = require('./InputView')
+const Browser = require('../Browser')
+const LoaderView = require('../Loader')
+const generateFileID = require('@uppy/utils/lib/generateFileID')
+const getFileType = require('@uppy/utils/lib/getFileType')
+const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
+const Header = require('./Header')
+const SharedHandler = require('../SharedHandler')
+const CloseWrapper = require('../CloseWrapper')
+
+/**
+ * Class to easily generate generic views for Provider plugins
+ */
+module.exports = class ProviderView {
+  static VERSION = require('../../package.json').version
+
+  /**
+   * @param {object} plugin instance of the plugin
+   * @param {object} opts
+   */
+  constructor (plugin, opts) {
+    this.plugin = plugin
+    this.provider = opts.provider
+    this._sharedHandler = new SharedHandler(plugin)
+
+    // set default options
+    const defaultOptions = {
+      viewType: 'grid',
+      showTitles: false,
+      showFilter: false,
+      showBreadcrumbs: false
+    }
+
+    // merge default options with the ones set by user
+    this.opts = { ...defaultOptions, ...opts }
+
+    // Logic
+    this.search = this.search.bind(this)
+    this.triggerSearchInput = this.triggerSearchInput.bind(this)
+    this.addFile = this.addFile.bind(this)
+    this.preFirstRender = this.preFirstRender.bind(this)
+    this.handleError = this.handleError.bind(this)
+    this.handleScroll = this.handleScroll.bind(this)
+    this.donePicking = this.donePicking.bind(this)
+    this.cancelPicking = this.cancelPicking.bind(this)
+    this.clearSelection = this.clearSelection.bind(this)
+
+    // Visual
+    this.render = this.render.bind(this)
+
+    this.clearSelection()
+
+    // Set default state for the plugin
+    this.plugin.setPluginState({
+      isInputMode: true,
+      files: [],
+      folders: [],
+      directories: [],
+      filterInput: '',
+      isSearchVisible: false
+    })
+  }
+
+  tearDown () {
+    // Nothing.
+  }
+
+  _updateFilesAndInputMode (res, files) {
+    this.nextPageQuery = res.nextPageQuery
+    this._searchTerm = res.searchedFor
+    res.items.forEach((item) => { files.push(item) })
+    this.plugin.setPluginState({ isInputMode: false, files })
+  }
+
+  /**
+   * Called only the first time the provider view is rendered.
+   * Kind of like an init function.
+   */
+  preFirstRender () {
+    this.plugin.setPluginState({ didFirstRender: true })
+    this.plugin.onFirstRender()
+  }
+
+  search (query) {
+    if (query && query === this._searchTerm) {
+      // no need to search again as this is the same as the previous search
+      this.plugin.setPluginState({ isInputMode: false })
+      return
+    }
+
+    return this._sharedHandler.loaderWrapper(
+      this.provider.search(query),
+      (res) => {
+        this._updateFilesAndInputMode(res, [])
+      },
+      this.handleError
+    )
+  }
+
+  triggerSearchInput () {
+    this.plugin.setPluginState({ isInputMode: true })
+  }
+
+  // @todo this function should really be a function of the plugin and not the view.
+  // maybe we should consider creating a base ProviderPlugin class that has this method
+  addFile (file) {
+    const tagFile = {
+      id: this.providerFileToId(file),
+      source: this.plugin.id,
+      data: file,
+      name: file.name || file.id,
+      type: file.mimeType,
+      isRemote: true,
+      body: {
+        fileId: file.id
+      },
+      remote: {
+        companionUrl: this.plugin.opts.companionUrl,
+        url: `${this.provider.fileUrl(file.requestPath)}`,
+        body: {
+          fileId: file.id
+        },
+        providerOptions: Object.assign({}, this.provider.opts, { provider: null })
+      }
+    }
+
+    const fileType = getFileType(tagFile)
+    // TODO Should we just always use the thumbnail URL if it exists?
+    if (fileType && isPreviewSupported(fileType)) {
+      tagFile.preview = file.thumbnail
+    }
+    this.plugin.uppy.log('Adding remote file')
+    try {
+      this.plugin.uppy.addFile(tagFile)
+    } catch (err) {
+      if (!err.isRestriction) {
+        this.plugin.uppy.log(err)
+      }
+    }
+  }
+
+  providerFileToId (file) {
+    return generateFileID({
+      data: file,
+      name: file.name || file.id,
+      type: file.mimeType
+    })
+  }
+
+  handleError (error) {
+    const uppy = this.plugin.uppy
+    uppy.log(error.toString())
+    const message = uppy.i18n('companionError')
+    uppy.info({ message: message, details: error.toString() }, 'error', 5000)
+  }
+
+  handleScroll (e) {
+    const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
+    const query = this.nextPageQuery || null
+
+    if (scrollPos < 50 && query && !this._isHandlingScroll) {
+      this.provider.search(this._searchTerm, query)
+        .then((res) => {
+          const { files } = this.plugin.getPluginState()
+          this._updateFilesAndInputMode(res, files)
+        }).catch(this.handleError)
+        .then(() => { this._isHandlingScroll = false }) // always called
+
+      this._isHandlingScroll = true
+    }
+  }
+
+  donePicking () {
+    const { currentSelection } = this.plugin.getPluginState()
+    const promises = currentSelection.map((file) => this.addFile(file))
+
+    this._sharedHandler.loaderWrapper(Promise.all(promises), () => {
+      this.clearSelection()
+    }, () => {})
+  }
+
+  cancelPicking () {
+    this.clearSelection()
+
+    const dashboard = this.plugin.uppy.getPlugin('Dashboard')
+    if (dashboard) dashboard.hideAllPanels()
+  }
+
+  clearSelection () {
+    this.plugin.setPluginState({ currentSelection: [] })
+  }
+
+  render (state, viewOptions = {}) {
+    const { didFirstRender, isInputMode } = this.plugin.getPluginState()
+    if (!didFirstRender) {
+      this.preFirstRender()
+    }
+
+    // reload pluginState for "loading" attribute because it might
+    // have changed above.
+    if (this.plugin.getPluginState().loading) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <LoaderView i18n={this.plugin.uppy.i18n} />
+        </CloseWrapper>
+      )
+    }
+
+    if (isInputMode) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <SearchInput
+            search={this.search}
+            i18n={this.plugin.uppy.i18n}
+          />
+        </CloseWrapper>
+      )
+    }
+
+    const targetViewOptions = { ...this.opts, ...viewOptions }
+    const browserProps = Object.assign({}, this.plugin.getPluginState(), {
+      isChecked: this._sharedHandler.isChecked,
+      toggleCheckbox: this._sharedHandler.toggleCheckbox,
+      handleScroll: this.handleScroll,
+      done: this.donePicking,
+      cancel: this.cancelPicking,
+      headerComponent: Header({
+        triggerSearchInput: this.triggerSearchInput,
+        i18n: this.plugin.uppy.i18n
+      }),
+      title: this.plugin.title,
+      viewType: targetViewOptions.viewType,
+      showTitles: targetViewOptions.showTitles,
+      showFilter: targetViewOptions.showFilter,
+      showBreadcrumbs: targetViewOptions.showBreadcrumbs,
+      pluginIcon: this.plugin.icon,
+      i18n: this.plugin.uppy.i18n
+    })
+
+    return (
+      <CloseWrapper onUnmount={this.clearSelection}>
+        <Browser {...browserProps} />
+      </CloseWrapper>
+    )
+  }
+}

+ 1 - 0
packages/@uppy/provider-views/src/SearchProviderView/index.js

@@ -0,0 +1 @@
+module.exports = require('./SearchProviderView')

+ 80 - 0
packages/@uppy/provider-views/src/SharedHandler.js

@@ -0,0 +1,80 @@
+module.exports = class SharedHandler {
+  constructor (plugin) {
+    this.plugin = plugin
+    this.filterItems = this.filterItems.bind(this)
+    this.toggleCheckbox = this.toggleCheckbox.bind(this)
+    this.isChecked = this.isChecked.bind(this)
+    this.loaderWrapper = this.loaderWrapper.bind(this)
+  }
+
+  filterItems (items) {
+    const state = this.plugin.getPluginState()
+    if (!state.filterInput || state.filterInput === '') {
+      return items
+    }
+    return items.filter((folder) => {
+      return folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
+    })
+  }
+
+  /**
+   * Toggles file/folder checkbox to on/off state while updating files list.
+   *
+   * Note that some extra complexity comes from supporting shift+click to
+   * toggle multiple checkboxes at once, which is done by getting all files
+   * in between last checked file and current one.
+   */
+  toggleCheckbox (e, file) {
+    e.stopPropagation()
+    e.preventDefault()
+    e.currentTarget.focus()
+    const { folders, files } = this.plugin.getPluginState()
+    const items = this.filterItems(folders.concat(files))
+
+    // Shift-clicking selects a single consecutive list of items
+    // starting at the previous click and deselects everything else.
+    if (this.lastCheckbox && e.shiftKey) {
+      let currentSelection
+      const prevIndex = items.indexOf(this.lastCheckbox)
+      const currentIndex = items.indexOf(file)
+      if (prevIndex < currentIndex) {
+        currentSelection = items.slice(prevIndex, currentIndex + 1)
+      } else {
+        currentSelection = items.slice(currentIndex, prevIndex + 1)
+      }
+      this.plugin.setPluginState({ currentSelection })
+      return
+    }
+
+    this.lastCheckbox = file
+    const { currentSelection } = this.plugin.getPluginState()
+    if (this.isChecked(file)) {
+      this.plugin.setPluginState({
+        currentSelection: currentSelection.filter((item) => item.id !== file.id)
+      })
+    } else {
+      this.plugin.setPluginState({
+        currentSelection: currentSelection.concat([file])
+      })
+    }
+  }
+
+  isChecked (file) {
+    const { currentSelection } = this.plugin.getPluginState()
+    // comparing id instead of the file object, because the reference to the object
+    // changes when we switch folders, and the file list is updated
+    return currentSelection.some((item) => item.id === file.id)
+  }
+
+  loaderWrapper (promise, then, catch_) {
+    promise
+      .then((result) => {
+        this.plugin.setPluginState({ loading: false })
+        then(result)
+      }).catch((err) => {
+        this.plugin.setPluginState({ loading: false })
+        catch_(err)
+      })
+    this.plugin.setPluginState({ loading: true })
+  }
+}

+ 5 - 647
packages/@uppy/provider-views/src/index.js

@@ -1,649 +1,7 @@
-const { h, Component } = require('preact')
-const AuthView = require('./AuthView')
-const Browser = require('./Browser')
-const LoaderView = require('./Loader')
-const generateFileID = require('@uppy/utils/lib/generateFileID')
-const getFileType = require('@uppy/utils/lib/getFileType')
-const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
+const ProviderViews = require('./ProviderView')
+const SearchProviderViews = require('./SearchProviderView')
 
-/**
- * Array.prototype.findIndex ponyfill for old browsers.
- */
-function findIndex (array, predicate) {
-  for (let i = 0; i < array.length; i++) {
-    if (predicate(array[i])) return i
-  }
-  return -1
-}
-
-// location.origin does not exist in IE
-function getOrigin () {
-  if ('origin' in location) {
-    return location.origin // eslint-disable-line compat/compat
-  }
-  return `${location.protocol}//${location.hostname}${location.port ? `:${location.port}` : ''}`
-}
-
-class CloseWrapper extends Component {
-  componentWillUnmount () {
-    this.props.onUnmount()
-  }
-
-  render () {
-    return this.props.children[0]
-  }
-}
-
-/**
- * Class to easily generate generic views for Provider plugins
- */
-module.exports = class ProviderView {
-  static VERSION = require('../package.json').version
-
-  /**
-   * @param {object} plugin instance of the plugin
-   * @param {object} opts
-   */
-  constructor (plugin, opts) {
-    this.plugin = plugin
-    this.provider = opts.provider
-
-    // set default options
-    const defaultOptions = {
-      viewType: 'list',
-      showTitles: true,
-      showFilter: true,
-      showBreadcrumbs: true
-    }
-
-    // merge default options with the ones set by user
-    this.opts = { ...defaultOptions, ...opts }
-
-    // Logic
-    this.addFile = this.addFile.bind(this)
-    this.filterItems = this.filterItems.bind(this)
-    this.filterQuery = this.filterQuery.bind(this)
-    this.toggleSearch = this.toggleSearch.bind(this)
-    this.getFolder = this.getFolder.bind(this)
-    this.getNextFolder = this.getNextFolder.bind(this)
-    this.logout = this.logout.bind(this)
-    this.preFirstRender = this.preFirstRender.bind(this)
-    this.handleAuth = this.handleAuth.bind(this)
-    this.sortByTitle = this.sortByTitle.bind(this)
-    this.sortByDate = this.sortByDate.bind(this)
-    this.isActiveRow = this.isActiveRow.bind(this)
-    this.isChecked = this.isChecked.bind(this)
-    this.toggleCheckbox = this.toggleCheckbox.bind(this)
-    this.handleError = this.handleError.bind(this)
-    this.handleScroll = this.handleScroll.bind(this)
-    this.listAllFiles = this.listAllFiles.bind(this)
-    this.donePicking = this.donePicking.bind(this)
-    this.cancelPicking = this.cancelPicking.bind(this)
-    this.clearSelection = this.clearSelection.bind(this)
-
-    // Visual
-    this.render = this.render.bind(this)
-
-    this.clearSelection()
-
-    // Set default state for the plugin
-    this.plugin.setPluginState({
-      authenticated: false,
-      files: [],
-      folders: [],
-      directories: [],
-      activeRow: -1,
-      filterInput: '',
-      isSearchVisible: false
-    })
-  }
-
-  tearDown () {
-    // Nothing.
-  }
-
-  _updateFilesAndFolders (res, files, folders) {
-    this.nextPagePath = res.nextPagePath
-    res.items.forEach((item) => {
-      if (item.isFolder) {
-        folders.push(item)
-      } else {
-        files.push(item)
-      }
-    })
-
-    this.plugin.setPluginState({ folders, files })
-  }
-
-  /**
-   * Called only the first time the provider view is rendered.
-   * Kind of like an init function.
-   */
-  preFirstRender () {
-    this.plugin.setPluginState({ didFirstRender: true })
-    this.plugin.onFirstRender()
-  }
-
-  /**
-   * Based on folder ID, fetch a new folder and update it to state
-   *
-   * @param  {string} id Folder id
-   * @returns {Promise}   Folders/files in folder
-   */
-  getFolder (id, name) {
-    return this._loaderWrapper(
-      this.provider.list(id),
-      (res) => {
-        const folders = []
-        const files = []
-        let updatedDirectories
-
-        const state = this.plugin.getPluginState()
-        const index = findIndex(state.directories, (dir) => id === dir.id)
-
-        if (index !== -1) {
-          updatedDirectories = state.directories.slice(0, index + 1)
-        } else {
-          updatedDirectories = state.directories.concat([{ id, title: name }])
-        }
-
-        this.username = res.username || this.username
-        this._updateFilesAndFolders(res, files, folders)
-        this.plugin.setPluginState({ directories: updatedDirectories })
-      },
-      this.handleError)
-  }
-
-  /**
-   * Fetches new folder
-   *
-   * @param  {object} folder
-   * @param  {string} title Folder title
-   */
-  getNextFolder (folder) {
-    this.getFolder(folder.requestPath, folder.name)
-    this.lastCheckbox = undefined
-  }
-
-  addFile (file) {
-    const tagFile = {
-      id: this.providerFileToId(file),
-      source: this.plugin.id,
-      data: file,
-      name: file.name || file.id,
-      type: file.mimeType,
-      isRemote: true,
-      body: {
-        fileId: file.id
-      },
-      remote: {
-        companionUrl: this.plugin.opts.companionUrl,
-        url: `${this.provider.fileUrl(file.requestPath)}`,
-        body: {
-          fileId: file.id
-        },
-        providerOptions: this.provider.opts
-      }
-    }
-
-    const fileType = getFileType(tagFile)
-    // TODO Should we just always use the thumbnail URL if it exists?
-    if (fileType && isPreviewSupported(fileType)) {
-      tagFile.preview = file.thumbnail
-    }
-    this.plugin.uppy.log('Adding remote file')
-    try {
-      this.plugin.uppy.addFile(tagFile)
-      return true
-    } catch (err) {
-      if (!err.isRestriction) {
-        this.plugin.uppy.log(err)
-      }
-      return false
-    }
-  }
-
-  removeFile (id) {
-    const { currentSelection } = this.plugin.getPluginState()
-    this.plugin.setPluginState({
-      currentSelection: currentSelection.filter((file) => file.id !== id)
-    })
-  }
-
-  /**
-   * Removes session token on client side.
-   */
-  logout () {
-    this.provider.logout()
-      .then((res) => {
-        if (res.ok) {
-          if (!res.revoked) {
-            const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
-              provider: this.plugin.title,
-              url: res.manual_revoke_url
-            })
-            this.plugin.uppy.info(message, 'info', 7000)
-          }
-
-          const newState = {
-            authenticated: false,
-            files: [],
-            folders: [],
-            directories: []
-          }
-          this.plugin.setPluginState(newState)
-        }
-      }).catch(this.handleError)
-  }
-
-  filterQuery (e) {
-    const state = this.plugin.getPluginState()
-    this.plugin.setPluginState(Object.assign({}, state, {
-      filterInput: e ? e.target.value : ''
-    }))
-  }
-
-  toggleSearch (inputEl) {
-    const state = this.plugin.getPluginState()
-
-    this.plugin.setPluginState({
-      isSearchVisible: !state.isSearchVisible,
-      filterInput: ''
-    })
-  }
-
-  filterItems (items) {
-    const state = this.plugin.getPluginState()
-    if (!state.filterInput || state.filterInput === '') {
-      return items
-    }
-    return items.filter((folder) => {
-      return folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
-    })
-  }
-
-  sortByTitle () {
-    const state = Object.assign({}, this.plugin.getPluginState())
-    const { files, folders, sorting } = state
-
-    const sortedFiles = files.sort((fileA, fileB) => {
-      if (sorting === 'titleDescending') {
-        return fileB.name.localeCompare(fileA.name)
-      }
-      return fileA.name.localeCompare(fileB.name)
-    })
-
-    const sortedFolders = folders.sort((folderA, folderB) => {
-      if (sorting === 'titleDescending') {
-        return folderB.name.localeCompare(folderA.name)
-      }
-      return folderA.name.localeCompare(folderB.name)
-    })
-
-    this.plugin.setPluginState(Object.assign({}, state, {
-      files: sortedFiles,
-      folders: sortedFolders,
-      sorting: (sorting === 'titleDescending') ? 'titleAscending' : 'titleDescending'
-    }))
-  }
-
-  sortByDate () {
-    const state = Object.assign({}, this.plugin.getPluginState())
-    const { files, folders, sorting } = state
-
-    const sortedFiles = files.sort((fileA, fileB) => {
-      const a = new Date(fileA.modifiedDate)
-      const b = new Date(fileB.modifiedDate)
-
-      if (sorting === 'dateDescending') {
-        return a > b ? -1 : a < b ? 1 : 0
-      }
-      return a > b ? 1 : a < b ? -1 : 0
-    })
-
-    const sortedFolders = folders.sort((folderA, folderB) => {
-      const a = new Date(folderA.modifiedDate)
-      const b = new Date(folderB.modifiedDate)
-
-      if (sorting === 'dateDescending') {
-        return a > b ? -1 : a < b ? 1 : 0
-      }
-
-      return a > b ? 1 : a < b ? -1 : 0
-    })
-
-    this.plugin.setPluginState(Object.assign({}, state, {
-      files: sortedFiles,
-      folders: sortedFolders,
-      sorting: (sorting === 'dateDescending') ? 'dateAscending' : 'dateDescending'
-    }))
-  }
-
-  sortBySize () {
-    const state = Object.assign({}, this.plugin.getPluginState())
-    const { files, sorting } = state
-
-    // check that plugin supports file sizes
-    if (!files.length || !this.plugin.getItemData(files[0]).size) {
-      return
-    }
-
-    const sortedFiles = files.sort((fileA, fileB) => {
-      const a = fileA.size
-      const b = fileB.size
-
-      if (sorting === 'sizeDescending') {
-        return a > b ? -1 : a < b ? 1 : 0
-      }
-      return a > b ? 1 : a < b ? -1 : 0
-    })
-
-    this.plugin.setPluginState(Object.assign({}, state, {
-      files: sortedFiles,
-      sorting: (sorting === 'sizeDescending') ? 'sizeAscending' : 'sizeDescending'
-    }))
-  }
-
-  isActiveRow (file) {
-    return this.plugin.getPluginState().activeRow === this.plugin.getItemId(file)
-  }
-
-  isChecked (file) {
-    const { currentSelection } = this.plugin.getPluginState()
-    // comparing id instead of the file object, because the reference to the object
-    // changes when we switch folders, and the file list is updated
-    return currentSelection.some((item) => item.id === file.id)
-  }
-
-  /**
-   * Adds all files found inside of specified folder.
-   *
-   * Uses separated state while folder contents are being fetched and
-   * mantains list of selected folders, which are separated from files.
-   */
-  addFolder (folder) {
-    const folderId = this.providerFileToId(folder)
-    const state = this.plugin.getPluginState()
-    const folders = { ...state.selectedFolders }
-    if (folderId in folders && folders[folderId].loading) {
-      return
-    }
-    folders[folderId] = { loading: true, files: [] }
-    this.plugin.setPluginState({ selectedFolders: { ...folders } })
-    return this.listAllFiles(folder.requestPath).then((files) => {
-      let count = 0
-      files.forEach((file) => {
-        const success = this.addFile(file)
-        if (success) count++
-      })
-      const ids = files.map(this.providerFileToId)
-      folders[folderId] = {
-        loading: false,
-        files: ids
-      }
-      this.plugin.setPluginState({ selectedFolders: folders })
-
-      let message
-      if (files.length) {
-        message = this.plugin.uppy.i18n('folderAdded', {
-          smart_count: count, folder: folder.name
-        })
-      } else {
-        message = this.plugin.uppy.i18n('emptyFolderAdded')
-      }
-      this.plugin.uppy.info(message)
-    }).catch((e) => {
-      const state = this.plugin.getPluginState()
-      const selectedFolders = { ...state.selectedFolders }
-      delete selectedFolders[folderId]
-      this.plugin.setPluginState({ selectedFolders })
-      this.handleError(e)
-    })
-  }
-
-  /**
-   * Toggles file/folder checkbox to on/off state while updating files list.
-   *
-   * Note that some extra complexity comes from supporting shift+click to
-   * toggle multiple checkboxes at once, which is done by getting all files
-   * in between last checked file and current one.
-   */
-  toggleCheckbox (e, file) {
-    e.stopPropagation()
-    e.preventDefault()
-    e.currentTarget.focus()
-    const { folders, files } = this.plugin.getPluginState()
-    const items = this.filterItems(folders.concat(files))
-
-    // Shift-clicking selects a single consecutive list of items
-    // starting at the previous click and deselects everything else.
-    if (this.lastCheckbox && e.shiftKey) {
-      let currentSelection
-      const prevIndex = items.indexOf(this.lastCheckbox)
-      const currentIndex = items.indexOf(file)
-      if (prevIndex < currentIndex) {
-        currentSelection = items.slice(prevIndex, currentIndex + 1)
-      } else {
-        currentSelection = items.slice(currentIndex, prevIndex + 1)
-      }
-      this.plugin.setPluginState({ currentSelection })
-      return
-    }
-
-    this.lastCheckbox = file
-    const { currentSelection } = this.plugin.getPluginState()
-    if (this.isChecked(file)) {
-      this.plugin.setPluginState({
-        currentSelection: currentSelection.filter((item) => item.id !== file.id)
-      })
-    } else {
-      this.plugin.setPluginState({
-        currentSelection: currentSelection.concat([file])
-      })
-    }
-  }
-
-  providerFileToId (file) {
-    return generateFileID({
-      data: file,
-      name: file.name || file.id,
-      type: file.mimeType
-    })
-  }
-
-  handleAuth () {
-    const authState = btoa(JSON.stringify({ origin: getOrigin() }))
-    const clientVersion = encodeURIComponent(`@uppy/provider-views=${ProviderView.VERSION}`)
-    const link = `${this.provider.authUrl()}?state=${authState}&uppyVersions=${clientVersion}`
-
-    const authWindow = window.open(link, '_blank')
-    const handleToken = (e) => {
-      if (!this._isOriginAllowed(e.origin, this.plugin.opts.companionAllowedHosts) || e.source !== authWindow) {
-        this.plugin.uppy.log(`rejecting event from ${e.origin} vs allowed pattern ${this.plugin.opts.companionAllowedHosts}`)
-        return
-      }
-
-      // Check if it's a string before doing the JSON.parse to maintain support
-      // for older Companion versions that used object references
-      const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
-
-      if (!data.token) {
-        this.plugin.uppy.log('did not receive token from auth window')
-        return
-      }
-
-      authWindow.close()
-      window.removeEventListener('message', handleToken)
-      this.provider.setAuthToken(data.token)
-      this.preFirstRender()
-    }
-    window.addEventListener('message', handleToken)
-  }
-
-  _isOriginAllowed (origin, allowedOrigin) {
-    const getRegex = (value) => {
-      if (typeof value === 'string') {
-        return new RegExp(`^${value}$`)
-      } else if (value instanceof RegExp) {
-        return value
-      }
-    }
-
-    const patterns = Array.isArray(allowedOrigin) ? allowedOrigin.map(getRegex) : [getRegex(allowedOrigin)]
-    return patterns
-      .filter((pattern) => pattern != null) // loose comparison to catch undefined
-      .some((pattern) => pattern.test(origin) || pattern.test(`${origin}/`)) // allowing for trailing '/'
-  }
-
-  handleError (error) {
-    const uppy = this.plugin.uppy
-    uppy.log(error.toString())
-    if (error.isAuthError) {
-      return
-    }
-    const message = uppy.i18n('companionError')
-    uppy.info({ message: message, details: error.toString() }, 'error', 5000)
-  }
-
-  handleScroll (e) {
-    const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
-    const path = this.nextPagePath || null
-
-    if (scrollPos < 50 && path && !this._isHandlingScroll) {
-      this.provider.list(path)
-        .then((res) => {
-          const { files, folders } = this.plugin.getPluginState()
-          this._updateFilesAndFolders(res, files, folders)
-        }).catch(this.handleError)
-        .then(() => { this._isHandlingScroll = false }) // always called
-
-      this._isHandlingScroll = true
-    }
-  }
-
-  listAllFiles (path, files = null) {
-    files = files || []
-    return new Promise((resolve, reject) => {
-      this.provider.list(path).then((res) => {
-        res.items.forEach((item) => {
-          if (!item.isFolder) {
-            files.push(item)
-          } else {
-            this.addFolder(item)
-          }
-        })
-        const moreFiles = res.nextPagePath || null
-        if (moreFiles) {
-          return this.listAllFiles(moreFiles, files)
-            .then((files) => resolve(files))
-            .catch(e => reject(e))
-        } else {
-          return resolve(files)
-        }
-      }).catch(e => reject(e))
-    })
-  }
-
-  donePicking () {
-    const { currentSelection } = this.plugin.getPluginState()
-    const promises = currentSelection.map((file) => {
-      if (file.isFolder) {
-        return this.addFolder(file)
-      } else {
-        return this.addFile(file)
-      }
-    })
-
-    this._loaderWrapper(Promise.all(promises), () => {
-      this.clearSelection()
-    }, () => {})
-  }
-
-  cancelPicking () {
-    this.clearSelection()
-
-    const dashboard = this.plugin.uppy.getPlugin('Dashboard')
-    if (dashboard) dashboard.hideAllPanels()
-  }
-
-  clearSelection () {
-    this.plugin.setPluginState({ currentSelection: [] })
-  }
-
-  // displays loader view while asynchronous request is being made.
-  _loaderWrapper (promise, then, catch_) {
-    promise
-      .then((result) => {
-        this.plugin.setPluginState({ loading: false })
-        then(result)
-      }).catch((err) => {
-        this.plugin.setPluginState({ loading: false })
-        catch_(err)
-      })
-    this.plugin.setPluginState({ loading: true })
-  }
-
-  render (state, viewOptions = {}) {
-    const { authenticated, didFirstRender } = this.plugin.getPluginState()
-    if (!didFirstRender) {
-      this.preFirstRender()
-    }
-
-    // reload pluginState for "loading" attribute because it might
-    // have changed above.
-    if (this.plugin.getPluginState().loading) {
-      return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <LoaderView i18n={this.plugin.uppy.i18n} />
-        </CloseWrapper>
-      )
-    }
-
-    if (!authenticated) {
-      return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <AuthView
-            pluginName={this.plugin.title}
-            pluginIcon={this.plugin.icon}
-            handleAuth={this.handleAuth}
-            i18n={this.plugin.uppy.i18n}
-            i18nArray={this.plugin.uppy.i18nArray}
-          />
-        </CloseWrapper>
-      )
-    }
-
-    const targetViewOptions = { ...this.opts, ...viewOptions }
-    const browserProps = Object.assign({}, this.plugin.getPluginState(), {
-      username: this.username,
-      getNextFolder: this.getNextFolder,
-      getFolder: this.getFolder,
-      filterItems: this.filterItems,
-      filterQuery: this.filterQuery,
-      toggleSearch: this.toggleSearch,
-      sortByTitle: this.sortByTitle,
-      sortByDate: this.sortByDate,
-      logout: this.logout,
-      isActiveRow: this.isActiveRow,
-      isChecked: this.isChecked,
-      toggleCheckbox: this.toggleCheckbox,
-      handleScroll: this.handleScroll,
-      listAllFiles: this.listAllFiles,
-      done: this.donePicking,
-      cancel: this.cancelPicking,
-      title: this.plugin.title,
-      viewType: targetViewOptions.viewType,
-      showTitles: targetViewOptions.showTitles,
-      showFilter: targetViewOptions.showFilter,
-      showBreadcrumbs: targetViewOptions.showBreadcrumbs,
-      pluginIcon: this.plugin.icon,
-      i18n: this.plugin.uppy.i18n
-    })
-
-    return (
-      <CloseWrapper onUnmount={this.clearSelection}>
-        <Browser {...browserProps} />
-      </CloseWrapper>
-    )
-  }
+module.exports = {
+  ProviderViews,
+  SearchProviderViews
 }

+ 1 - 0
packages/@uppy/provider-views/src/style.scss

@@ -4,6 +4,7 @@
 @import './style/uppy-ProviderBrowser-viewType--grid';
 @import './style/uppy-ProviderBrowser-viewType--list';
 @import './style/uppy-ProviderBrowserItem-fakeCheckbox';
+@import './style/uppy-SearchProvider-input.scss';
 
 .uppy-DashboardContent-panelBody {
   display: flex;

+ 32 - 0
packages/@uppy/provider-views/src/style/uppy-SearchProvider-input.scss

@@ -0,0 +1,32 @@
+
+.uppy-SearchProvider {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  flex: 1;
+
+  [data-uppy-theme="dark"] & {
+    background-color: $gray-900;
+  }
+}
+
+.uppy-SearchProvider-input {
+  width: 90%;
+  max-width: 650px;
+  margin-bottom: 15px;
+
+  .uppy-size--md & {
+    margin-bottom: 20px;
+  }
+}
+
+.uppy-SearchProvider-searchButton {
+  padding: 13px 25px;
+
+  .uppy-size--md & {
+    padding: 13px 30px;
+  }
+}

+ 21 - 0
packages/@uppy/unsplash/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 42 - 0
packages/@uppy/unsplash/README.md

@@ -0,0 +1,42 @@
+# @uppy/unsplash
+
+<img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" width="120" alt="Uppy logo: a superman puppy in a pink suit" align="right">
+
+<a href="https://www.npmjs.com/package/@uppy/unsplash"><img src="https://img.shields.io/npm/v/@uppy/unsplash.svg?style=flat-square"></a>
+<a href="https://travis-ci.org/transloadit/uppy"><img src="https://img.shields.io/travis/transloadit/uppy/master.svg?style=flat-square" alt="Build Status"></a>
+
+The Unsplash plugin lets users import files from the Internet. Paste any URL and it’ll be added!
+
+A Companion instance is required for the Unsplash plugin to work. Companion will download the files and upload them to their destination. This saves bandwidth for the user (especially on mobile connections) and helps avoid CORS restrictions.
+
+Uppy is being developed by the folks at [Transloadit](https://transloadit.com), a versatile file encoding service.
+
+## Example
+
+```js
+const Uppy = require('@uppy/core')
+const Unsplash = require('@uppy/unsplash')
+
+const uppy = Uppy()
+uppy.use(Unsplash, {
+  // Options
+})
+```
+
+## Installation
+
+```bash
+$ npm install @uppy/unsplash --save
+```
+
+We recommend installing from npm and then using a module bundler such as [Webpack](https://webpack.js.org/), [Browserify](http://browserify.org/) or [Rollup.js](http://rollupjs.org/).
+
+Alternatively, you can also use this plugin in a pre-built bundle from Transloadit's CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object. See the [main Uppy documentation](https://uppy.io/docs/#Installation) for instructions.
+
+## Documentation
+
+Documentation for this plugin can be found on the [Uppy website](https://uppy.io/docs/unsplash).
+
+## License
+
+[The MIT License](./LICENSE).

+ 31 - 0
packages/@uppy/unsplash/package.json

@@ -0,0 +1,31 @@
+{
+  "name": "@uppy/unsplash",
+  "description": "Import files from Unsplash, into Uppy.",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "types": "types/index.d.ts",
+  "keywords": [
+    "file uploader",
+    "uppy",
+    "uppy-plugin",
+    "unsplash"
+  ],
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "@uppy/companion-client": "file:../companion-client",
+    "@uppy/provider-views": "file:../provider-views",
+    "@uppy/utils": "file:../utils",
+    "preact": "8.2.9"
+  },
+  "peerDependencies": {
+    "@uppy/core": "^1.0.0"
+  }
+}

+ 64 - 0
packages/@uppy/unsplash/src/index.js

@@ -0,0 +1,64 @@
+const { Plugin } = require('@uppy/core')
+const { h } = require('preact')
+const { SearchProvider } = require('@uppy/companion-client')
+const { SearchProviderViews } = require('@uppy/provider-views')
+
+/**
+ * Unsplash
+ *
+ */
+module.exports = class Unsplash extends Plugin {
+  static VERSION = require('../package.json').version
+
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.id = this.opts.id || 'Unsplash'
+    this.title = this.opts.title || 'Unsplash'
+    this.type = 'acquirer'
+    this.icon = () => (
+      <svg viewBox="0 0 32 32" height="32" width="32" aria-hidden="true">
+        <path d="M46.575 10.883v-9h12v9zm12 5h10v18h-32v-18h10v9h12z" fill="#fff" />
+        <rect width="32" height="32" rx="16" />
+        <path d="M13 12.5V8h6v4.5zm6 2.5h5v9H8v-9h5v4.5h6z" fill="#fff" />
+      </svg>
+    )
+
+    const defaultOptions = {}
+    this.opts = { ...defaultOptions, ...opts }
+    this.hostname = this.opts.companionUrl
+
+    if (!this.hostname) {
+      throw new Error('Companion hostname is required, please consult https://uppy.io/docs/companion')
+    }
+
+    this.provider = new SearchProvider(uppy, {
+      companionUrl: this.opts.companionUrl,
+      companionHeaders: this.opts.companionHeaders,
+      provider: 'unsplash',
+      pluginId: this.id
+    })
+  }
+
+  install () {
+    this.view = new SearchProviderViews(this, {
+      provider: this.provider
+    })
+
+    const target = this.opts.target
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  onFirstRender () {
+    // do nothing
+  }
+
+  render (state) {
+    return this.view.render(state)
+  }
+
+  uninstall () {
+    this.unmount()
+  }
+}

+ 16 - 0
packages/@uppy/unsplash/types/index.d.ts

@@ -0,0 +1,16 @@
+import Uppy = require('@uppy/core')
+import CompanionClient = require('@uppy/companion-client')
+
+declare module Unsplash {
+  interface UnsplashOptions
+    extends Uppy.PluginOptions,
+      CompanionClient.RequestClientOptions {
+    replaceTargetContent?: boolean
+    target?: Uppy.PluginTarget
+    title?: string
+  }
+}
+
+declare class Unsplash extends Uppy.Plugin<Unsplash.UnsplashOptions> {}
+
+export = Unsplash

+ 2 - 0
packages/@uppy/unsplash/types/index.test-d.ts

@@ -0,0 +1,2 @@
+import Unsplash = require('../')
+// TODO implement

+ 0 - 0
packages/@uppy/dashboard/src/utils/truncateString.js → packages/@uppy/utils/src/truncateString.js


+ 0 - 0
packages/@uppy/dashboard/src/utils/truncateString.test.js → packages/@uppy/utils/src/truncateString.test.js


+ 1 - 1
packages/@uppy/zoom/src/index.js

@@ -1,6 +1,6 @@
 const { Plugin } = require('@uppy/core')
 const { Provider } = require('@uppy/companion-client')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
 module.exports = class Zoom extends Plugin {

+ 1 - 0
packages/uppy/index.js

@@ -26,6 +26,7 @@ exports.GoogleDrive = require('@uppy/google-drive')
 exports.Instagram = require('@uppy/instagram')
 exports.OneDrive = require('@uppy/onedrive')
 exports.Facebook = require('@uppy/facebook')
+exports.Unsplash = require('@uppy/unsplash')
 exports.Url = require('@uppy/url')
 exports.Webcam = require('@uppy/webcam')
 exports.ScreenCapture = require('@uppy/screen-capture')

+ 1 - 0
packages/uppy/index.mjs

@@ -24,6 +24,7 @@ export { default as GoogleDrive } from '@uppy/google-drive'
 export { default as Instagram } from '@uppy/instagram'
 export { default as OneDrive } from '@uppy/onedrive'
 export { default as Facebook } from '@uppy/facebook'
+export { default as Unsplash } from '@uppy/unsplash'
 export { default as Url } from '@uppy/url'
 export { default as Webcam } from '@uppy/webcam'
 export { default as ScreenCapture } from '@uppy/screen-capture'

+ 1 - 0
packages/uppy/package.json

@@ -55,6 +55,7 @@
     "@uppy/thumbnail-generator": "file:../@uppy/thumbnail-generator",
     "@uppy/transloadit": "file:../@uppy/transloadit",
     "@uppy/tus": "file:../@uppy/tus",
+    "@uppy/unsplash": "file:../@uppy/unsplash",
     "@uppy/url": "file:../@uppy/url",
     "@uppy/webcam": "file:../@uppy/webcam",
     "@uppy/xhr-upload": "file:../@uppy/xhr-upload"

+ 1 - 1
website/src/_posts/2020-03-custom-providers.md

@@ -422,7 +422,7 @@ First, we'll create a `client/MyCustomProvider.js` file. Following the instructi
 ```js
 const { Plugin } = require('@uppy/core')
 const { Provider } = require('@uppy/companion-client')
-const ProviderViews = require('@uppy/provider-views')
+const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
 module.exports = class MyCustomProvider extends Plugin {