ProviderView.jsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import { h } from 'preact'
  2. // eslint-disable-next-line import/no-unresolved
  3. import PQueue from 'p-queue'
  4. import { getSafeFileId } from '@uppy/utils/lib/generateFileID'
  5. import AuthView from './AuthView.jsx'
  6. import Header from './Header.jsx'
  7. import Browser from '../Browser.jsx'
  8. import LoaderView from '../Loader.jsx'
  9. import CloseWrapper from '../CloseWrapper.js'
  10. import View from '../View.js'
  11. import packageJson from '../../package.json'
  12. function formatBreadcrumbs (breadcrumbs) {
  13. return breadcrumbs.slice(1).map((directory) => directory.name).join('/')
  14. }
  15. function prependPath (path, component) {
  16. if (!path) return component
  17. return `${path}/${component}`
  18. }
  19. export function defaultPickerIcon () {
  20. return (
  21. <svg aria-hidden="true" focusable="false" width="30" height="30" viewBox="0 0 30 30">
  22. <path d="M15 30c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15C6.716 0 0 6.716 0 15c0 8.284 6.716 15 15 15zm4.258-12.676v6.846h-8.426v-6.846H5.204l9.82-12.364 9.82 12.364H19.26z" />
  23. </svg>
  24. )
  25. }
  26. /**
  27. * Class to easily generate generic views for Provider plugins
  28. */
  29. export default class ProviderView extends View {
  30. static VERSION = packageJson.version
  31. /**
  32. * @param {object} plugin instance of the plugin
  33. * @param {object} opts
  34. */
  35. constructor (plugin, opts) {
  36. super(plugin, opts)
  37. // set default options
  38. const defaultOptions = {
  39. viewType: 'list',
  40. showTitles: true,
  41. showFilter: true,
  42. showBreadcrumbs: true,
  43. loadAllFiles: false,
  44. }
  45. // merge default options with the ones set by user
  46. this.opts = { ...defaultOptions, ...opts }
  47. // Logic
  48. this.filterQuery = this.filterQuery.bind(this)
  49. this.clearFilter = this.clearFilter.bind(this)
  50. this.getFolder = this.getFolder.bind(this)
  51. this.getNextFolder = this.getNextFolder.bind(this)
  52. this.logout = this.logout.bind(this)
  53. this.handleAuth = this.handleAuth.bind(this)
  54. this.handleScroll = this.handleScroll.bind(this)
  55. this.donePicking = this.donePicking.bind(this)
  56. // Visual
  57. this.render = this.render.bind(this)
  58. // Set default state for the plugin
  59. this.plugin.setPluginState({
  60. authenticated: false,
  61. files: [],
  62. folders: [],
  63. breadcrumbs: [],
  64. filterInput: '',
  65. isSearchVisible: false,
  66. currentSelection: [],
  67. })
  68. }
  69. // eslint-disable-next-line class-methods-use-this
  70. tearDown () {
  71. // Nothing.
  72. }
  73. #abortController
  74. async #withAbort (op) {
  75. // prevent multiple requests in parallel from causing race conditions
  76. this.#abortController?.abort()
  77. const abortController = new AbortController()
  78. this.#abortController = abortController
  79. const cancelRequest = () => {
  80. abortController.abort()
  81. this.clearSelection()
  82. }
  83. try {
  84. this.plugin.uppy.on('dashboard:close-panel', cancelRequest)
  85. this.plugin.uppy.on('cancel-all', cancelRequest)
  86. await op(abortController.signal)
  87. } finally {
  88. this.plugin.uppy.off('dashboard:close-panel', cancelRequest)
  89. this.plugin.uppy.off('cancel-all', cancelRequest)
  90. this.#abortController = undefined
  91. }
  92. }
  93. async #list ({ requestPath, absDirPath, signal }) {
  94. const { username, nextPagePath, items } = await this.provider.list(requestPath, { signal })
  95. this.username = username || this.username
  96. return {
  97. items: items.map((item) => ({
  98. ...item,
  99. absDirPath,
  100. })),
  101. nextPagePath,
  102. }
  103. }
  104. async #listFilesAndFolders ({ breadcrumbs, signal }) {
  105. const absDirPath = formatBreadcrumbs(breadcrumbs)
  106. const { items, nextPagePath } = await this.#list({ requestPath: this.nextPagePath, absDirPath, signal })
  107. this.nextPagePath = nextPagePath
  108. const files = []
  109. const folders = []
  110. items.forEach((item) => {
  111. if (item.isFolder) {
  112. folders.push(item)
  113. } else {
  114. files.push(item)
  115. }
  116. })
  117. return { files, folders }
  118. }
  119. /**
  120. * Select a folder based on its id: fetches the folder and then updates state with its contents
  121. * TODO rename to something better like selectFolder or navigateToFolder (breaking change?)
  122. *
  123. * @param {string} requestPath
  124. * the path we need to use when sending list request to companion (for some providers it's different from ID)
  125. * @param {string} name used in the UI and to build the absDirPath
  126. * @returns {Promise} Folders/files in folder
  127. */
  128. async getFolder (requestPath, name) {
  129. this.setLoading(true)
  130. try {
  131. await this.#withAbort(async (signal) => {
  132. this.lastCheckbox = undefined
  133. let { breadcrumbs } = this.plugin.getPluginState()
  134. const index = breadcrumbs.findIndex((dir) => requestPath === dir.requestPath)
  135. if (index !== -1) {
  136. // means we navigated back to a known directory (already in the stack), so cut the stack off there
  137. breadcrumbs = breadcrumbs.slice(0, index + 1)
  138. } else {
  139. // we have navigated into a new (unknown) folder, add it to the stack
  140. breadcrumbs = [...breadcrumbs, { requestPath, name }]
  141. }
  142. this.nextPagePath = requestPath
  143. let files = []
  144. let folders = []
  145. do {
  146. const { files: newFiles, folders: newFolders } = await this.#listFilesAndFolders({
  147. breadcrumbs, signal,
  148. })
  149. files = files.concat(newFiles)
  150. folders = folders.concat(newFolders)
  151. this.setLoading(this.plugin.uppy.i18n('loadedXFiles', { numFiles: files.length + folders.length }))
  152. } while (
  153. this.opts.loadAllFiles && this.nextPagePath
  154. )
  155. this.plugin.setPluginState({ folders, files, breadcrumbs, filterInput: '' })
  156. })
  157. } catch (err) {
  158. this.handleError(err)
  159. } finally {
  160. this.setLoading(false)
  161. }
  162. }
  163. /**
  164. * Fetches new folder
  165. *
  166. * @param {object} folder
  167. */
  168. getNextFolder (folder) {
  169. this.getFolder(folder.requestPath, folder.name)
  170. this.lastCheckbox = undefined
  171. }
  172. /**
  173. * Removes session token on client side.
  174. */
  175. async logout () {
  176. try {
  177. await this.#withAbort(async (signal) => {
  178. const res = await this.provider.logout({ signal })
  179. if (res.ok) {
  180. if (!res.revoked) {
  181. const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
  182. provider: this.plugin.title,
  183. url: res.manual_revoke_url,
  184. })
  185. this.plugin.uppy.info(message, 'info', 7000)
  186. }
  187. const newState = {
  188. authenticated: false,
  189. files: [],
  190. folders: [],
  191. breadcrumbs: [],
  192. filterInput: '',
  193. }
  194. this.plugin.setPluginState(newState)
  195. }
  196. })
  197. } catch (err) {
  198. this.handleError(err)
  199. }
  200. }
  201. filterQuery (input) {
  202. this.plugin.setPluginState({ filterInput: input })
  203. }
  204. clearFilter () {
  205. this.plugin.setPluginState({ filterInput: '' })
  206. }
  207. async handleAuth () {
  208. const clientVersion = `@uppy/provider-views=${ProviderView.VERSION}`
  209. try {
  210. await this.provider.login({ uppyVersions: clientVersion })
  211. this.plugin.setPluginState({ authenticated: true })
  212. this.preFirstRender()
  213. } catch (e) {
  214. this.plugin.uppy.log(`login failed: ${e.message}`)
  215. }
  216. }
  217. async handleScroll (event) {
  218. if (this.shouldHandleScroll(event) && this.nextPagePath) {
  219. this.isHandlingScroll = true
  220. try {
  221. await this.#withAbort(async (signal) => {
  222. const { files, folders, breadcrumbs } = this.plugin.getPluginState()
  223. const { files: newFiles, folders: newFolders } = await this.#listFilesAndFolders({
  224. breadcrumbs, signal,
  225. })
  226. const combinedFiles = files.concat(newFiles)
  227. const combinedFolders = folders.concat(newFolders)
  228. this.plugin.setPluginState({ folders: combinedFolders, files: combinedFiles })
  229. })
  230. } catch (error) {
  231. this.handleError(error)
  232. } finally {
  233. this.isHandlingScroll = false
  234. }
  235. }
  236. }
  237. async #recursivelyListAllFiles ({ requestPath, absDirPath, relDirPath, queue, onFiles, signal }) {
  238. let curPath = requestPath
  239. while (curPath) {
  240. const res = await this.#list({ requestPath: curPath, absDirPath, signal })
  241. curPath = res.nextPagePath
  242. const files = res.items.filter((item) => !item.isFolder)
  243. const folders = res.items.filter((item) => item.isFolder)
  244. onFiles(files)
  245. // recursively queue call to self for each folder
  246. const promises = folders.map(async (folder) => queue.add(async () => (
  247. this.#recursivelyListAllFiles({
  248. requestPath: folder.requestPath,
  249. absDirPath: prependPath(absDirPath, folder.name),
  250. relDirPath: prependPath(relDirPath, folder.name),
  251. queue,
  252. onFiles,
  253. signal,
  254. })
  255. )))
  256. await Promise.all(promises) // in case we get an error
  257. }
  258. }
  259. async donePicking () {
  260. this.setLoading(true)
  261. try {
  262. await this.#withAbort(async (signal) => {
  263. const { currentSelection } = this.plugin.getPluginState()
  264. const messages = []
  265. const newFiles = []
  266. for (const selectedItem of currentSelection) {
  267. const { requestPath } = selectedItem
  268. const withRelDirPath = (newItem) => ({
  269. ...newItem,
  270. // calculate the file's path relative to the user's selected item's path
  271. // see https://github.com/transloadit/uppy/pull/4537#issuecomment-1614236655
  272. relDirPath: newItem.absDirPath.replace(selectedItem.absDirPath, '').replace(/^\//, ''),
  273. })
  274. if (selectedItem.isFolder) {
  275. let isEmpty = true
  276. let numNewFiles = 0
  277. const queue = new PQueue({ concurrency: 6 })
  278. const onFiles = (files) => {
  279. for (const newFile of files) {
  280. const tagFile = this.getTagFile(newFile)
  281. const id = getSafeFileId(tagFile)
  282. // If the same folder is added again, we don't want to send
  283. // X amount of duplicate file notifications, we want to say
  284. // the folder was already added. This checks if all files are duplicate,
  285. // if that's the case, we don't add the files.
  286. if (!this.plugin.uppy.checkIfFileAlreadyExists(id)) {
  287. newFiles.push(withRelDirPath(newFile))
  288. numNewFiles++
  289. this.setLoading(this.plugin.uppy.i18n('addedNumFiles', { numFiles: numNewFiles }))
  290. }
  291. isEmpty = false
  292. }
  293. }
  294. await this.#recursivelyListAllFiles({
  295. requestPath,
  296. absDirPath: prependPath(selectedItem.absDirPath, selectedItem.name),
  297. relDirPath: selectedItem.name,
  298. queue,
  299. onFiles,
  300. signal,
  301. })
  302. await queue.onIdle()
  303. let message
  304. if (isEmpty) {
  305. message = this.plugin.uppy.i18n('emptyFolderAdded')
  306. } else if (numNewFiles === 0) {
  307. message = this.plugin.uppy.i18n('folderAlreadyAdded', {
  308. folder: selectedItem.name,
  309. })
  310. } else {
  311. // TODO we don't really know at this point whether any files were actually added
  312. // (only later after addFiles has been called) so we should probably rewrite this.
  313. // Example: If all files fail to add due to restriction error, it will still say "Added 100 files from folder"
  314. message = this.plugin.uppy.i18n('folderAdded', {
  315. smart_count: numNewFiles, folder: selectedItem.name,
  316. })
  317. }
  318. messages.push(message)
  319. } else {
  320. newFiles.push(withRelDirPath(selectedItem))
  321. }
  322. }
  323. // Note: this.plugin.uppy.addFiles must be only run once we are done fetching all files,
  324. // because it will cause the loading screen to disappear,
  325. // and that will allow the user to start the upload, so we need to make sure we have
  326. // finished all async operations before we add any file
  327. // see https://github.com/transloadit/uppy/pull/4384
  328. this.plugin.uppy.log('Adding files from a remote provider')
  329. this.plugin.uppy.addFiles(newFiles.map((file) => this.getTagFile(file)))
  330. this.plugin.setPluginState({ filterInput: '' })
  331. messages.forEach(message => this.plugin.uppy.info(message))
  332. this.clearSelection()
  333. })
  334. } catch (err) {
  335. this.handleError(err)
  336. } finally {
  337. this.setLoading(false)
  338. }
  339. }
  340. render (state, viewOptions = {}) {
  341. const { authenticated, didFirstRender } = this.plugin.getPluginState()
  342. const { i18n } = this.plugin.uppy
  343. if (!didFirstRender) {
  344. this.preFirstRender()
  345. }
  346. const targetViewOptions = { ...this.opts, ...viewOptions }
  347. const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState()
  348. const { isChecked, toggleCheckbox, recordShiftKeyPress, filterItems } = this
  349. const hasInput = filterInput !== ''
  350. const pluginIcon = this.plugin.icon || defaultPickerIcon
  351. const headerProps = {
  352. showBreadcrumbs: targetViewOptions.showBreadcrumbs,
  353. getFolder: this.getFolder,
  354. breadcrumbs: this.plugin.getPluginState().breadcrumbs,
  355. pluginIcon,
  356. title: this.plugin.title,
  357. logout: this.logout,
  358. username: this.username,
  359. i18n,
  360. }
  361. const browserProps = {
  362. isChecked,
  363. toggleCheckbox,
  364. recordShiftKeyPress,
  365. currentSelection,
  366. files: hasInput ? filterItems(files) : files,
  367. folders: hasInput ? filterItems(folders) : folders,
  368. username: this.username,
  369. getNextFolder: this.getNextFolder,
  370. getFolder: this.getFolder,
  371. loadAllFiles: this.opts.loadAllFiles,
  372. // For SearchFilterInput component
  373. showSearchFilter: targetViewOptions.showFilter,
  374. search: this.filterQuery,
  375. clearSearch: this.clearFilter,
  376. searchTerm: filterInput,
  377. searchOnInput: true,
  378. searchInputLabel: i18n('filter'),
  379. clearSearchLabel: i18n('resetFilter'),
  380. noResultsLabel: i18n('noFilesFound'),
  381. logout: this.logout,
  382. handleScroll: this.handleScroll,
  383. done: this.donePicking,
  384. cancel: this.cancelPicking,
  385. headerComponent: Header(headerProps),
  386. title: this.plugin.title,
  387. viewType: targetViewOptions.viewType,
  388. showTitles: targetViewOptions.showTitles,
  389. showBreadcrumbs: targetViewOptions.showBreadcrumbs,
  390. pluginIcon,
  391. i18n: this.plugin.uppy.i18n,
  392. uppyFiles: this.plugin.uppy.getFiles(),
  393. validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
  394. }
  395. if (loading) {
  396. return (
  397. <CloseWrapper onUnmount={this.clearSelection}>
  398. <LoaderView i18n={this.plugin.uppy.i18n} loading={loading} />
  399. </CloseWrapper>
  400. )
  401. }
  402. if (!authenticated) {
  403. return (
  404. <CloseWrapper onUnmount={this.clearSelection}>
  405. <AuthView
  406. pluginName={this.plugin.title}
  407. pluginIcon={pluginIcon}
  408. handleAuth={this.handleAuth}
  409. i18n={this.plugin.uppy.i18n}
  410. i18nArray={this.plugin.uppy.i18nArray}
  411. />
  412. </CloseWrapper>
  413. )
  414. }
  415. return (
  416. <CloseWrapper onUnmount={this.clearSelection}>
  417. {/* eslint-disable-next-line react/jsx-props-no-spreading */}
  418. <Browser {...browserProps} />
  419. </CloseWrapper>
  420. )
  421. }
  422. }