123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import { h } from 'preact'
- import type {
- UnknownProviderPlugin,
- PartialTreeFolder,
- PartialTreeFolderNode,
- PartialTreeFile,
- UnknownProviderPluginState,
- PartialTreeId,
- PartialTree,
- } from '@uppy/core/lib/Uppy.js'
- import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
- import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
- import classNames from 'classnames'
- import type { ValidateableFile } from '@uppy/core/lib/Restricter.js'
- import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
- import type { I18n } from '@uppy/utils/lib/Translator'
- import AuthView from './AuthView.tsx'
- import Header from './Header.tsx'
- import Browser from '../Browser.tsx'
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore We don't want TS to generate types for the package.json
- import packageJson from '../../package.json'
- import PartialTreeUtils from '../utils/PartialTreeUtils/index.ts'
- import shouldHandleScroll from '../utils/shouldHandleScroll.ts'
- import handleError from '../utils/handleError.ts'
- import getClickedRange from '../utils/getClickedRange.ts'
- import SearchInput from '../SearchInput.tsx'
- import FooterActions from '../FooterActions.tsx'
- import addFiles from '../utils/addFiles.ts'
- import getCheckedFilesWithPaths from '../utils/PartialTreeUtils/getCheckedFilesWithPaths.ts'
- import getBreadcrumbs from '../utils/PartialTreeUtils/getBreadcrumbs.ts'
- export function defaultPickerIcon(): h.JSX.Element {
- return (
- <svg
- aria-hidden="true"
- focusable="false"
- width="30"
- height="30"
- viewBox="0 0 30 30"
- >
- <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" />
- </svg>
- )
- }
- const getDefaultState = (
- rootFolderId: string | null,
- ): UnknownProviderPluginState => ({
- authenticated: undefined, // we don't know yet
- partialTree: [
- {
- type: 'root',
- id: rootFolderId,
- cached: false,
- nextPagePath: null,
- },
- ],
- currentFolderId: rootFolderId,
- searchString: '',
- didFirstRender: false,
- username: null,
- loading: false,
- })
- type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
- export interface Opts<M extends Meta, B extends Body> {
- provider: UnknownProviderPlugin<M, B>['provider']
- viewType: 'list' | 'grid'
- showTitles: boolean
- showFilter: boolean
- showBreadcrumbs: boolean
- loadAllFiles: boolean
- renderAuthForm?: (args: {
- pluginName: string
- i18n: I18n
- loading: boolean | string
- onAuth: (authFormData: unknown) => Promise<void>
- }) => h.JSX.Element
- virtualList: boolean
- }
- type PassedOpts<M extends Meta, B extends Body> = Optional<
- Opts<M, B>,
- | 'viewType'
- | 'showTitles'
- | 'showFilter'
- | 'showBreadcrumbs'
- | 'loadAllFiles'
- | 'virtualList'
- >
- type DefaultOpts<M extends Meta, B extends Body> = Omit<Opts<M, B>, 'provider'>
- type RenderOpts<M extends Meta, B extends Body> = Omit<
- PassedOpts<M, B>,
- 'provider'
- >
- /**
- * Class to easily generate generic views for Provider plugins
- */
- export default class ProviderView<M extends Meta, B extends Body> {
- static VERSION = packageJson.version
- plugin: UnknownProviderPlugin<M, B>
- provider: UnknownProviderPlugin<M, B>['provider']
- opts: Opts<M, B>
- isHandlingScroll: boolean = false
- lastCheckbox: string | null = null
- constructor(plugin: UnknownProviderPlugin<M, B>, opts: PassedOpts<M, B>) {
- this.plugin = plugin
- this.provider = opts.provider
- const defaultOptions: DefaultOpts<M, B> = {
- viewType: 'list',
- showTitles: true,
- showFilter: true,
- showBreadcrumbs: true,
- loadAllFiles: false,
- virtualList: false,
- }
- this.opts = { ...defaultOptions, ...opts }
- this.openFolder = this.openFolder.bind(this)
- this.logout = this.logout.bind(this)
- this.handleAuth = this.handleAuth.bind(this)
- this.handleScroll = this.handleScroll.bind(this)
- this.resetPluginState = this.resetPluginState.bind(this)
- this.donePicking = this.donePicking.bind(this)
- this.render = this.render.bind(this)
- this.cancelSelection = this.cancelSelection.bind(this)
- this.toggleCheckbox = this.toggleCheckbox.bind(this)
- // Set default state for the plugin
- this.resetPluginState()
- // todo
- // @ts-expect-error this should be typed in @uppy/dashboard.
- this.plugin.uppy.on('dashboard:close-panel', this.resetPluginState)
- this.plugin.uppy.registerRequestClient(
- this.provider.provider,
- this.provider,
- )
- }
- resetPluginState(): void {
- this.plugin.setPluginState(getDefaultState(this.plugin.rootFolderId))
- }
- // eslint-disable-next-line class-methods-use-this
- tearDown(): void {
- // Nothing.
- }
- setLoading(loading: boolean | string): void {
- this.plugin.setPluginState({ loading })
- }
- cancelSelection(): void {
- const { partialTree } = this.plugin.getPluginState()
- const newPartialTree: PartialTree = partialTree.map((item) =>
- item.type === 'root' ? item : { ...item, status: 'unchecked' },
- )
- this.plugin.setPluginState({ partialTree: newPartialTree })
- }
- #abortController: AbortController | undefined
- async #withAbort(op: (signal: AbortSignal) => Promise<void>) {
- // prevent multiple requests in parallel from causing race conditions
- this.#abortController?.abort()
- const abortController = new AbortController()
- this.#abortController = abortController
- const cancelRequest = () => {
- abortController.abort()
- }
- try {
- // @ts-expect-error this should be typed in @uppy/dashboard.
- // Even then I don't think we can make this work without adding dashboard
- // as a dependency to provider-views.
- this.plugin.uppy.on('dashboard:close-panel', cancelRequest)
- this.plugin.uppy.on('cancel-all', cancelRequest)
- await op(abortController.signal)
- } finally {
- // @ts-expect-error this should be typed in @uppy/dashboard.
- // Even then I don't think we can make this work without adding dashboard
- // as a dependency to provider-views.
- this.plugin.uppy.off('dashboard:close-panel', cancelRequest)
- this.plugin.uppy.off('cancel-all', cancelRequest)
- this.#abortController = undefined
- }
- }
- async openFolder(folderId: string | null): Promise<void> {
- this.lastCheckbox = null
- // Returning cached folder
- const { partialTree } = this.plugin.getPluginState()
- const clickedFolder = partialTree.find(
- (folder) => folder.id === folderId,
- )! as PartialTreeFolder
- if (clickedFolder.cached) {
- this.plugin.setPluginState({
- currentFolderId: folderId,
- searchString: '',
- })
- return
- }
- this.setLoading(true)
- await this.#withAbort(async (signal) => {
- let currentPagePath = folderId
- let currentItems: CompanionFile[] = []
- do {
- const { username, nextPagePath, items } = await this.provider.list(
- currentPagePath,
- { signal },
- )
- // It's important to set the username during one of our first fetches
- this.plugin.setPluginState({ username })
- currentPagePath = nextPagePath
- currentItems = currentItems.concat(items)
- this.setLoading(
- this.plugin.uppy.i18n('loadedXFiles', {
- numFiles: currentItems.length,
- }),
- )
- } while (this.opts.loadAllFiles && currentPagePath)
- const newPartialTree = PartialTreeUtils.afterOpenFolder(
- partialTree,
- currentItems,
- clickedFolder,
- currentPagePath,
- this.validateSingleFile,
- )
- this.plugin.setPluginState({
- partialTree: newPartialTree,
- currentFolderId: folderId,
- searchString: '',
- })
- }).catch(handleError(this.plugin.uppy))
- this.setLoading(false)
- }
- /**
- * Removes session token on client side.
- */
- async logout(): Promise<void> {
- await this.#withAbort(async (signal) => {
- const res = await this.provider.logout<{
- ok: boolean
- revoked: boolean
- manual_revoke_url: string
- }>({
- signal,
- })
- // res.ok is from the JSON body, not to be confused with Response.ok
- 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)
- }
- this.plugin.setPluginState({
- ...getDefaultState(this.plugin.rootFolderId),
- authenticated: false,
- })
- }
- }).catch(handleError(this.plugin.uppy))
- }
- async handleAuth(authFormData?: unknown): Promise<void> {
- await this.#withAbort(async (signal) => {
- this.setLoading(true)
- await this.provider.login({ authFormData, signal })
- this.plugin.setPluginState({ authenticated: true })
- await Promise.all([
- this.provider.fetchPreAuthToken(),
- this.openFolder(this.plugin.rootFolderId),
- ])
- }).catch(handleError(this.plugin.uppy))
- this.setLoading(false)
- }
- async handleScroll(event: Event): Promise<void> {
- const { partialTree, currentFolderId } = this.plugin.getPluginState()
- const currentFolder = partialTree.find(
- (i) => i.id === currentFolderId,
- ) as PartialTreeFolder
- if (
- shouldHandleScroll(event) &&
- !this.isHandlingScroll &&
- currentFolder.nextPagePath
- ) {
- this.isHandlingScroll = true
- await this.#withAbort(async (signal) => {
- const { nextPagePath, items } = await this.provider.list(
- currentFolder.nextPagePath,
- { signal },
- )
- const newPartialTree = PartialTreeUtils.afterScrollFolder(
- partialTree,
- currentFolderId,
- items,
- nextPagePath,
- this.validateSingleFile,
- )
- this.plugin.setPluginState({ partialTree: newPartialTree })
- }).catch(handleError(this.plugin.uppy))
- this.isHandlingScroll = false
- }
- }
- validateSingleFile = (file: CompanionFile): string | null => {
- const companionFile: ValidateableFile<M, B> = remoteFileObjToLocal(file)
- const result = this.plugin.uppy.validateSingleFile(companionFile)
- return result
- }
- async donePicking(): Promise<void> {
- const { partialTree } = this.plugin.getPluginState()
- this.setLoading(true)
- await this.#withAbort(async (signal) => {
- // 1. Enrich our partialTree by fetching all 'checked' but not-yet-fetched folders
- const enrichedTree: PartialTree = await PartialTreeUtils.afterFill(
- partialTree,
- (path: PartialTreeId) => this.provider.list(path, { signal }),
- this.validateSingleFile,
- (n) => {
- this.setLoading(
- this.plugin.uppy.i18n('addedNumFiles', { numFiles: n }),
- )
- },
- )
- // 2. Now that we know how many files there are - recheck aggregateRestrictions!
- const aggregateRestrictionError =
- this.validateAggregateRestrictions(enrichedTree)
- if (aggregateRestrictionError) {
- this.plugin.setPluginState({ partialTree: enrichedTree })
- return
- }
- // 3. Add files
- const companionFiles = getCheckedFilesWithPaths(enrichedTree)
- addFiles(companionFiles, this.plugin, this.provider)
- // 4. Reset state
- this.resetPluginState()
- }).catch(handleError(this.plugin.uppy))
- this.setLoading(false)
- }
- toggleCheckbox(
- ourItem: PartialTreeFolderNode | PartialTreeFile,
- isShiftKeyPressed: boolean,
- ) {
- const { partialTree } = this.plugin.getPluginState()
- const clickedRange = getClickedRange(
- ourItem.id,
- this.getDisplayedPartialTree(),
- isShiftKeyPressed,
- this.lastCheckbox,
- )
- const newPartialTree = PartialTreeUtils.afterToggleCheckbox(
- partialTree,
- clickedRange,
- )
- this.plugin.setPluginState({ partialTree: newPartialTree })
- this.lastCheckbox = ourItem.id
- }
- getDisplayedPartialTree = (): (PartialTreeFile | PartialTreeFolderNode)[] => {
- const { partialTree, currentFolderId, searchString } =
- this.plugin.getPluginState()
- const inThisFolder = partialTree.filter(
- (item) => item.type !== 'root' && item.parentId === currentFolderId,
- ) as (PartialTreeFile | PartialTreeFolderNode)[]
- const filtered =
- searchString === '' ? inThisFolder : (
- inThisFolder.filter(
- (item) =>
- (item.data.name ?? this.plugin.uppy.i18n('unnamed'))
- .toLowerCase()
- .indexOf(searchString.toLowerCase()) !== -1,
- )
- )
- return filtered
- }
- validateAggregateRestrictions = (partialTree: PartialTree) => {
- const checkedFiles = partialTree.filter(
- (item) => item.type === 'file' && item.status === 'checked',
- ) as PartialTreeFile[]
- const uppyFiles = checkedFiles.map((file) => file.data)
- return this.plugin.uppy.validateAggregateRestrictions(uppyFiles)
- }
- render(state: unknown, viewOptions: RenderOpts<M, B> = {}): h.JSX.Element {
- const { didFirstRender } = this.plugin.getPluginState()
- const { i18n } = this.plugin.uppy
- if (!didFirstRender) {
- this.plugin.setPluginState({ didFirstRender: true })
- this.provider.fetchPreAuthToken()
- this.openFolder(this.plugin.rootFolderId)
- }
- const opts: Opts<M, B> = { ...this.opts, ...viewOptions }
- const { authenticated, loading } = this.plugin.getPluginState()
- const pluginIcon = this.plugin.icon || defaultPickerIcon
- if (authenticated === false) {
- return (
- <AuthView
- pluginName={this.plugin.title}
- pluginIcon={pluginIcon}
- handleAuth={this.handleAuth}
- i18n={this.plugin.uppy.i18n}
- renderForm={opts.renderAuthForm}
- loading={loading}
- />
- )
- }
- const { partialTree, currentFolderId, username, searchString } =
- this.plugin.getPluginState()
- const breadcrumbs = getBreadcrumbs(partialTree, currentFolderId)
- return (
- <div
- className={classNames(
- 'uppy-ProviderBrowser',
- `uppy-ProviderBrowser-viewType--${opts.viewType}`,
- )}
- >
- <Header<M, B>
- showBreadcrumbs={opts.showBreadcrumbs}
- openFolder={this.openFolder}
- breadcrumbs={breadcrumbs}
- pluginIcon={pluginIcon}
- title={this.plugin.title}
- logout={this.logout}
- username={username}
- i18n={i18n}
- />
- {opts.showFilter && (
- <SearchInput
- searchString={searchString}
- setSearchString={(s: string) => {
- this.plugin.setPluginState({ searchString: s })
- }}
- submitSearchString={() => {}}
- inputLabel={i18n('filter')}
- clearSearchLabel={i18n('resetFilter')}
- wrapperClassName="uppy-ProviderBrowser-searchFilter"
- inputClassName="uppy-ProviderBrowser-searchFilterInput"
- />
- )}
- <Browser<M, B>
- toggleCheckbox={this.toggleCheckbox}
- displayedPartialTree={this.getDisplayedPartialTree()}
- openFolder={this.openFolder}
- virtualList={opts.virtualList}
- noResultsLabel={i18n('noFilesFound')}
- handleScroll={this.handleScroll}
- viewType={opts.viewType}
- showTitles={opts.showTitles}
- i18n={this.plugin.uppy.i18n}
- isLoading={loading}
- />
- <FooterActions
- partialTree={partialTree}
- donePicking={this.donePicking}
- cancelSelection={this.cancelSelection}
- i18n={i18n}
- validateAggregateRestrictions={this.validateAggregateRestrictions}
- />
- </div>
- )
- }
- }
|