ProviderView.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. import { h } from 'preact'
  2. import type {
  3. UnknownProviderPlugin,
  4. PartialTreeFolder,
  5. PartialTreeFolderNode,
  6. PartialTreeFile,
  7. UnknownProviderPluginState,
  8. PartialTreeId,
  9. PartialTree,
  10. } from '@uppy/core/lib/Uppy.js'
  11. import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
  12. import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
  13. import classNames from 'classnames'
  14. import type { ValidateableFile } from '@uppy/core/lib/Restricter.js'
  15. import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
  16. import type { I18n } from '@uppy/utils/lib/Translator'
  17. import AuthView from './AuthView.tsx'
  18. import Header from './Header.tsx'
  19. import Browser from '../Browser.tsx'
  20. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  21. // @ts-ignore We don't want TS to generate types for the package.json
  22. import packageJson from '../../package.json'
  23. import PartialTreeUtils from '../utils/PartialTreeUtils/index.ts'
  24. import shouldHandleScroll from '../utils/shouldHandleScroll.ts'
  25. import handleError from '../utils/handleError.ts'
  26. import getClickedRange from '../utils/getClickedRange.ts'
  27. import SearchInput from '../SearchInput.tsx'
  28. import FooterActions from '../FooterActions.tsx'
  29. import addFiles from '../utils/addFiles.ts'
  30. import getCheckedFilesWithPaths from '../utils/PartialTreeUtils/getCheckedFilesWithPaths.ts'
  31. import getBreadcrumbs from '../utils/PartialTreeUtils/getBreadcrumbs.ts'
  32. export function defaultPickerIcon(): h.JSX.Element {
  33. return (
  34. <svg
  35. aria-hidden="true"
  36. focusable="false"
  37. width="30"
  38. height="30"
  39. viewBox="0 0 30 30"
  40. >
  41. <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" />
  42. </svg>
  43. )
  44. }
  45. const getDefaultState = (
  46. rootFolderId: string | null,
  47. ): UnknownProviderPluginState => ({
  48. authenticated: undefined, // we don't know yet
  49. partialTree: [
  50. {
  51. type: 'root',
  52. id: rootFolderId,
  53. cached: false,
  54. nextPagePath: null,
  55. },
  56. ],
  57. currentFolderId: rootFolderId,
  58. searchString: '',
  59. didFirstRender: false,
  60. username: null,
  61. loading: false,
  62. })
  63. type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
  64. export interface Opts<M extends Meta, B extends Body> {
  65. provider: UnknownProviderPlugin<M, B>['provider']
  66. viewType: 'list' | 'grid'
  67. showTitles: boolean
  68. showFilter: boolean
  69. showBreadcrumbs: boolean
  70. loadAllFiles: boolean
  71. renderAuthForm?: (args: {
  72. pluginName: string
  73. i18n: I18n
  74. loading: boolean | string
  75. onAuth: (authFormData: unknown) => Promise<void>
  76. }) => h.JSX.Element
  77. virtualList: boolean
  78. }
  79. type PassedOpts<M extends Meta, B extends Body> = Optional<
  80. Opts<M, B>,
  81. | 'viewType'
  82. | 'showTitles'
  83. | 'showFilter'
  84. | 'showBreadcrumbs'
  85. | 'loadAllFiles'
  86. | 'virtualList'
  87. >
  88. type DefaultOpts<M extends Meta, B extends Body> = Omit<Opts<M, B>, 'provider'>
  89. type RenderOpts<M extends Meta, B extends Body> = Omit<
  90. PassedOpts<M, B>,
  91. 'provider'
  92. >
  93. /**
  94. * Class to easily generate generic views for Provider plugins
  95. */
  96. export default class ProviderView<M extends Meta, B extends Body> {
  97. static VERSION = packageJson.version
  98. plugin: UnknownProviderPlugin<M, B>
  99. provider: UnknownProviderPlugin<M, B>['provider']
  100. opts: Opts<M, B>
  101. isHandlingScroll: boolean = false
  102. lastCheckbox: string | null = null
  103. constructor(plugin: UnknownProviderPlugin<M, B>, opts: PassedOpts<M, B>) {
  104. this.plugin = plugin
  105. this.provider = opts.provider
  106. const defaultOptions: DefaultOpts<M, B> = {
  107. viewType: 'list',
  108. showTitles: true,
  109. showFilter: true,
  110. showBreadcrumbs: true,
  111. loadAllFiles: false,
  112. virtualList: false,
  113. }
  114. this.opts = { ...defaultOptions, ...opts }
  115. this.openFolder = this.openFolder.bind(this)
  116. this.logout = this.logout.bind(this)
  117. this.handleAuth = this.handleAuth.bind(this)
  118. this.handleScroll = this.handleScroll.bind(this)
  119. this.resetPluginState = this.resetPluginState.bind(this)
  120. this.donePicking = this.donePicking.bind(this)
  121. this.render = this.render.bind(this)
  122. this.cancelSelection = this.cancelSelection.bind(this)
  123. this.toggleCheckbox = this.toggleCheckbox.bind(this)
  124. // Set default state for the plugin
  125. this.resetPluginState()
  126. // todo
  127. // @ts-expect-error this should be typed in @uppy/dashboard.
  128. this.plugin.uppy.on('dashboard:close-panel', this.resetPluginState)
  129. this.plugin.uppy.registerRequestClient(
  130. this.provider.provider,
  131. this.provider,
  132. )
  133. }
  134. resetPluginState(): void {
  135. this.plugin.setPluginState(getDefaultState(this.plugin.rootFolderId))
  136. }
  137. // eslint-disable-next-line class-methods-use-this
  138. tearDown(): void {
  139. // Nothing.
  140. }
  141. setLoading(loading: boolean | string): void {
  142. this.plugin.setPluginState({ loading })
  143. }
  144. cancelSelection(): void {
  145. const { partialTree } = this.plugin.getPluginState()
  146. const newPartialTree: PartialTree = partialTree.map((item) =>
  147. item.type === 'root' ? item : { ...item, status: 'unchecked' },
  148. )
  149. this.plugin.setPluginState({ partialTree: newPartialTree })
  150. }
  151. #abortController: AbortController | undefined
  152. async #withAbort(op: (signal: AbortSignal) => Promise<void>) {
  153. // prevent multiple requests in parallel from causing race conditions
  154. this.#abortController?.abort()
  155. const abortController = new AbortController()
  156. this.#abortController = abortController
  157. const cancelRequest = () => {
  158. abortController.abort()
  159. }
  160. try {
  161. // @ts-expect-error this should be typed in @uppy/dashboard.
  162. // Even then I don't think we can make this work without adding dashboard
  163. // as a dependency to provider-views.
  164. this.plugin.uppy.on('dashboard:close-panel', cancelRequest)
  165. this.plugin.uppy.on('cancel-all', cancelRequest)
  166. await op(abortController.signal)
  167. } finally {
  168. // @ts-expect-error this should be typed in @uppy/dashboard.
  169. // Even then I don't think we can make this work without adding dashboard
  170. // as a dependency to provider-views.
  171. this.plugin.uppy.off('dashboard:close-panel', cancelRequest)
  172. this.plugin.uppy.off('cancel-all', cancelRequest)
  173. this.#abortController = undefined
  174. }
  175. }
  176. async openFolder(folderId: string | null): Promise<void> {
  177. this.lastCheckbox = null
  178. // Returning cached folder
  179. const { partialTree } = this.plugin.getPluginState()
  180. const clickedFolder = partialTree.find(
  181. (folder) => folder.id === folderId,
  182. )! as PartialTreeFolder
  183. if (clickedFolder.cached) {
  184. this.plugin.setPluginState({
  185. currentFolderId: folderId,
  186. searchString: '',
  187. })
  188. return
  189. }
  190. this.setLoading(true)
  191. await this.#withAbort(async (signal) => {
  192. let currentPagePath = folderId
  193. let currentItems: CompanionFile[] = []
  194. do {
  195. const { username, nextPagePath, items } = await this.provider.list(
  196. currentPagePath,
  197. { signal },
  198. )
  199. // It's important to set the username during one of our first fetches
  200. this.plugin.setPluginState({ username })
  201. currentPagePath = nextPagePath
  202. currentItems = currentItems.concat(items)
  203. this.setLoading(
  204. this.plugin.uppy.i18n('loadedXFiles', {
  205. numFiles: currentItems.length,
  206. }),
  207. )
  208. } while (this.opts.loadAllFiles && currentPagePath)
  209. const newPartialTree = PartialTreeUtils.afterOpenFolder(
  210. partialTree,
  211. currentItems,
  212. clickedFolder,
  213. currentPagePath,
  214. this.validateSingleFile,
  215. )
  216. this.plugin.setPluginState({
  217. partialTree: newPartialTree,
  218. currentFolderId: folderId,
  219. searchString: '',
  220. })
  221. }).catch(handleError(this.plugin.uppy))
  222. this.setLoading(false)
  223. }
  224. /**
  225. * Removes session token on client side.
  226. */
  227. async logout(): Promise<void> {
  228. await this.#withAbort(async (signal) => {
  229. const res = await this.provider.logout<{
  230. ok: boolean
  231. revoked: boolean
  232. manual_revoke_url: string
  233. }>({
  234. signal,
  235. })
  236. // res.ok is from the JSON body, not to be confused with Response.ok
  237. if (res.ok) {
  238. if (!res.revoked) {
  239. const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
  240. provider: this.plugin.title,
  241. url: res.manual_revoke_url,
  242. })
  243. this.plugin.uppy.info(message, 'info', 7000)
  244. }
  245. this.plugin.setPluginState({
  246. ...getDefaultState(this.plugin.rootFolderId),
  247. authenticated: false,
  248. })
  249. }
  250. }).catch(handleError(this.plugin.uppy))
  251. }
  252. async handleAuth(authFormData?: unknown): Promise<void> {
  253. await this.#withAbort(async (signal) => {
  254. this.setLoading(true)
  255. await this.provider.login({ authFormData, signal })
  256. this.plugin.setPluginState({ authenticated: true })
  257. await Promise.all([
  258. this.provider.fetchPreAuthToken(),
  259. this.openFolder(this.plugin.rootFolderId),
  260. ])
  261. }).catch(handleError(this.plugin.uppy))
  262. this.setLoading(false)
  263. }
  264. async handleScroll(event: Event): Promise<void> {
  265. const { partialTree, currentFolderId } = this.plugin.getPluginState()
  266. const currentFolder = partialTree.find(
  267. (i) => i.id === currentFolderId,
  268. ) as PartialTreeFolder
  269. if (
  270. shouldHandleScroll(event) &&
  271. !this.isHandlingScroll &&
  272. currentFolder.nextPagePath
  273. ) {
  274. this.isHandlingScroll = true
  275. await this.#withAbort(async (signal) => {
  276. const { nextPagePath, items } = await this.provider.list(
  277. currentFolder.nextPagePath,
  278. { signal },
  279. )
  280. const newPartialTree = PartialTreeUtils.afterScrollFolder(
  281. partialTree,
  282. currentFolderId,
  283. items,
  284. nextPagePath,
  285. this.validateSingleFile,
  286. )
  287. this.plugin.setPluginState({ partialTree: newPartialTree })
  288. }).catch(handleError(this.plugin.uppy))
  289. this.isHandlingScroll = false
  290. }
  291. }
  292. validateSingleFile = (file: CompanionFile): string | null => {
  293. const companionFile: ValidateableFile<M, B> = remoteFileObjToLocal(file)
  294. const result = this.plugin.uppy.validateSingleFile(companionFile)
  295. return result
  296. }
  297. async donePicking(): Promise<void> {
  298. const { partialTree } = this.plugin.getPluginState()
  299. this.setLoading(true)
  300. await this.#withAbort(async (signal) => {
  301. // 1. Enrich our partialTree by fetching all 'checked' but not-yet-fetched folders
  302. const enrichedTree: PartialTree = await PartialTreeUtils.afterFill(
  303. partialTree,
  304. (path: PartialTreeId) => this.provider.list(path, { signal }),
  305. this.validateSingleFile,
  306. (n) => {
  307. this.setLoading(
  308. this.plugin.uppy.i18n('addedNumFiles', { numFiles: n }),
  309. )
  310. },
  311. )
  312. // 2. Now that we know how many files there are - recheck aggregateRestrictions!
  313. const aggregateRestrictionError =
  314. this.validateAggregateRestrictions(enrichedTree)
  315. if (aggregateRestrictionError) {
  316. this.plugin.setPluginState({ partialTree: enrichedTree })
  317. return
  318. }
  319. // 3. Add files
  320. const companionFiles = getCheckedFilesWithPaths(enrichedTree)
  321. addFiles(companionFiles, this.plugin, this.provider)
  322. // 4. Reset state
  323. this.resetPluginState()
  324. }).catch(handleError(this.plugin.uppy))
  325. this.setLoading(false)
  326. }
  327. toggleCheckbox(
  328. ourItem: PartialTreeFolderNode | PartialTreeFile,
  329. isShiftKeyPressed: boolean,
  330. ) {
  331. const { partialTree } = this.plugin.getPluginState()
  332. const clickedRange = getClickedRange(
  333. ourItem.id,
  334. this.getDisplayedPartialTree(),
  335. isShiftKeyPressed,
  336. this.lastCheckbox,
  337. )
  338. const newPartialTree = PartialTreeUtils.afterToggleCheckbox(
  339. partialTree,
  340. clickedRange,
  341. )
  342. this.plugin.setPluginState({ partialTree: newPartialTree })
  343. this.lastCheckbox = ourItem.id
  344. }
  345. getDisplayedPartialTree = (): (PartialTreeFile | PartialTreeFolderNode)[] => {
  346. const { partialTree, currentFolderId, searchString } =
  347. this.plugin.getPluginState()
  348. const inThisFolder = partialTree.filter(
  349. (item) => item.type !== 'root' && item.parentId === currentFolderId,
  350. ) as (PartialTreeFile | PartialTreeFolderNode)[]
  351. const filtered =
  352. searchString === '' ? inThisFolder : (
  353. inThisFolder.filter(
  354. (item) =>
  355. (item.data.name ?? this.plugin.uppy.i18n('unnamed'))
  356. .toLowerCase()
  357. .indexOf(searchString.toLowerCase()) !== -1,
  358. )
  359. )
  360. return filtered
  361. }
  362. validateAggregateRestrictions = (partialTree: PartialTree) => {
  363. const checkedFiles = partialTree.filter(
  364. (item) => item.type === 'file' && item.status === 'checked',
  365. ) as PartialTreeFile[]
  366. const uppyFiles = checkedFiles.map((file) => file.data)
  367. return this.plugin.uppy.validateAggregateRestrictions(uppyFiles)
  368. }
  369. render(state: unknown, viewOptions: RenderOpts<M, B> = {}): h.JSX.Element {
  370. const { didFirstRender } = this.plugin.getPluginState()
  371. const { i18n } = this.plugin.uppy
  372. if (!didFirstRender) {
  373. this.plugin.setPluginState({ didFirstRender: true })
  374. this.provider.fetchPreAuthToken()
  375. this.openFolder(this.plugin.rootFolderId)
  376. }
  377. const opts: Opts<M, B> = { ...this.opts, ...viewOptions }
  378. const { authenticated, loading } = this.plugin.getPluginState()
  379. const pluginIcon = this.plugin.icon || defaultPickerIcon
  380. if (authenticated === false) {
  381. return (
  382. <AuthView
  383. pluginName={this.plugin.title}
  384. pluginIcon={pluginIcon}
  385. handleAuth={this.handleAuth}
  386. i18n={this.plugin.uppy.i18n}
  387. renderForm={opts.renderAuthForm}
  388. loading={loading}
  389. />
  390. )
  391. }
  392. const { partialTree, currentFolderId, username, searchString } =
  393. this.plugin.getPluginState()
  394. const breadcrumbs = getBreadcrumbs(partialTree, currentFolderId)
  395. return (
  396. <div
  397. className={classNames(
  398. 'uppy-ProviderBrowser',
  399. `uppy-ProviderBrowser-viewType--${opts.viewType}`,
  400. )}
  401. >
  402. <Header<M, B>
  403. showBreadcrumbs={opts.showBreadcrumbs}
  404. openFolder={this.openFolder}
  405. breadcrumbs={breadcrumbs}
  406. pluginIcon={pluginIcon}
  407. title={this.plugin.title}
  408. logout={this.logout}
  409. username={username}
  410. i18n={i18n}
  411. />
  412. {opts.showFilter && (
  413. <SearchInput
  414. searchString={searchString}
  415. setSearchString={(s: string) => {
  416. this.plugin.setPluginState({ searchString: s })
  417. }}
  418. submitSearchString={() => {}}
  419. inputLabel={i18n('filter')}
  420. clearSearchLabel={i18n('resetFilter')}
  421. wrapperClassName="uppy-ProviderBrowser-searchFilter"
  422. inputClassName="uppy-ProviderBrowser-searchFilterInput"
  423. />
  424. )}
  425. <Browser<M, B>
  426. toggleCheckbox={this.toggleCheckbox}
  427. displayedPartialTree={this.getDisplayedPartialTree()}
  428. openFolder={this.openFolder}
  429. virtualList={opts.virtualList}
  430. noResultsLabel={i18n('noFilesFound')}
  431. handleScroll={this.handleScroll}
  432. viewType={opts.viewType}
  433. showTitles={opts.showTitles}
  434. i18n={this.plugin.uppy.i18n}
  435. isLoading={loading}
  436. />
  437. <FooterActions
  438. partialTree={partialTree}
  439. donePicking={this.donePicking}
  440. cancelSelection={this.cancelSelection}
  441. i18n={i18n}
  442. validateAggregateRestrictions={this.validateAggregateRestrictions}
  443. />
  444. </div>
  445. )
  446. }
  447. }