index.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. const { h, Component } = require('preact')
  2. const AuthView = require('./AuthView')
  3. const Browser = require('./Browser')
  4. const LoaderView = require('./Loader')
  5. const generateFileID = require('@uppy/utils/lib/generateFileID')
  6. const getFileType = require('@uppy/utils/lib/getFileType')
  7. const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
  8. /**
  9. * Array.prototype.findIndex ponyfill for old browsers.
  10. */
  11. function findIndex (array, predicate) {
  12. for (let i = 0; i < array.length; i++) {
  13. if (predicate(array[i])) return i
  14. }
  15. return -1
  16. }
  17. class CloseWrapper extends Component {
  18. componentWillUnmount () {
  19. this.props.onUnmount()
  20. }
  21. render () {
  22. return this.props.children[0]
  23. }
  24. }
  25. /**
  26. * Class to easily generate generic views for Provider plugins
  27. */
  28. module.exports = class ProviderView {
  29. /**
  30. * @param {object} instance of the plugin
  31. */
  32. constructor (plugin, opts) {
  33. this.plugin = plugin
  34. this.provider = opts.provider
  35. // set default options
  36. const defaultOptions = {
  37. viewType: 'list',
  38. showTitles: true,
  39. showFilter: true,
  40. showBreadcrumbs: true
  41. }
  42. // merge default options with the ones set by user
  43. this.opts = Object.assign({}, defaultOptions, opts)
  44. // Logic
  45. this.addFile = this.addFile.bind(this)
  46. this.filterItems = this.filterItems.bind(this)
  47. this.filterQuery = this.filterQuery.bind(this)
  48. this.toggleSearch = this.toggleSearch.bind(this)
  49. this.getFolder = this.getFolder.bind(this)
  50. this.getNextFolder = this.getNextFolder.bind(this)
  51. this.logout = this.logout.bind(this)
  52. this.preFirstRender = this.preFirstRender.bind(this)
  53. this.handleAuth = this.handleAuth.bind(this)
  54. this.handleDemoAuth = this.handleDemoAuth.bind(this)
  55. this.sortByTitle = this.sortByTitle.bind(this)
  56. this.sortByDate = this.sortByDate.bind(this)
  57. this.isActiveRow = this.isActiveRow.bind(this)
  58. this.isChecked = this.isChecked.bind(this)
  59. this.toggleCheckbox = this.toggleCheckbox.bind(this)
  60. this.handleError = this.handleError.bind(this)
  61. this.handleScroll = this.handleScroll.bind(this)
  62. this.donePicking = this.donePicking.bind(this)
  63. this.cancelPicking = this.cancelPicking.bind(this)
  64. this.clearSelection = this.clearSelection.bind(this)
  65. // Visual
  66. this.render = this.render.bind(this)
  67. this.clearSelection()
  68. }
  69. tearDown () {
  70. // Nothing.
  71. }
  72. _updateFilesAndFolders (res, files, folders) {
  73. this.nextPagePath = res.nextPagePath
  74. res.items.forEach((item) => {
  75. if (item.isFolder) {
  76. folders.push(item)
  77. } else {
  78. files.push(item)
  79. }
  80. })
  81. this.plugin.setPluginState({ folders, files })
  82. }
  83. /**
  84. * Called only the first time the provider view is rendered.
  85. * Kind of like an init function.
  86. */
  87. preFirstRender () {
  88. this.plugin.setPluginState({ didFirstRender: true })
  89. this.plugin.onFirstRender()
  90. }
  91. /**
  92. * Based on folder ID, fetch a new folder and update it to state
  93. * @param {String} id Folder id
  94. * @return {Promise} Folders/files in folder
  95. */
  96. getFolder (id, name) {
  97. return this._loaderWrapper(
  98. this.provider.list(id),
  99. (res) => {
  100. let folders = []
  101. let files = []
  102. let updatedDirectories
  103. const state = this.plugin.getPluginState()
  104. const index = findIndex(state.directories, (dir) => id === dir.id)
  105. if (index !== -1) {
  106. updatedDirectories = state.directories.slice(0, index + 1)
  107. } else {
  108. updatedDirectories = state.directories.concat([{id, title: name}])
  109. }
  110. this.username = this.username ? this.username : res.username
  111. this._updateFilesAndFolders(res, files, folders)
  112. this.plugin.setPluginState({ directories: updatedDirectories })
  113. },
  114. this.handleError)
  115. }
  116. /**
  117. * Fetches new folder
  118. * @param {Object} Folder
  119. * @param {String} title Folder title
  120. */
  121. getNextFolder (folder) {
  122. this.getFolder(folder.requestPath, folder.name)
  123. this.lastCheckbox = undefined
  124. }
  125. addFile (file) {
  126. const tagFile = {
  127. id: this.providerFileToId(file),
  128. source: this.plugin.id,
  129. data: file,
  130. name: file.name || file.id,
  131. type: file.mimeType,
  132. isRemote: true,
  133. body: {
  134. fileId: file.id
  135. },
  136. remote: {
  137. companionUrl: this.plugin.opts.companionUrl,
  138. url: `${this.provider.fileUrl(file.requestPath)}`,
  139. body: {
  140. fileId: file.id
  141. },
  142. providerOptions: this.provider.opts
  143. }
  144. }
  145. const fileType = getFileType(tagFile)
  146. // TODO Should we just always use the thumbnail URL if it exists?
  147. if (fileType && isPreviewSupported(fileType)) {
  148. tagFile.preview = file.thumbnail
  149. }
  150. this.plugin.uppy.log('Adding remote file')
  151. try {
  152. this.plugin.uppy.addFile(tagFile)
  153. } catch (err) {
  154. // Nothing, restriction errors handled in Core
  155. }
  156. }
  157. removeFile (id) {
  158. const { currentSelection } = this.plugin.getPluginState()
  159. this.plugin.setPluginState({
  160. currentSelection: currentSelection.filter((file) => file.id !== id)
  161. })
  162. }
  163. /**
  164. * Removes session token on client side.
  165. */
  166. logout () {
  167. this.provider.logout(location.href)
  168. .then((res) => {
  169. if (res.ok) {
  170. const newState = {
  171. authenticated: false,
  172. files: [],
  173. folders: [],
  174. directories: []
  175. }
  176. this.plugin.setPluginState(newState)
  177. }
  178. }).catch(this.handleError)
  179. }
  180. filterQuery (e) {
  181. const state = this.plugin.getPluginState()
  182. this.plugin.setPluginState(Object.assign({}, state, {
  183. filterInput: e ? e.target.value : ''
  184. }))
  185. }
  186. toggleSearch (inputEl) {
  187. const state = this.plugin.getPluginState()
  188. this.plugin.setPluginState({
  189. isSearchVisible: !state.isSearchVisible,
  190. filterInput: ''
  191. })
  192. }
  193. filterItems (items) {
  194. const state = this.plugin.getPluginState()
  195. if (!state.filterInput || state.filterInput === '') {
  196. return items
  197. }
  198. return items.filter((folder) => {
  199. return folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
  200. })
  201. }
  202. sortByTitle () {
  203. const state = Object.assign({}, this.plugin.getPluginState())
  204. const {files, folders, sorting} = state
  205. let sortedFiles = files.sort((fileA, fileB) => {
  206. if (sorting === 'titleDescending') {
  207. return fileB.name.localeCompare(fileA.name)
  208. }
  209. return fileA.name.localeCompare(fileB.name)
  210. })
  211. let sortedFolders = folders.sort((folderA, folderB) => {
  212. if (sorting === 'titleDescending') {
  213. return folderB.name.localeCompare(folderA.name)
  214. }
  215. return folderA.name.localeCompare(folderB.name)
  216. })
  217. this.plugin.setPluginState(Object.assign({}, state, {
  218. files: sortedFiles,
  219. folders: sortedFolders,
  220. sorting: (sorting === 'titleDescending') ? 'titleAscending' : 'titleDescending'
  221. }))
  222. }
  223. sortByDate () {
  224. const state = Object.assign({}, this.plugin.getPluginState())
  225. const {files, folders, sorting} = state
  226. let sortedFiles = files.sort((fileA, fileB) => {
  227. let a = new Date(fileA.modifiedDate)
  228. let b = new Date(fileB.modifiedDate)
  229. if (sorting === 'dateDescending') {
  230. return a > b ? -1 : a < b ? 1 : 0
  231. }
  232. return a > b ? 1 : a < b ? -1 : 0
  233. })
  234. let sortedFolders = folders.sort((folderA, folderB) => {
  235. let a = new Date(folderA.modifiedDate)
  236. let b = new Date(folderB.modifiedDate)
  237. if (sorting === 'dateDescending') {
  238. return a > b ? -1 : a < b ? 1 : 0
  239. }
  240. return a > b ? 1 : a < b ? -1 : 0
  241. })
  242. this.plugin.setPluginState(Object.assign({}, state, {
  243. files: sortedFiles,
  244. folders: sortedFolders,
  245. sorting: (sorting === 'dateDescending') ? 'dateAscending' : 'dateDescending'
  246. }))
  247. }
  248. sortBySize () {
  249. const state = Object.assign({}, this.plugin.getPluginState())
  250. const {files, sorting} = state
  251. // check that plugin supports file sizes
  252. if (!files.length || !this.plugin.getItemData(files[0]).size) {
  253. return
  254. }
  255. let sortedFiles = files.sort((fileA, fileB) => {
  256. let a = fileA.size
  257. let b = fileB.size
  258. if (sorting === 'sizeDescending') {
  259. return a > b ? -1 : a < b ? 1 : 0
  260. }
  261. return a > b ? 1 : a < b ? -1 : 0
  262. })
  263. this.plugin.setPluginState(Object.assign({}, state, {
  264. files: sortedFiles,
  265. sorting: (sorting === 'sizeDescending') ? 'sizeAscending' : 'sizeDescending'
  266. }))
  267. }
  268. isActiveRow (file) {
  269. return this.plugin.getPluginState().activeRow === this.plugin.getItemId(file)
  270. }
  271. isChecked (file) {
  272. const { currentSelection } = this.plugin.getPluginState()
  273. return currentSelection.some((item) => item === file)
  274. }
  275. /**
  276. * Adds all files found inside of specified folder.
  277. *
  278. * Uses separated state while folder contents are being fetched and
  279. * mantains list of selected folders, which are separated from files.
  280. */
  281. addFolder (folder) {
  282. const folderId = this.providerFileToId(folder)
  283. let state = this.plugin.getPluginState()
  284. let folders = state.selectedFolders || {}
  285. if (folderId in folders && folders[folderId].loading) {
  286. return
  287. }
  288. folders[folderId] = {loading: true, files: []}
  289. this.plugin.setPluginState({selectedFolders: folders})
  290. return this.provider.list(folder.requestPath).then((res) => {
  291. let files = []
  292. res.items.forEach((item) => {
  293. if (!item.isFolder) {
  294. this.addFile(item)
  295. files.push(this.providerFileToId(item))
  296. }
  297. })
  298. state = this.plugin.getPluginState()
  299. state.selectedFolders[folderId] = {loading: false, files: files}
  300. this.plugin.setPluginState({selectedFolders: folders})
  301. const dashboard = this.plugin.uppy.getPlugin('Dashboard')
  302. let message
  303. if (files.length) {
  304. message = dashboard.i18n('folderAdded', {
  305. smart_count: files.length, folder: folder.name
  306. })
  307. } else {
  308. message = dashboard.i18n('emptyFolderAdded')
  309. }
  310. this.plugin.uppy.info(message)
  311. }).catch((e) => {
  312. state = this.plugin.getPluginState()
  313. delete state.selectedFolders[folderId]
  314. this.plugin.setPluginState({selectedFolders: state.selectedFolders})
  315. this.handleError(e)
  316. })
  317. }
  318. /**
  319. * Toggles file/folder checkbox to on/off state while updating files list.
  320. *
  321. * Note that some extra complexity comes from supporting shift+click to
  322. * toggle multiple checkboxes at once, which is done by getting all files
  323. * in between last checked file and current one.
  324. */
  325. toggleCheckbox (e, file) {
  326. e.stopPropagation()
  327. e.preventDefault()
  328. let { folders, files } = this.plugin.getPluginState()
  329. let items = this.filterItems(folders.concat(files))
  330. // Shift-clicking selects a single consecutive list of items
  331. // starting at the previous click and deselects everything else.
  332. if (this.lastCheckbox && e.shiftKey) {
  333. let currentSelection
  334. const prevIndex = items.indexOf(this.lastCheckbox)
  335. const currentIndex = items.indexOf(file)
  336. if (prevIndex < currentIndex) {
  337. currentSelection = items.slice(prevIndex, currentIndex + 1)
  338. } else {
  339. currentSelection = items.slice(currentIndex, prevIndex + 1)
  340. }
  341. this.plugin.setPluginState({ currentSelection })
  342. return
  343. }
  344. this.lastCheckbox = file
  345. const { currentSelection } = this.plugin.getPluginState()
  346. if (this.isChecked(file)) {
  347. this.plugin.setPluginState({
  348. currentSelection: currentSelection.filter((item) => item !== file)
  349. })
  350. } else {
  351. this.plugin.setPluginState({
  352. currentSelection: currentSelection.concat([file])
  353. })
  354. }
  355. }
  356. providerFileToId (file) {
  357. return generateFileID({
  358. data: file,
  359. name: file.name || file.id,
  360. type: file.mimeType
  361. })
  362. }
  363. handleDemoAuth () {
  364. const state = this.plugin.getPluginState()
  365. this.plugin.setPluginState({}, state, {
  366. authenticated: true
  367. })
  368. }
  369. handleAuth () {
  370. const authState = btoa(JSON.stringify({ origin: location.origin }))
  371. const link = `${this.provider.authUrl()}?state=${authState}`
  372. const authWindow = window.open(link, '_blank')
  373. const handleToken = (e) => {
  374. if (!this._isOriginAllowed(e.origin, this.plugin.opts.companionAllowedHosts) || e.source !== authWindow) {
  375. this.plugin.uppy.log(`rejecting event from ${e.origin} vs allowed pattern ${this.plugin.opts.companionAllowedHosts}`)
  376. return
  377. }
  378. authWindow.close()
  379. window.removeEventListener('message', handleToken)
  380. this.provider.setAuthToken(e.data.token)
  381. this.preFirstRender()
  382. }
  383. window.addEventListener('message', handleToken)
  384. }
  385. _isOriginAllowed (origin, allowedOrigin) {
  386. const getRegex = (value) => {
  387. if (typeof value === 'string') {
  388. return new RegExp(`^${value}$`)
  389. } else if (value instanceof RegExp) {
  390. return value
  391. }
  392. }
  393. const patterns = Array.isArray(allowedOrigin) ? allowedOrigin.map(getRegex) : [getRegex(allowedOrigin)]
  394. return patterns
  395. .filter((pattern) => pattern !== null)
  396. .some((pattern) => pattern.test(origin))
  397. }
  398. handleError (error) {
  399. const uppy = this.plugin.uppy
  400. uppy.log(error.toString())
  401. const message = uppy.i18n(error.isAuthError ? 'companionAuthError' : 'companionError')
  402. uppy.info({message: message, details: error.toString()}, 'error', 5000)
  403. }
  404. handleScroll (e) {
  405. const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
  406. const path = this.nextPagePath || null
  407. if (scrollPos < 50 && path && !this._isHandlingScroll) {
  408. this.provider.list(path)
  409. .then((res) => {
  410. const { files, folders } = this.plugin.getPluginState()
  411. this._updateFilesAndFolders(res, files, folders)
  412. }).catch(this.handleError)
  413. .then(() => { this._isHandlingScroll = false }) // always called
  414. this._isHandlingScroll = true
  415. }
  416. }
  417. donePicking () {
  418. const { currentSelection } = this.plugin.getPluginState()
  419. const promises = currentSelection.map((file) => {
  420. if (file.isFolder) {
  421. return this.addFolder(file)
  422. } else {
  423. return this.addFile(file)
  424. }
  425. })
  426. this._loaderWrapper(Promise.all(promises), () => {
  427. this.clearSelection()
  428. }, () => {})
  429. }
  430. cancelPicking () {
  431. this.clearSelection()
  432. const dashboard = this.plugin.uppy.getPlugin('Dashboard')
  433. if (dashboard) dashboard.hideAllPanels()
  434. }
  435. clearSelection () {
  436. this.plugin.setPluginState({ currentSelection: [] })
  437. }
  438. // displays loader view while asynchronous request is being made.
  439. _loaderWrapper (promise, then, catch_) {
  440. promise
  441. .then((result) => {
  442. this.plugin.setPluginState({ loading: false })
  443. then(result)
  444. }).catch((err) => {
  445. this.plugin.setPluginState({ loading: false })
  446. catch_(err)
  447. })
  448. this.plugin.setPluginState({ loading: true })
  449. }
  450. render (state) {
  451. const { authenticated, didFirstRender } = this.plugin.getPluginState()
  452. if (!didFirstRender) {
  453. this.preFirstRender()
  454. }
  455. // reload pluginState for "loading" attribute because it might
  456. // have changed above.
  457. if (this.plugin.getPluginState().loading) {
  458. return (
  459. <CloseWrapper onUnmount={this.clearSelection}>
  460. <LoaderView i18n={this.plugin.uppy.i18n} />
  461. </CloseWrapper>
  462. )
  463. }
  464. if (!authenticated) {
  465. return (
  466. <CloseWrapper onUnmount={this.clearSelection}>
  467. <AuthView
  468. pluginName={this.plugin.title}
  469. pluginIcon={this.plugin.icon}
  470. demo={this.plugin.opts.demo}
  471. handleAuth={this.handleAuth}
  472. handleDemoAuth={this.handleDemoAuth} />
  473. </CloseWrapper>
  474. )
  475. }
  476. const browserProps = Object.assign({}, this.plugin.getPluginState(), {
  477. username: this.username,
  478. getNextFolder: this.getNextFolder,
  479. getFolder: this.getFolder,
  480. filterItems: this.filterItems,
  481. filterQuery: this.filterQuery,
  482. toggleSearch: this.toggleSearch,
  483. sortByTitle: this.sortByTitle,
  484. sortByDate: this.sortByDate,
  485. logout: this.logout,
  486. demo: this.plugin.opts.demo,
  487. isActiveRow: this.isActiveRow,
  488. isChecked: this.isChecked,
  489. toggleCheckbox: this.toggleCheckbox,
  490. handleScroll: this.handleScroll,
  491. done: this.donePicking,
  492. cancel: this.cancelPicking,
  493. title: this.plugin.title,
  494. viewType: this.opts.viewType,
  495. showTitles: this.opts.showTitles,
  496. showFilter: this.opts.showFilter,
  497. showBreadcrumbs: this.opts.showBreadcrumbs,
  498. pluginIcon: this.plugin.icon,
  499. i18n: this.plugin.uppy.i18n
  500. })
  501. return (
  502. <CloseWrapper onUnmount={this.clearSelection}>
  503. <Browser {...browserProps} />
  504. </CloseWrapper>
  505. )
  506. }
  507. }