index.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. const AuthView = require('./AuthView')
  2. const Browser = require('./Browser')
  3. const LoaderView = require('./Loader')
  4. const Utils = require('../../core/Utils')
  5. const { h } = require('preact')
  6. /**
  7. * Class to easily generate generic views for plugins
  8. *
  9. *
  10. * This class expects the plugin instance using it to have the following
  11. * accessor methods.
  12. * Each method takes the item whose property is to be accessed
  13. * as a param
  14. *
  15. * isFolder
  16. * @return {Boolean} for if the item is a folder or not
  17. * getItemData
  18. * @return {Object} that is format ready for uppy upload/download
  19. * getItemIcon
  20. * @return {Object} html instance of the item's icon
  21. * getItemSubList
  22. * @return {Array} sub-items in the item. e.g a folder may contain sub-items
  23. * getItemName
  24. * @return {String} display friendly name of the item
  25. * getMimeType
  26. * @return {String} mime type of the item
  27. * getItemId
  28. * @return {String} unique id of the item
  29. * getItemRequestPath
  30. * @return {String} unique request path of the item when making calls to uppy server
  31. * getItemModifiedDate
  32. * @return {object} or {String} date of when last the item was modified
  33. * getItemThumbnailUrl
  34. * @return {String}
  35. */
  36. module.exports = class ProviderView {
  37. /**
  38. * @param {object} instance of the plugin
  39. */
  40. constructor (plugin, opts) {
  41. this.plugin = plugin
  42. this.Provider = plugin[plugin.id]
  43. // set default options
  44. const defaultOptions = {
  45. viewType: 'list',
  46. showTitles: true,
  47. showFilter: true,
  48. showBreadcrumbs: true
  49. }
  50. // merge default options with the ones set by user
  51. this.opts = Object.assign({}, defaultOptions, opts)
  52. // Logic
  53. this.updateFolderState = this.updateFolderState.bind(this)
  54. this.addFile = this.addFile.bind(this)
  55. this.filterItems = this.filterItems.bind(this)
  56. this.filterQuery = this.filterQuery.bind(this)
  57. this.toggleSearch = this.toggleSearch.bind(this)
  58. this.getFolder = this.getFolder.bind(this)
  59. this.getNextFolder = this.getNextFolder.bind(this)
  60. this.logout = this.logout.bind(this)
  61. this.checkAuth = this.checkAuth.bind(this)
  62. this.handleAuth = this.handleAuth.bind(this)
  63. this.handleDemoAuth = this.handleDemoAuth.bind(this)
  64. this.sortByTitle = this.sortByTitle.bind(this)
  65. this.sortByDate = this.sortByDate.bind(this)
  66. this.isActiveRow = this.isActiveRow.bind(this)
  67. this.isChecked = this.isChecked.bind(this)
  68. this.toggleCheckbox = this.toggleCheckbox.bind(this)
  69. this.handleError = this.handleError.bind(this)
  70. this.handleScroll = this.handleScroll.bind(this)
  71. this.donePicking = this.donePicking.bind(this)
  72. this.plugin.uppy.on('file-removed', this.updateFolderState)
  73. // Visual
  74. this.render = this.render.bind(this)
  75. }
  76. tearDown () {
  77. this.plugin.uppy.off('file-removed', this.updateFolderState)
  78. }
  79. _updateFilesAndFolders (res, files, folders) {
  80. this.plugin.getItemSubList(res).forEach((item) => {
  81. if (this.plugin.isFolder(item)) {
  82. folders.push(item)
  83. } else {
  84. files.push(item)
  85. }
  86. })
  87. this.plugin.setPluginState({ folders, files })
  88. }
  89. checkAuth () {
  90. this.plugin.setPluginState({ checkAuthInProgress: true })
  91. this.Provider.checkAuth()
  92. .then((authenticated) => {
  93. this.plugin.setPluginState({ checkAuthInProgress: false })
  94. this.plugin.onAuth(authenticated)
  95. })
  96. .catch((err) => {
  97. this.plugin.setPluginState({ checkAuthInProgress: false })
  98. this.handleError(err)
  99. })
  100. }
  101. /**
  102. * Based on folder ID, fetch a new folder and update it to state
  103. * @param {String} id Folder id
  104. * @return {Promise} Folders/files in folder
  105. */
  106. getFolder (id, name) {
  107. return this._loaderWrapper(
  108. this.Provider.list(id),
  109. (res) => {
  110. let folders = []
  111. let files = []
  112. let updatedDirectories
  113. const state = this.plugin.getPluginState()
  114. const index = state.directories.findIndex((dir) => id === dir.id)
  115. if (index !== -1) {
  116. updatedDirectories = state.directories.slice(0, index + 1)
  117. } else {
  118. updatedDirectories = state.directories.concat([{id, title: name || this.plugin.getItemName(res)}])
  119. }
  120. this.username = this.username ? this.username : this.plugin.getUsername(res)
  121. this._updateFilesAndFolders(res, files, folders)
  122. this.plugin.setPluginState({ directories: updatedDirectories })
  123. },
  124. this.handleError)
  125. }
  126. /**
  127. * Fetches new folder
  128. * @param {Object} Folder
  129. * @param {String} title Folder title
  130. */
  131. getNextFolder (folder) {
  132. let id = this.plugin.getItemRequestPath(folder)
  133. this.getFolder(id, this.plugin.getItemName(folder))
  134. this.lastCheckbox = undefined
  135. }
  136. addFile (file, isCheckbox = false) {
  137. const tagFile = {
  138. source: this.plugin.id,
  139. data: this.plugin.getItemData(file),
  140. name: this.plugin.getItemName(file) || this.plugin.getItemId(file),
  141. type: this.plugin.getMimeType(file),
  142. isRemote: true,
  143. body: {
  144. fileId: this.plugin.getItemId(file)
  145. },
  146. remote: {
  147. host: this.plugin.opts.host,
  148. url: `${this.Provider.fileUrl(this.plugin.getItemRequestPath(file))}`,
  149. body: {
  150. fileId: this.plugin.getItemId(file)
  151. }
  152. }
  153. }
  154. const fileType = Utils.getFileType(tagFile)
  155. // TODO Should we just always use the thumbnail URL if it exists?
  156. if (fileType && Utils.isPreviewSupported(fileType)) {
  157. tagFile.preview = this.plugin.getItemThumbnailUrl(file)
  158. }
  159. this.plugin.uppy.log('Adding remote file')
  160. this.plugin.uppy.addFile(tagFile)
  161. if (!isCheckbox) {
  162. this.donePicking()
  163. }
  164. }
  165. /**
  166. * Removes session token on client side.
  167. */
  168. logout () {
  169. this.Provider.logout(location.href)
  170. .then((res) => {
  171. if (res.ok) {
  172. const newState = {
  173. authenticated: false,
  174. files: [],
  175. folders: [],
  176. directories: []
  177. }
  178. this.plugin.setPluginState(newState)
  179. }
  180. }).catch(this.handleError)
  181. }
  182. filterQuery (e) {
  183. const state = this.plugin.getPluginState()
  184. this.plugin.setPluginState(Object.assign({}, state, {
  185. filterInput: e ? e.target.value : ''
  186. }))
  187. }
  188. toggleSearch (inputEl) {
  189. const state = this.plugin.getPluginState()
  190. this.plugin.setPluginState({
  191. isSearchVisible: !state.isSearchVisible,
  192. filterInput: ''
  193. })
  194. }
  195. filterItems (items) {
  196. const state = this.plugin.getPluginState()
  197. return items.filter((folder) => {
  198. return this.plugin.getItemName(folder).toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
  199. })
  200. }
  201. sortByTitle () {
  202. const state = Object.assign({}, this.plugin.getPluginState())
  203. const {files, folders, sorting} = state
  204. let sortedFiles = files.sort((fileA, fileB) => {
  205. if (sorting === 'titleDescending') {
  206. return this.plugin.getItemName(fileB).localeCompare(this.plugin.getItemName(fileA))
  207. }
  208. return this.plugin.getItemName(fileA).localeCompare(this.plugin.getItemName(fileB))
  209. })
  210. let sortedFolders = folders.sort((folderA, folderB) => {
  211. if (sorting === 'titleDescending') {
  212. return this.plugin.getItemName(folderB).localeCompare(this.plugin.getItemName(folderA))
  213. }
  214. return this.plugin.getItemName(folderA).localeCompare(this.plugin.getItemName(folderB))
  215. })
  216. this.plugin.setPluginState(Object.assign({}, state, {
  217. files: sortedFiles,
  218. folders: sortedFolders,
  219. sorting: (sorting === 'titleDescending') ? 'titleAscending' : 'titleDescending'
  220. }))
  221. }
  222. sortByDate () {
  223. const state = Object.assign({}, this.plugin.getPluginState())
  224. const {files, folders, sorting} = state
  225. let sortedFiles = files.sort((fileA, fileB) => {
  226. let a = new Date(this.plugin.getItemModifiedDate(fileA))
  227. let b = new Date(this.plugin.getItemModifiedDate(fileB))
  228. if (sorting === 'dateDescending') {
  229. return a > b ? -1 : a < b ? 1 : 0
  230. }
  231. return a > b ? 1 : a < b ? -1 : 0
  232. })
  233. let sortedFolders = folders.sort((folderA, folderB) => {
  234. let a = new Date(this.plugin.getItemModifiedDate(folderA))
  235. let b = new Date(this.plugin.getItemModifiedDate(folderB))
  236. if (sorting === 'dateDescending') {
  237. return a > b ? -1 : a < b ? 1 : 0
  238. }
  239. return a > b ? 1 : a < b ? -1 : 0
  240. })
  241. this.plugin.setPluginState(Object.assign({}, state, {
  242. files: sortedFiles,
  243. folders: sortedFolders,
  244. sorting: (sorting === 'dateDescending') ? 'dateAscending' : 'dateDescending'
  245. }))
  246. }
  247. sortBySize () {
  248. const state = Object.assign({}, this.plugin.getPluginState())
  249. const {files, sorting} = state
  250. // check that plugin supports file sizes
  251. if (!files.length || !this.plugin.getItemData(files[0]).size) {
  252. return
  253. }
  254. let sortedFiles = files.sort((fileA, fileB) => {
  255. let a = this.plugin.getItemData(fileA).size
  256. let b = this.plugin.getItemData(fileB).size
  257. if (sorting === 'sizeDescending') {
  258. return a > b ? -1 : a < b ? 1 : 0
  259. }
  260. return a > b ? 1 : a < b ? -1 : 0
  261. })
  262. this.plugin.setPluginState(Object.assign({}, state, {
  263. files: sortedFiles,
  264. sorting: (sorting === 'sizeDescending') ? 'sizeAscending' : 'sizeDescending'
  265. }))
  266. }
  267. isActiveRow (file) {
  268. return this.plugin.getPluginState().activeRow === this.plugin.getItemId(file)
  269. }
  270. isChecked (item) {
  271. const itemId = this.providerFileToId(item)
  272. if (this.plugin.isFolder(item)) {
  273. const state = this.plugin.getPluginState()
  274. const folders = state.selectedFolders || {}
  275. if (itemId in folders) {
  276. return folders[itemId]
  277. }
  278. return false
  279. }
  280. return (itemId in this.plugin.uppy.getState().files)
  281. }
  282. /**
  283. * Adds all files found inside of specified folder.
  284. *
  285. * Uses separated state while folder contents are being fetched and
  286. * mantains list of selected folders, which are separated from files.
  287. */
  288. addFolder (folder) {
  289. const folderId = this.providerFileToId(folder)
  290. let state = this.plugin.getPluginState()
  291. let folders = state.selectedFolders || {}
  292. if (folderId in folders && folders[folderId].loading) {
  293. return
  294. }
  295. folders[folderId] = {loading: true, files: []}
  296. this.plugin.setPluginState({selectedFolders: folders})
  297. this.Provider.list(this.plugin.getItemRequestPath(folder)).then((res) => {
  298. let files = []
  299. this.plugin.getItemSubList(res).forEach((item) => {
  300. if (!this.plugin.isFolder(item)) {
  301. this.addFile(item, true)
  302. files.push(this.providerFileToId(item))
  303. }
  304. })
  305. state = this.plugin.getPluginState()
  306. state.selectedFolders[folderId] = {loading: false, files: files}
  307. this.plugin.setPluginState({selectedFolders: folders})
  308. const dashboard = this.plugin.uppy.getPlugin('Dashboard')
  309. let message
  310. if (files.length) {
  311. message = dashboard.i18n('folderAdded', {
  312. smart_count: files.length, folder: this.plugin.getItemName(folder)
  313. })
  314. } else {
  315. message = dashboard.i18n('emptyFolderAdded')
  316. }
  317. this.plugin.uppy.info(message)
  318. }).catch((e) => {
  319. state = this.plugin.getPluginState()
  320. delete state.selectedFolders[folderId]
  321. this.plugin.setPluginState({selectedFolders: state.selectedFolders})
  322. this.handleError(e)
  323. })
  324. }
  325. removeFolder (folderId) {
  326. let state = this.plugin.getPluginState()
  327. let folders = state.selectedFolders || {}
  328. if (!(folderId in folders)) {
  329. return
  330. }
  331. let folder = folders[folderId]
  332. if (folder.loading) {
  333. return
  334. }
  335. // deepcopy the files before iteration because the
  336. // original array constantly gets mutated during
  337. // the iteration by updateFolderState as each file
  338. // is removed and 'core:file-removed' is emitted.
  339. const files = folder.files.concat([])
  340. for (const fileId of files) {
  341. if (fileId in this.plugin.uppy.getState().files) {
  342. this.plugin.uppy.removeFile(fileId)
  343. }
  344. }
  345. delete folders[folderId]
  346. this.plugin.setPluginState({selectedFolders: folders})
  347. }
  348. /**
  349. * Updates selected folders state everytime file is being removed.
  350. *
  351. * Note that this is only important when files are getting removed from the
  352. * main screen, and will do nothing when you uncheck folder directly, since
  353. * it's already been done in removeFolder method.
  354. */
  355. updateFolderState (file) {
  356. let state = this.plugin.getPluginState()
  357. let folders = state.selectedFolders || {}
  358. for (let folderId in folders) {
  359. let folder = folders[folderId]
  360. if (folder.loading) {
  361. continue
  362. }
  363. let i = folder.files.indexOf(file.id)
  364. if (i > -1) {
  365. folder.files.splice(i, 1)
  366. }
  367. if (!folder.files.length) {
  368. delete folders[folderId]
  369. }
  370. }
  371. this.plugin.setPluginState({selectedFolders: folders})
  372. }
  373. /**
  374. * Toggles file/folder checkbox to on/off state while updating files list.
  375. *
  376. * Note that some extra complexity comes from supporting shift+click to
  377. * toggle multiple checkboxes at once, which is done by getting all files
  378. * in between last checked file and current one, and applying an on/off state
  379. * for all of them, depending on current file state.
  380. */
  381. toggleCheckbox (e, file) {
  382. e.stopPropagation()
  383. e.preventDefault()
  384. let { folders, files, filterInput } = this.plugin.getPluginState()
  385. let items = folders.concat(files)
  386. if (filterInput !== '') {
  387. items = this.filterItems(items)
  388. }
  389. let itemsToToggle = [file]
  390. if (this.lastCheckbox && e.shiftKey) {
  391. let prevIndex = items.indexOf(this.lastCheckbox)
  392. let currentIndex = items.indexOf(file)
  393. if (prevIndex < currentIndex) {
  394. itemsToToggle = items.slice(prevIndex, currentIndex + 1)
  395. } else {
  396. itemsToToggle = items.slice(currentIndex, prevIndex + 1)
  397. }
  398. }
  399. this.lastCheckbox = file
  400. if (this.isChecked(file)) {
  401. for (let item of itemsToToggle) {
  402. const itemId = this.providerFileToId(item)
  403. if (this.plugin.isFolder(item)) {
  404. this.removeFolder(itemId)
  405. } else {
  406. if (itemId in this.plugin.uppy.getState().files) {
  407. this.plugin.uppy.removeFile(itemId)
  408. }
  409. }
  410. }
  411. } else {
  412. for (let item of itemsToToggle) {
  413. if (this.plugin.isFolder(item)) {
  414. this.addFolder(item)
  415. } else {
  416. this.addFile(item, true)
  417. }
  418. }
  419. }
  420. }
  421. providerFileToId (file) {
  422. return Utils.generateFileID({
  423. data: this.plugin.getItemData(file),
  424. name: this.plugin.getItemName(file) || this.plugin.getItemId(file),
  425. type: this.plugin.getMimeType(file)
  426. })
  427. }
  428. handleDemoAuth () {
  429. const state = this.plugin.getPluginState()
  430. this.plugin.setPluginState({}, state, {
  431. authenticated: true
  432. })
  433. }
  434. handleAuth () {
  435. const urlId = Math.floor(Math.random() * 999999) + 1
  436. const redirect = `${location.href}${location.search ? '&' : '?'}id=${urlId}`
  437. const authState = btoa(JSON.stringify({ redirect }))
  438. const link = `${this.Provider.authUrl()}?state=${authState}`
  439. const authWindow = window.open(link, '_blank')
  440. authWindow.opener = null
  441. const checkAuth = () => {
  442. let authWindowUrl
  443. try {
  444. authWindowUrl = authWindow.location.href
  445. } catch (e) {
  446. if (e instanceof DOMException || e instanceof TypeError) {
  447. return setTimeout(checkAuth, 100)
  448. } else throw e
  449. }
  450. // split url because chrome adds '#' to redirects
  451. if (authWindowUrl && authWindowUrl.split('#')[0] === redirect) {
  452. authWindow.close()
  453. this._loaderWrapper(this.Provider.checkAuth(), this.plugin.onAuth, this.handleError)
  454. } else {
  455. setTimeout(checkAuth, 100)
  456. }
  457. }
  458. checkAuth()
  459. }
  460. handleError (error) {
  461. const uppy = this.plugin.uppy
  462. const message = uppy.i18n('uppyServerError')
  463. uppy.log(error.toString())
  464. uppy.info({message: message, details: error.toString()}, 'error', 5000)
  465. }
  466. handleScroll (e) {
  467. const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
  468. const path = this.plugin.getNextPagePath ? this.plugin.getNextPagePath() : null
  469. if (scrollPos < 50 && path && !this._isHandlingScroll) {
  470. this.Provider.list(path)
  471. .then((res) => {
  472. const { files, folders } = this.plugin.getPluginState()
  473. this._updateFilesAndFolders(res, files, folders)
  474. }).catch(this.handleError)
  475. .then(() => { this._isHandlingScroll = false }) // always called
  476. this._isHandlingScroll = true
  477. }
  478. }
  479. donePicking () {
  480. const dashboard = this.plugin.uppy.getPlugin('Dashboard')
  481. if (dashboard) dashboard.hideAllPanels()
  482. }
  483. // displays loader view while asynchronous request is being made.
  484. _loaderWrapper (promise, then, catch_) {
  485. promise
  486. .then(then).catch(catch_)
  487. .then(() => this.plugin.setPluginState({ loading: false })) // always called.
  488. this.plugin.setPluginState({ loading: true })
  489. }
  490. render (state) {
  491. const { authenticated, checkAuthInProgress, loading } = this.plugin.getPluginState()
  492. if (loading) {
  493. return LoaderView()
  494. }
  495. if (!authenticated) {
  496. return h(AuthView, {
  497. pluginName: this.plugin.title,
  498. pluginIcon: this.plugin.icon,
  499. demo: this.plugin.opts.demo,
  500. checkAuth: this.checkAuth,
  501. handleAuth: this.handleAuth,
  502. handleDemoAuth: this.handleDemoAuth,
  503. checkAuthInProgress: checkAuthInProgress
  504. })
  505. }
  506. const browserProps = Object.assign({}, this.plugin.getPluginState(), {
  507. username: this.username,
  508. getNextFolder: this.getNextFolder,
  509. getFolder: this.getFolder,
  510. addFile: this.addFile,
  511. filterItems: this.filterItems,
  512. filterQuery: this.filterQuery,
  513. toggleSearch: this.toggleSearch,
  514. sortByTitle: this.sortByTitle,
  515. sortByDate: this.sortByDate,
  516. logout: this.logout,
  517. demo: this.plugin.opts.demo,
  518. isActiveRow: this.isActiveRow,
  519. isChecked: this.isChecked,
  520. toggleCheckbox: this.toggleCheckbox,
  521. getItemId: this.plugin.getItemId,
  522. getItemName: this.plugin.getItemName,
  523. getItemIcon: this.plugin.getItemIcon,
  524. handleScroll: this.handleScroll,
  525. done: this.donePicking,
  526. title: this.plugin.title,
  527. viewType: this.opts.viewType,
  528. showTitles: this.opts.showTitles,
  529. showFilter: this.opts.showFilter,
  530. showBreadcrumbs: this.opts.showBreadcrumbs,
  531. pluginIcon: this.plugin.icon,
  532. i18n: this.plugin.uppy.i18n
  533. })
  534. return Browser(browserProps)
  535. }
  536. }