diff --git a/packages/@uppy/companion-client/src/Provider.ts b/packages/@uppy/companion-client/src/Provider.ts index d252c97d12..b8e12dc270 100644 --- a/packages/@uppy/companion-client/src/Provider.ts +++ b/packages/@uppy/companion-client/src/Provider.ts @@ -366,7 +366,7 @@ export default class Provider } list( - directory: string | undefined, + directory: string | null, options: RequestOptions, ): Promise { return this.get(`${this.id}/list/${directory || ''}`, options) diff --git a/packages/@uppy/core/src/Uppy.test.ts b/packages/@uppy/core/src/Uppy.test.ts index b3315fe2c2..25f3736117 100644 --- a/packages/@uppy/core/src/Uppy.test.ts +++ b/packages/@uppy/core/src/Uppy.test.ts @@ -2160,7 +2160,7 @@ describe('src/Core', () => { ) }) - it('should check if a file validateRestrictions', () => { + it('should report error on validateSingleFile', () => { const core = new Core({ restrictions: { minFileSize: 300000, @@ -2185,17 +2185,13 @@ describe('src/Core', () => { size: 270733, } - // @ts-ignore - const validateRestrictions1 = core.validateRestrictions(newFile) - // @ts-ignore - const validateRestrictions2 = core2.validateRestrictions(newFile) + const validateRestrictions1 = core.validateSingleFile(newFile) + const validateRestrictions2 = core2.validateSingleFile(newFile) - expect(validateRestrictions1!.message).toEqual( + expect(validateRestrictions1).toEqual( 'This file is smaller than the allowed size of 293 KB', ) - expect(validateRestrictions2!.message).toEqual( - 'You can only upload: image/png', - ) + expect(validateRestrictions2).toEqual('You can only upload: image/png') }) it('should emit `restriction-failed` event when some rule is violated', () => { diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index ae297d6abe..52d05d8c52 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -58,23 +58,86 @@ export type UnknownPlugin< PluginState extends Record = Record, > = BasePlugin -// `OmitFirstArg` is the type of the returned value of `someArray.slice(1)`. -type OmitFirstArg = T extends [any, ...infer U] ? U : never +/** + * ids are always `string`s, except the root folder's id can be `null` + */ +export type PartialTreeId = string | null + +export type PartialTreeStatusFile = 'checked' | 'unchecked' +export type PartialTreeStatus = PartialTreeStatusFile | 'partial' + +export type PartialTreeFile = { + type: 'file' + id: string + + /** + * There exist two types of restrictions: + * - individual restrictions (`allowedFileTypes`, `minFileSize`, `maxFileSize`), and + * - aggregate restrictions (`maxNumberOfFiles`, `maxTotalFileSize`). + * + * `.restrictionError` reports whether this file passes individual restrictions. + * + */ + restrictionError: string | null + + status: PartialTreeStatusFile + parentId: PartialTreeId + data: CompanionFile +} + +export type PartialTreeFolderNode = { + type: 'folder' + id: string + + /** + * Consider `(.nextPagePath, .cached)` a composite key that can represent 4 states: + * - `{ cached: true, nextPagePath: null }` - we fetched all pages in this folder + * - `{ cached: true, nextPagePath: 'smth' }` - we fetched 1st page, and there are still pages left to fetch in this folder + * - `{ cached: false, nextPagePath: null }` - we didn't fetch the 1st page in this folder + * - `{ cached: false, nextPagePath: 'someString' }` - ❌ CAN'T HAPPEN ❌ + */ + cached: boolean + nextPagePath: PartialTreeId + + status: PartialTreeStatus + parentId: PartialTreeId + data: CompanionFile +} + +export type PartialTreeFolderRoot = { + type: 'root' + id: PartialTreeId + + cached: boolean + nextPagePath: PartialTreeId +} + +export type PartialTreeFolder = PartialTreeFolderNode | PartialTreeFolderRoot + +/** + * PartialTree has the following structure. + * + * FolderRoot + * ┌─────┴─────┐ + * FolderNode File + * ┌─────┴────┐ + * File File + * + * Root folder is called `PartialTreeFolderRoot`, + * all other folders are called `PartialTreeFolderNode`, because they are "internal nodes". + * + * It's possible for `PartialTreeFolderNode` to be a leaf node if it doesn't contain any files. + */ +export type PartialTree = (PartialTreeFile | PartialTreeFolder)[] export type UnknownProviderPluginState = { authenticated: boolean | undefined - breadcrumbs: { - requestPath?: string - name?: string - id?: string - }[] didFirstRender: boolean - currentSelection: CompanionFile[] - filterInput: string + searchString: string loading: boolean | string - folders: CompanionFile[] - files: CompanionFile[] - isSearchVisible: boolean + partialTree: PartialTree + currentFolderId: PartialTreeId + username: string | null } /* * UnknownProviderPlugin can be any Companion plugin (such as Google Drive). @@ -89,8 +152,8 @@ export type UnknownProviderPlugin< M extends Meta, B extends Body, > = UnknownPlugin & { - rootFolderId: string | null title: string + rootFolderId: string | null files: UppyFile[] icon: () => h.JSX.Element provider: CompanionClientProvider @@ -111,16 +174,10 @@ export type UnknownProviderPlugin< * `SearchProvider` does operate on Companion plugins with `uppy.getPlugin()`. */ export type UnknownSearchProviderPluginState = { - isInputMode?: boolean - searchTerm?: string | null + isInputMode: boolean } & Pick< UnknownProviderPluginState, - | 'loading' - | 'files' - | 'folders' - | 'currentSelection' - | 'filterInput' - | 'didFirstRender' + 'loading' | 'searchString' | 'partialTree' | 'currentFolderId' > export type UnknownSearchProviderPlugin< M extends Meta, @@ -296,6 +353,9 @@ export interface UppyEventMap 'upload-start': (files: UppyFile[]) => void } +/** `OmitFirstArg` is the type of the returned value of `someArray.slice(1)`. */ +type OmitFirstArg = T extends [any, ...infer U] ? U : never + const defaultUploadState = { totalProgress: 0, allowNewUpload: true, @@ -780,14 +840,23 @@ export class Uppy> { } } - validateRestrictions( - file: ValidateableFile, - files: ValidateableFile[] = this.getFiles(), - ): RestrictionError | null { + validateSingleFile(file: ValidateableFile): string | null { + try { + this.#restricter.validateSingleFile(file) + } catch (err) { + return err.message + } + return null + } + + validateAggregateRestrictions( + files: ValidateableFile[], + ): string | null { + const existingFiles = this.getFiles() try { - this.#restricter.validate(files, [file]) + this.#restricter.validateAggregateRestrictions(existingFiles, files) } catch (err) { - return err as any + return err.message } return null } diff --git a/packages/@uppy/core/src/_common.scss b/packages/@uppy/core/src/_common.scss index 1dcea0cc3f..ac88cdc196 100644 --- a/packages/@uppy/core/src/_common.scss +++ b/packages/@uppy/core/src/_common.scss @@ -127,7 +127,7 @@ background-color: $blue; border-radius: 4px; - &:hover { + &:not(:disabled):hover { background-color: darken($blue, 10%); } @@ -145,6 +145,10 @@ @include blue-border-focus--dark; } + + &.uppy-c-btn--disabled { + background-color: rgb(142, 178, 219); + } } .uppy-c-btn-link { diff --git a/packages/@uppy/facebook/src/Facebook.tsx b/packages/@uppy/facebook/src/Facebook.tsx index f81c66c595..b1b8158073 100644 --- a/packages/@uppy/facebook/src/Facebook.tsx +++ b/packages/@uppy/facebook/src/Facebook.tsx @@ -103,19 +103,19 @@ export default class Facebook extends UIPlugin< } render(state: unknown): ComponentChild { - const viewOptions: { - viewType?: string - showFilter?: boolean - showTitles?: boolean - } = {} - if ( - this.getPluginState().files.length && - !this.getPluginState().folders.length - ) { - viewOptions.viewType = 'grid' - viewOptions.showFilter = false - viewOptions.showTitles = false + const { partialTree, currentFolderId } = this.getPluginState() + + const foldersInThisFolder = partialTree.filter( + (i) => i.type === 'folder' && i.parentId === currentFolderId, + ) + + if (foldersInThisFolder.length === 0) { + return this.view.render(state, { + viewType: 'grid', + showFilter: false, + showTitles: false, + }) } - return this.view.render(state, viewOptions) + return this.view.render(state) } } diff --git a/packages/@uppy/google-drive/src/DriveProviderViews.ts b/packages/@uppy/google-drive/src/DriveProviderViews.ts index 7ccbe80920..fe0bfb8e3a 100644 --- a/packages/@uppy/google-drive/src/DriveProviderViews.ts +++ b/packages/@uppy/google-drive/src/DriveProviderViews.ts @@ -1,18 +1,22 @@ +import type { + PartialTreeFile, + PartialTreeFolderNode, +} from '@uppy/core/lib/Uppy' import { ProviderViews } from '@uppy/provider-views' -import type { CompanionFile } from '@uppy/utils/lib/CompanionFile' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' export default class DriveProviderViews< M extends Meta, B extends Body, > extends ProviderViews { - toggleCheckbox(e: Event, file: CompanionFile): void { - e.stopPropagation() - e.preventDefault() - - // Shared Drives aren't selectable; for all else, defer to the base ProviderView. - if (!file.custom!.isSharedDrive) { - super.toggleCheckbox(e, file) + toggleCheckbox( + item: PartialTreeFolderNode | PartialTreeFile, + isShiftKeyPressed: boolean, + ): void { + // We don't allow to check team drives; but we leave the checkboxes visible to show the 'partial' state + // (For a full explanation, see https://github.com/transloadit/uppy/issues/5232) + if (!item.data.custom?.isSharedDrive) { + super.toggleCheckbox(item, isShiftKeyPressed) } } } diff --git a/packages/@uppy/google-photos/src/GooglePhotos.tsx b/packages/@uppy/google-photos/src/GooglePhotos.tsx index 85c45f37a6..81df409d6c 100644 --- a/packages/@uppy/google-photos/src/GooglePhotos.tsx +++ b/packages/@uppy/google-photos/src/GooglePhotos.tsx @@ -114,10 +114,13 @@ export default class GooglePhotos< } render(state: unknown): ComponentChild { - if ( - this.getPluginState().files.length && - !this.getPluginState().folders.length - ) { + const { partialTree, currentFolderId } = this.getPluginState() + + const foldersInThisFolder = partialTree.filter( + (i) => i.type === 'folder' && i.parentId === currentFolderId, + ) + + if (foldersInThisFolder.length === 0) { return this.view.render(state, { viewType: 'grid', showFilter: false, diff --git a/packages/@uppy/provider-views/package.json b/packages/@uppy/provider-views/package.json index 964dbe22b2..e243b4b0e3 100644 --- a/packages/@uppy/provider-views/package.json +++ b/packages/@uppy/provider-views/package.json @@ -25,6 +25,9 @@ "p-queue": "^8.0.0", "preact": "^10.5.13" }, + "devDependencies": { + "vitest": "^1.6.0" + }, "peerDependencies": { "@uppy/core": "workspace:^" }, diff --git a/packages/@uppy/provider-views/src/Breadcrumbs.tsx b/packages/@uppy/provider-views/src/Breadcrumbs.tsx index 0312d42ec9..9e61083992 100644 --- a/packages/@uppy/provider-views/src/Breadcrumbs.tsx +++ b/packages/@uppy/provider-views/src/Breadcrumbs.tsx @@ -1,53 +1,35 @@ -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' +import type { PartialTreeFolder } from '@uppy/core/lib/Uppy' import { h, Fragment } from 'preact' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type ProviderView from './ProviderView/index.js' -type BreadcrumbProps = { - getFolder: () => void - title: string - isLast: boolean -} - -const Breadcrumb = (props: BreadcrumbProps) => { - const { getFolder, title, isLast } = props - - return ( - - - {!isLast ? ' / ' : ''} - - ) -} - type BreadcrumbsProps = { - getFolder: ProviderView['getFolder'] + openFolder: ProviderView['openFolder'] title: string breadcrumbsIcon: h.JSX.Element - breadcrumbs: UnknownProviderPluginState['breadcrumbs'] + breadcrumbs: PartialTreeFolder[] } export default function Breadcrumbs( props: BreadcrumbsProps, -) { - const { getFolder, title, breadcrumbsIcon, breadcrumbs } = props +): h.JSX.Element { + const { openFolder, title, breadcrumbsIcon, breadcrumbs } = props return (
{breadcrumbsIcon}
- {breadcrumbs.map((directory, i) => ( - getFolder(directory.requestPath, directory.name)} - title={i === 0 ? title : (directory.name as string)} - isLast={i + 1 === breadcrumbs.length} - /> + {breadcrumbs.map((folder, index) => ( + + + {breadcrumbs.length === index + 1 ? '' : ' / '} + ))}
) diff --git a/packages/@uppy/provider-views/src/Browser.tsx b/packages/@uppy/provider-views/src/Browser.tsx index c1ccf61b63..62e494a5cd 100644 --- a/packages/@uppy/provider-views/src/Browser.tsx +++ b/packages/@uppy/provider-views/src/Browser.tsx @@ -1,273 +1,120 @@ -/* eslint-disable react/require-default-props */ import { h } from 'preact' -import classNames from 'classnames' -import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal' -import { useMemo } from 'preact/hooks' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore untyped import VirtualList from '@uppy/utils/lib/VirtualList' -import type { CompanionFile } from '@uppy/utils/lib/CompanionFile' -import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type { I18n } from '@uppy/utils/lib/Translator' -import type Uppy from '@uppy/core' -import SearchFilterInput from './SearchFilterInput.tsx' -import FooterActions from './FooterActions.tsx' +import type { + PartialTreeFile, + PartialTreeFolderNode, +} from '@uppy/core/lib/Uppy.ts' +import { useEffect, useState } from 'preact/hooks' import Item from './Item/index.tsx' - -const VIRTUAL_SHARED_DIR = 'shared-with-me' - -type ListItemProps = { - currentSelection: any[] - uppyFiles: UppyFile[] - viewType: string - isChecked: (file: any) => boolean - toggleCheckbox: (event: Event, file: CompanionFile) => void - recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void - showTitles: boolean - i18n: I18n - validateRestrictions: Uppy['validateRestrictions'] - getNextFolder?: (folder: any) => void - f: CompanionFile -} - -function ListItem(props: ListItemProps) { - const { - currentSelection, - uppyFiles, - viewType, - isChecked, - toggleCheckbox, - recordShiftKeyPress, - showTitles, - i18n, - validateRestrictions, - getNextFolder, - f, - } = props - - if (f.isFolder) { - return Item({ - showTitles, - viewType, - i18n, - id: f.id, - title: f.name, - getItemIcon: () => f.icon, - isChecked: isChecked(f), - toggleCheckbox: (event: Event) => toggleCheckbox(event, f), - recordShiftKeyPress, - type: 'folder', - // TODO: when was this supposed to be true? - isDisabled: false, - isCheckboxDisabled: f.id === VIRTUAL_SHARED_DIR, - // getNextFolder always exists when f.isFolder is true - handleFolderClick: () => getNextFolder!(f), - }) - } - const restrictionError = validateRestrictions(remoteFileObjToLocal(f), [ - ...uppyFiles, - ...currentSelection, - ]) - - return Item({ - id: f.id, - title: f.name, - author: f.author, - getItemIcon: () => - viewType === 'grid' && f.thumbnail ? f.thumbnail : f.icon, - isChecked: isChecked(f), - toggleCheckbox: (event: Event) => toggleCheckbox(event, f), - isCheckboxDisabled: false, - recordShiftKeyPress, - showTitles, - viewType, - i18n, - type: 'file', - isDisabled: Boolean(restrictionError) && !isChecked(f), - restrictionError, - }) -} +import ProviderView from './ProviderView/ProviderView.tsx' type BrowserProps = { - currentSelection: any[] - folders: CompanionFile[] - files: CompanionFile[] - uppyFiles: UppyFile[] + displayedPartialTree: (PartialTreeFile | PartialTreeFolderNode)[] viewType: string - headerComponent?: h.JSX.Element - showBreadcrumbs: boolean - isChecked: (file: any) => boolean - toggleCheckbox: (event: Event, file: CompanionFile) => void - recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void - handleScroll: (event: Event) => Promise + toggleCheckbox: ProviderView['toggleCheckbox'] + handleScroll: ProviderView['handleScroll'] showTitles: boolean i18n: I18n - validateRestrictions: Uppy['validateRestrictions'] isLoading: boolean | string - showSearchFilter: boolean - search: (query: string) => void - searchTerm?: string | null - clearSearch: () => void - searchOnInput: boolean - searchInputLabel: string - clearSearchLabel: string - getNextFolder?: (folder: any) => void - cancel: () => void - done: () => void + openFolder: ProviderView['openFolder'] noResultsLabel: string - virtualList?: boolean + virtualList: boolean } function Browser(props: BrowserProps) { const { - currentSelection, - folders, - files, - uppyFiles, + displayedPartialTree, viewType, - headerComponent, - showBreadcrumbs, - isChecked, toggleCheckbox, - recordShiftKeyPress, handleScroll, showTitles, i18n, - validateRestrictions, isLoading, - showSearchFilter, - search, - searchTerm, - clearSearch, - searchOnInput, - searchInputLabel, - clearSearchLabel, - getNextFolder, - cancel, - done, + openFolder, noResultsLabel, virtualList, } = props - const selected = currentSelection.length + const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false) + + // This records whether the user is holding the SHIFT key this very moment. + // Typically, this is implemented using `onClick((e) => e.shiftKey)` - + // however we can't use that, because for accessibility reasons + // we're using html tags that don't support `e.shiftKey` property (see #3768). + useEffect(() => { + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') setIsShiftKeyPressed(false) + } + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') setIsShiftKeyPressed(true) + } + document.addEventListener('keyup', handleKeyUp) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keyup', handleKeyUp) + document.removeEventListener('keydown', handleKeyDown) + } + }, []) + + if (isLoading) { + return ( +
+ {i18n('loading')} +
+ ) + } - const rows = useMemo(() => [...folders, ...files], [folders, files]) + if (displayedPartialTree.length === 0) { + return
{noResultsLabel}
+ } - return ( -
- {headerComponent && ( -
-
- {headerComponent} -
-
- )} + const renderItem = (item: PartialTreeFile | PartialTreeFolderNode) => ( + { + event.stopPropagation() + event.preventDefault() + // Prevent shift-clicking from highlighting file names + // (https://stackoverflow.com/a/1527797/3192470) + document.getSelection()?.removeAllRanges() + toggleCheckbox(item, isShiftKeyPressed) + }} + showTitles={showTitles} + i18n={i18n} + openFolder={openFolder} + file={item} + /> + ) - {showSearchFilter && ( -
- +
    + -
- )} - - {(() => { - if (isLoading) { - return ( -
- - {typeof isLoading === 'string' ? isLoading : i18n('loading')} - -
- ) - } - - if (!folders.length && !files.length) { - return
{noResultsLabel}
- } - - if (virtualList) { - return ( -
-
    - ( - - )} - rowHeight={31} - /> -
-
- ) - } - - return ( -
-
    not focusable for firefox - tabIndex={-1} - > - {rows.map((f) => ( - - ))} -
-
- ) - })()} - - {selected > 0 && ( - - )} + +
+ ) + } + return ( +
+
    not focusable for firefox + tabIndex={-1} + > + {displayedPartialTree.map(renderItem)} +
) } diff --git a/packages/@uppy/provider-views/src/CloseWrapper.ts b/packages/@uppy/provider-views/src/CloseWrapper.ts deleted file mode 100644 index 14502e9b55..0000000000 --- a/packages/@uppy/provider-views/src/CloseWrapper.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Component, toChildArray } from 'preact' - -export default class CloseWrapper extends Component<{ onUnmount: () => void }> { - componentWillUnmount(): void { - const { onUnmount } = this.props - onUnmount() - } - - render(): ReturnType[0] { - const { children } = this.props - return toChildArray(children)[0] - } -} diff --git a/packages/@uppy/provider-views/src/FooterActions.tsx b/packages/@uppy/provider-views/src/FooterActions.tsx index 9e8bb49d4d..b297d5424f 100644 --- a/packages/@uppy/provider-views/src/FooterActions.tsx +++ b/packages/@uppy/provider-views/src/FooterActions.tsx @@ -1,35 +1,69 @@ import { h } from 'preact' import type { I18n } from '@uppy/utils/lib/Translator' +import type { Meta, Body } from '@uppy/utils/lib/UppyFile' +import classNames from 'classnames' +import type { PartialTree } from '@uppy/core/lib/Uppy' +import { useMemo } from 'preact/hooks' +import getNumberOfSelectedFiles from './utils/PartialTreeUtils/getNumberOfSelectedFiles.ts' +import ProviderView from './ProviderView/ProviderView.tsx' -export default function FooterActions({ - cancel, - done, +export default function FooterActions({ + cancelSelection, + donePicking, i18n, - selected, + partialTree, + validateAggregateRestrictions, }: { - cancel: () => void - done: () => void + cancelSelection: ProviderView['cancelSelection'] + donePicking: ProviderView['donePicking'] i18n: I18n - selected: number + partialTree: PartialTree + validateAggregateRestrictions: ProviderView< + M, + B + >['validateAggregateRestrictions'] }) { + const aggregateRestrictionError = useMemo(() => { + return validateAggregateRestrictions(partialTree) + }, [partialTree, validateAggregateRestrictions]) + + const nOfSelectedFiles = useMemo(() => { + return getNumberOfSelectedFiles(partialTree) + }, [partialTree]) + + if (nOfSelectedFiles === 0) { + return null + } + return (
- - +
+ + +
+ + {aggregateRestrictionError && ( +
+ {aggregateRestrictionError} +
+ )}
) } diff --git a/packages/@uppy/provider-views/src/Item/components/GridItem.tsx b/packages/@uppy/provider-views/src/Item/components/GridItem.tsx index 766e4d5508..9dd3cfb1d1 100644 --- a/packages/@uppy/provider-views/src/Item/components/GridItem.tsx +++ b/packages/@uppy/provider-views/src/Item/components/GridItem.tsx @@ -1,72 +1,51 @@ -/* eslint-disable react/require-default-props */ -import { h, type ComponentChildren } from 'preact' -import classNames from 'classnames' -import type { RestrictionError } from '@uppy/core/lib/Restricter' -import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import { h } from 'preact' +import type { + PartialTreeFile, + PartialTreeFolderNode, +} from '@uppy/core/lib/Uppy' +import ItemIcon from './ItemIcon.tsx' -type GridItemProps = { +type GridItemProps = { + file: PartialTreeFile | PartialTreeFolderNode + toggleCheckbox: (event: Event) => void className: string isDisabled: boolean - restrictionError?: RestrictionError | null - isChecked: boolean - title?: string - itemIconEl: any - showTitles?: boolean - toggleCheckbox: (event: Event) => void - recordShiftKeyPress: (event: KeyboardEvent) => void - id: string - children?: ComponentChildren + restrictionError: string | null + showTitles: boolean + children?: h.JSX.Element | null } -function GridItem( - props: GridItemProps, -): h.JSX.Element { - const { - className, - isDisabled, - restrictionError, - isChecked, - title, - itemIconEl, - showTitles, - toggleCheckbox, - recordShiftKeyPress, - id, - children, - } = props - - const checkBoxClassName = classNames( - 'uppy-u-reset', - 'uppy-ProviderBrowserItem-checkbox', - 'uppy-ProviderBrowserItem-checkbox--grid', - { 'uppy-ProviderBrowserItem-checkbox--is-checked': isChecked }, - ) - +function GridItem({ + file, + toggleCheckbox, + className, + isDisabled, + restrictionError, + showTitles, + children = null, +}: GridItemProps): h.JSX.Element { return (
  • diff --git a/packages/@uppy/provider-views/src/Item/components/ListItem.tsx b/packages/@uppy/provider-views/src/Item/components/ListItem.tsx index 38e95eeb16..11ccfb47b3 100644 --- a/packages/@uppy/provider-views/src/Item/components/ListItem.tsx +++ b/packages/@uppy/provider-views/src/Item/components/ListItem.tsx @@ -1,7 +1,10 @@ -/* eslint-disable react/require-default-props */ -import type { RestrictionError } from '@uppy/core/lib/Restricter' -import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { + PartialTreeFile, + PartialTreeFolderNode, + PartialTreeId, +} from '@uppy/core/lib/Uppy' import { h } from 'preact' +import ItemIcon from './ItemIcon.tsx' // if folder: // + checkbox (selects all files from folder) @@ -9,96 +12,79 @@ import { h } from 'preact' // if file: // + checkbox (selects file) // + file name (selects file) - -type ListItemProps = { +type ListItemProps = { + file: PartialTreeFile | PartialTreeFolderNode + openFolder: (folderId: PartialTreeId) => void + toggleCheckbox: (event: Event) => void className: string isDisabled: boolean - restrictionError?: RestrictionError | null - isCheckboxDisabled: boolean - isChecked: boolean - toggleCheckbox: (event: Event) => void - recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void - type: string - id: string - itemIconEl: any - title: string - handleFolderClick?: () => void + restrictionError: string | null showTitles: boolean i18n: any } -export default function ListItem( - props: ListItemProps, -): h.JSX.Element { - const { - className, - isDisabled, - restrictionError, - isCheckboxDisabled, - isChecked, - toggleCheckbox, - recordShiftKeyPress, - type, - id, - itemIconEl, - title, - handleFolderClick, - showTitles, - i18n, - } = props - +export default function ListItem({ + file, + openFolder, + className, + isDisabled, + restrictionError, + toggleCheckbox, + showTitles, + i18n, +}: ListItemProps): h.JSX.Element { return (
  • - {!isCheckboxDisabled ? - - name="listitem" - id={id} - checked={isChecked} - aria-label={ - type === 'file' ? null : ( - i18n('allFilesFromFolderNamed', { name: title }) - ) - } - disabled={isDisabled} - data-uppy-super-focusable - /> - : null} + + name="listitem" + id={file.id} + checked={file.status === 'checked'} + aria-label={ + file.data.isFolder ? + i18n('allFilesFromFolderNamed', { name: file.data.name }) + : null + } + disabled={isDisabled} + data-uppy-super-focusable + /> { - type === 'file' ? - // label for a checkbox - + file.data.isFolder ? // button to open a folder - : + // label for a checkbox + : }
  • diff --git a/packages/@uppy/provider-views/src/Item/index.tsx b/packages/@uppy/provider-views/src/Item/index.tsx index e127550b36..8fbf88cbd5 100644 --- a/packages/@uppy/provider-views/src/Item/index.tsx +++ b/packages/@uppy/provider-views/src/Item/index.tsx @@ -1,83 +1,66 @@ -/* eslint-disable react/require-default-props */ +/* eslint-disable react/jsx-props-no-spreading */ import { h } from 'preact' import classNames from 'classnames' import type { I18n } from '@uppy/utils/lib/Translator' -import type { CompanionFile } from '@uppy/utils/lib/CompanionFile' -import type { RestrictionError } from '@uppy/core/lib/Restricter' -import type { Meta, Body } from '@uppy/utils/lib/UppyFile' -import ItemIcon from './components/ItemIcon.tsx' +import type { + PartialTreeFile, + PartialTreeFolderNode, + PartialTreeId, +} from '@uppy/core/lib/Uppy.ts' import GridItem from './components/GridItem.tsx' import ListItem from './components/ListItem.tsx' -type ItemProps = { - showTitles: boolean - i18n: I18n - id: string - title: string +type ItemProps = { + file: PartialTreeFile | PartialTreeFolderNode + openFolder: (folderId: PartialTreeId) => void toggleCheckbox: (event: Event) => void - recordShiftKeyPress: (event: KeyboardEvent | MouseEvent) => void - handleFolderClick?: () => void - restrictionError?: RestrictionError | null - isCheckboxDisabled: boolean - type: 'folder' | 'file' - author?: CompanionFile['author'] - getItemIcon: () => string - isChecked: boolean - isDisabled: boolean viewType: string + showTitles: boolean + i18n: I18n } -export default function Item( - props: ItemProps, -): h.JSX.Element { - const { author, getItemIcon, isChecked, isDisabled, viewType } = props - const itemIconString = getItemIcon() +export default function Item(props: ItemProps): h.JSX.Element { + const { viewType, toggleCheckbox, showTitles, i18n, openFolder, file } = props + + const restrictionError = file.type === 'folder' ? null : file.restrictionError + const isDisabled = !!restrictionError && file.status !== 'checked' - const className = classNames( - 'uppy-ProviderBrowserItem', - { 'uppy-ProviderBrowserItem--selected': isChecked }, - { 'uppy-ProviderBrowserItem--disabled': isDisabled }, - { 'uppy-ProviderBrowserItem--noPreview': itemIconString === 'video' }, - ) + const ourProps = { + file, + openFolder, + toggleCheckbox, - const itemIconEl = + i18n, + viewType, + showTitles, + className: classNames( + 'uppy-ProviderBrowserItem', + { 'uppy-ProviderBrowserItem--disabled': isDisabled }, + { 'uppy-ProviderBrowserItem--noPreview': file.data.icon === 'video' }, + { 'uppy-ProviderBrowserItem--is-checked': file.status === 'checked' }, + { 'uppy-ProviderBrowserItem--is-partial': file.status === 'partial' }, + ), + isDisabled, + restrictionError, + } switch (viewType) { case 'grid': - return ( - - // eslint-disable-next-line react/jsx-props-no-spreading - {...props} - className={className} - itemIconEl={itemIconEl} - /> - ) + return case 'list': - return ( - - // eslint-disable-next-line react/jsx-props-no-spreading - {...props} - className={className} - itemIconEl={itemIconEl} - /> - ) + return case 'unsplash': return ( - - // eslint-disable-next-line react/jsx-props-no-spreading - {...props} - className={className} - itemIconEl={itemIconEl} - > + - {author!.name} + {file.data.author!.name} ) diff --git a/packages/@uppy/provider-views/src/ProviderView/AuthView.tsx b/packages/@uppy/provider-views/src/ProviderView/AuthView.tsx index 099e46a82c..5f04b9a4f1 100644 --- a/packages/@uppy/provider-views/src/ProviderView/AuthView.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/AuthView.tsx @@ -2,7 +2,7 @@ import { h } from 'preact' import { useCallback } from 'preact/hooks' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' import type Translator from '@uppy/utils/lib/Translator' -import type { ProviderViewOptions } from './ProviderView.js' +import type { Opts } from './ProviderView.js' import type ProviderViews from './ProviderView.js' type AuthViewProps = { @@ -11,7 +11,7 @@ type AuthViewProps = { pluginIcon: () => h.JSX.Element i18n: Translator['translateArray'] handleAuth: ProviderViews['handleAuth'] - renderForm?: ProviderViewOptions['renderAuthForm'] + renderForm?: Opts['renderAuthForm'] } function GoogleIcon() { diff --git a/packages/@uppy/provider-views/src/ProviderView/Header.tsx b/packages/@uppy/provider-views/src/ProviderView/Header.tsx index 4a2e6efa5c..2bdb4255c1 100644 --- a/packages/@uppy/provider-views/src/ProviderView/Header.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/Header.tsx @@ -1,20 +1,21 @@ /* eslint-disable react/destructuring-assignment */ -import { h, Fragment } from 'preact' +import { h } from 'preact' import type { I18n } from '@uppy/utils/lib/Translator' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy' +import type { PartialTreeFolder } from '@uppy/core/lib/Uppy.ts' +import classNames from 'classnames' import User from './User.tsx' import Breadcrumbs from '../Breadcrumbs.tsx' import type ProviderView from './ProviderView.js' type HeaderProps = { showBreadcrumbs: boolean - getFolder: ProviderView['getFolder'] - breadcrumbs: UnknownProviderPluginState['breadcrumbs'] + openFolder: ProviderView['openFolder'] + breadcrumbs: PartialTreeFolder[] pluginIcon: () => h.JSX.Element title: string logout: () => void - username: string | undefined + username: string | null i18n: I18n } @@ -22,16 +23,27 @@ export default function Header( props: HeaderProps, ) { return ( - - {props.showBreadcrumbs && ( - +
    + {props.showBreadcrumbs && ( + + )} + - )} - - +
    + ) } diff --git a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx index edacb56ef2..629808a9ab 100644 --- a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx @@ -1,42 +1,37 @@ import { h } from 'preact' -import PQueue from 'p-queue' - -import { getSafeFileId } from '@uppy/utils/lib/generateFileID' - import type { UnknownProviderPlugin, + PartialTreeFolder, + PartialTreeFolderNode, + PartialTreeFile, UnknownProviderPluginState, - Uppy, + PartialTreeId, + PartialTree, } from '@uppy/core/lib/Uppy.ts' import type { Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { CompanionFile } from '@uppy/utils/lib/CompanionFile' +import type { CompanionFile } from '@uppy/utils/lib/CompanionFile.ts' import type Translator from '@uppy/utils/lib/Translator' -import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin' +import classNames from 'classnames' +import type { ValidateableFile } from '@uppy/core/lib/Restricter.ts' +import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal' import AuthView from './AuthView.tsx' import Header from './Header.tsx' import Browser from '../Browser.tsx' -import CloseWrapper from '../CloseWrapper.ts' -import View, { type ViewOptions } from '../View.ts' // 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' - -function formatBreadcrumbs( - breadcrumbs: UnknownProviderPluginState['breadcrumbs'], -): string { - return breadcrumbs - .slice(1) - .map((directory) => directory.name) - .join('/') -} - -function prependPath(path: string | undefined, component: string): string { - if (!path) return component - return `${path}/${component}` -} - -export function defaultPickerIcon() { +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 (
    + + showBreadcrumbs={opts.showBreadcrumbs} + openFolder={this.openFolder} + breadcrumbs={breadcrumbs} + pluginIcon={pluginIcon} + title={this.plugin.title} + logout={this.logout} + username={username} + i18n={i18n} + /> + + {opts.showFilter && ( + { + this.plugin.setPluginState({ searchString: s }) + }} + submitSearchString={() => {}} + inputLabel={i18n('filter')} + clearSearchLabel={i18n('resetFilter')} + wrapperClassName="uppy-ProviderBrowser-searchFilter" + inputClassName="uppy-ProviderBrowser-searchFilterInput" + /> + )} + + + 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} + /> + + +
    ) } } diff --git a/packages/@uppy/provider-views/src/ProviderView/User.tsx b/packages/@uppy/provider-views/src/ProviderView/User.tsx index e9ef6cdf9c..53dab79c53 100644 --- a/packages/@uppy/provider-views/src/ProviderView/User.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/User.tsx @@ -3,15 +3,17 @@ import { h, Fragment } from 'preact' type UserProps = { i18n: (phrase: string) => string logout: () => void - username: string | undefined + username: string | null } export default function User({ i18n, logout, username }: UserProps) { return ( - - {username} - + {username && ( + + {username} + + )}