Skip to content

Commit

Permalink
Provider views rewrite (.files, .folders => .partialTree) (#5050)
Browse files Browse the repository at this point in the history
* ProviderView.tsx - fix onedrive breadcrumbs

* providers - correct ch-unch-indeterminate states

* providers - made .breadcrumbs derived from .partialTree

* everywhere - { files, folders, isChecked } => .partialTree

GoogleDrive
- travelling down into folders works
- checking a file works
- breadcrumbs DONT work

* GoogleDrive - made breadcrumbs work

* .getFolder() - remove the `name` argument

* <Breadcrumbs/> - refactors "/"

* Instagram - made files get fetched onScroll

* clearSelection() - recover the functionality

* GoogleDrive - recover custom `.toggleCheckbox()` functionality

* providers - recover `.isDisabled` functionality

* <SearchProviderView/> - made Unsplash use .partialTree

* Facebook - change `.files, .folders` => `.partialTree`

* everywhere - we don't need to ! `partialTreeFile.data` anymore

* <ProviderView/> - implement folder caching

* <View/> - enable shift-clicking

* everywhere - get rid of unnecessary `.getNextFolder()`

* everywhere - fixing types

* <ProviderView/> - rename `requestPath` to `folderId`

* all providers - get rid of `.onFirstRender()`

* provider views - get rid of `.onFirstRender()`

* <ProviderView/> - make the root folder cacheable too

* TEMP - setup for working with FOLDERS + LAZY_LOADING

* <ProviderView/> - get rid of `.#listFilesAndFolders`

* <ProviderView/> - make `this.nextPagePath` per-folder

* everywhere - more refined types

* types - reintroduce `StatusInPartialTree`

* <SearchProviderView/> - made Unsplash work with the new structure

* <ProviderView/> - preemptive cleaning of `.absDirPath` and `.relDirPath`

* <ProviderView/> - give `.nextPagePath` a rigorous type

* <ProviderView/> - make `.nextPagePath` & `.cached` a composite key

* <ListItem/> - remove unnecessary indirection level

* css - factor out `.statusClassName`

* everywhere - refactor `.validateRestrictions()`

* nOfSelectedFiles - make "Selected (n)" as smart as possible

* <ProviderViews/> - prevent shift-clicking from highlighting file names

* `.validateRestrictions()` - make it accept a `CompanionFile` instead of `PartialTree`'s file

* `.getFolder()` - simplify code

* everywhere - account for `restrictions` in `.partialTree`

* `PartialTreeUtils.ts` - factor out `getPartialTreeAfterTogglingCheckboxes()`

* `PartialTreeUtils.ts` - factor out `clickOnFolder()`

* `PartialTreeUtils.ts` - factor out `getPartialTreeAfterScroll()`

* `PartialTreeUtils.ts` - rename methods

* `.donePicking()` - implement using recursion

* `.donePicking()` - integrate with `<ProviderView/>`

* `donePicking()` - show notifications after addition

* `#list()` - get rid of unnecessary indirection

* ProviderView.tsx - add `signal` everywhere, reduce try/catch indents everywhere

* `handleError()` - make error handling uniform

* `state.isSearchVisible` - remove, it's just not used anywhere

* state - reuse default state

* state - reset state on close panel (like we discussed in the uppy call)

* methods - remove unnecessary indirection in state setting

* `<CloseWrapper/>` - remove CloseWrappers, this is unnecessary indirection now too

* `this.requestClientId` - remove, again - this was unnecessary indirection

* `getTagFile()` - factor out into a separate file

* `recordShiftKeyPress()` - fix chaotic shift-clicking in Grid providers, remove endless prop drilling while we're at it

* `getNOfSelectedFiles.ts`, `filterItems.ts` - factor out, this removes props drilling

* <Browser/> - pass `displayedPartialTree` right away (because Search&NormalProvider have wildly different logics!)

* `searchTerm`, `filterInput` - we only need one of these of course!

* <SearchProvider/> - fix the issue where `afterToggleCheckbox()` thinks we should always filter by `searchString`

* <SearchProvider/> - remove `this.nextPageQuery`

Also: fix the issue where <SearchProvider/> upon searching for "ocean" and then "pajama" would just be adding pajama pictures after the ocean ones

* <Browser/> - remove unnecessary prop indirection

Typescript didn't actually know some of these props aren't used (removed those now)! It only discovers unused props upon normal props passing, like we do now.

* <SearchFilterInput/> - make the form controlled, hugely simplifies everything

* `filterItems.ts` - move to <ProviderView/>, because it's only used there

* /utils/PartialTreeUtils.ts - put every util in a separate file

* `shouldHandleScroll.ts` - factor out into a util

This brings all references to `this.isHandlingScroll` into a single place, and makes `shouldHandleScroll()` a self-contained simple function

* this.state - make sure state is reset 1. on cancel 2. on close

* `this.xxx` - never leave `this.xxx` variables undefined

* `this.username` - should be in `this.state`

Also - when there is no username, stop showing the little dot

* `SearchProviderPluginState` type - simplify this type, never leave state vars undefined

* <Header/> - remove completely unnecessary indirection, remove unused props

* Facebook.tsx - more sane `viewOptions` code

* providers - properly type `opts`

* `this.isShiftKeyPressed` - move this variable into <Browser/>

* `this.handleError()` - move to /utils

* `this.isHandlingScroll` - move to child classes

* `this.registerRequestClient()` - move to child classes

* `this.lastCheckbox` - move to child classes

* `this.setLoading()` - move to child classes

* `this.validateRestrictions()` - move to utils

* types - fully simplify provider types, remove `View.ts` parent class

* index.d.ts - we're not using `OnFirstRenderer` anymore

* <ProviderView/>, <SearchProviderView/> - more precise typing for options

* package.json - remove nanoid

* GoogleDrive - make shift-clicking work

* everywhere - fix types across uppy

* `afterToggleCheckbox.ts` - less redundant args, pass `ourItem.id` instead of `ourItem`

* tests - create `afterToggleCheckbox()` tests

* `getClickedRange.ts` - decouple `getClickedRange()` from `afterToggleCheckbox()`

* tests - wrote tests for `afterToggleCheckbox.ts`

* tests - wrote tests for `afterClickOnFolder.ts`

* everywhere - finally rename `getFolder` => `openFolder`

* tests - wrote tests for `afterScrollFolder.ts`

* getPaths.ts - make `absDirPath`, `relDirPath` work like in docs & add tests for that

* injectPaths.ts - improve performance

* getTagFile.ts - handle path injection all in one place

* getTagFile.ts - refactor

Just makes it easier to read the structure of TagFile

* fill.ts - `provider.list(currentPath, { signal })` => `apiList`

(remove the dependency on provider, just pass a callback)

* tests - wrote tests for `fill.ts`

* tests - wrote tests for `getNOfSelectedFiles.ts`

* everywhere - change `JSON.stringify()` => `clone()`

* `PartialTreeUtils.ts` - more consistent function naming + alphabetical order in tests

* `donePicking()` - superseded a notification to i18n one

* GoogleDrive - make the shared drive checkable

* `Item.tsx` - standardize names; remove unnecessary question marks from props

* ProviderView.tsx - clicking "Cancel" should make all files "unchecked"

* everywhere - move `document.getSelection()?.removeAllRanges()` to <Browser/> to avoid repetition

* everywhere - standardize names and types of passed props

* <Browser/> - only leave "list of files" to the browser

Moves stuff closer to where it's used, prevents props drilling

* TEMP - easier pageSize for alex to play with

When it's set to 5 pages you have to reduce the browser window to make it scrollable

* everywhere - only handle individual-file restrictions

* everywhere - add aggregate restrictions on top

* SearchProvider, NormalProvider - unite the way we addFiles()

Same notifications, same code, same everything

* `getTagFile.ts` - pass fewer arguments

* `addFiles.ts` - move conversion to tagFiles into `addFiles()`

* `uppy.validateRestrictions()` - remove legacy method

* `uppy.validateAggregateRestrictions()` - make aggregate restricter report aggregate error

* <FooterActions/> css - make aggregate errors look nice

* `PartialTreeUtils/index.test.ts` - accommodate tests to the latest changes

* tests - make all uppy tests work

* prettiness - run `yarn format`

* prettiness - run `yarn lint:fix`

* package.json - add `vitest` as a dev dependency

* eslint - fixing 1

eslint - fixing 2

eslint - fixing 3

* <SearchFilterInput/> - add default props as per eslint

* <SearchFilterInput/> - rename to <SearchInput/>

* eslint - fixing 4 (clone.ts)

* Uppy.ts - rewrite `partialTree` docs

* eslint - fixing 5

* eslint - fixing 6

* `getBreadcrumbs.ts` - factor out

* tests - fixing 7

* everywhere - remove `.toReversed()`, because it's not yet supported in all browsers

* dev/Dashboard.js - restore to pristine version

* prettiness - run `yarn format`

* fixing 8 (`yarn run build:ts`)

* fixing 9 (run `corepack yarn`)

* prettier - undo indentation harm done by prettier

* `getBreadcrumbs()` - add tests, and rewrite to avoid using `.toReversed()`

Clarification: `.toReversed()` is no supported by all browsers

* `<SearchInput/>` - make it work for eslint

* everywhere - remove `eslint-disable react/require-default-props`

* <GridItem/>, <ListItem/> - refactor to avoid prop drilling

* <ListItem/> - disable checkboxes for GoogleDrive team drives

See #5232

* merge (fixing up some lines from the previous merge)

* merge (fixing up some lines from the previous merge)

* everywhere - remove TEMP development values

* `this.validateSingleFile()` - switch to `.restrictionError`

* `afterToggleCheckbox.ts` - refactor, add comments

* `afterToggleCheckbox.ts` - refactor to use ids instead of whole objects

* `afterToggleCheckbox.ts` - try to satisfy prettier

* fixing 10 (try to satisfy `npx webpack`)

* fixing 11 (try to satisfy `npx webpack`)

* Antoine: use Math.min & Math.max in `getClickedRange()`

Co-authored-by: Antoine du Hamel <antoine@transloadit.com>

* fixing 12 (run `yarn run format`)

* `clone.ts` - rename to `shallowClone.ts`

* Antoine: in `package.json`, move `devDependencies` up

* Antoine: rename `getNOfSelectedFiles()` to `getNumberOfSelectedFiles()`

* `getNumberOfSelectedFiles()` - better comments

* Antoine: remove `<form/>` tag

* Antoine: change `{}` to `Object.create(null)`, write tests

* Antoine: `<SearchInput/>` - return dynamic <form/> element

* `<SearchInput/>` - return `buttonCSSClassName`

* `GoogleDrive.tsx` - make team drive checkboxes visible

* merge (more)

* Mifi: update packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx

Co-authored-by: Mikael Finstad <finstaden@gmail.com>

* merge (more changes)

* `Facebook.tsx`, `GooglePhotos.tsx` - render in 'grid' style on per-folder basis

* `<GridItem/>` - use the `.thumbnail` whenever possible (improves image quality, adds video icons)

* `prettier` - ensure `PartialTree` is always strongly indented in tests

---------

Co-authored-by: Antoine du Hamel <antoine@transloadit.com>
Co-authored-by: Mikael Finstad <finstaden@gmail.com>
  • Loading branch information
3 people authored Jun 21, 2024
1 parent 88d508f commit 4dc2860
Show file tree
Hide file tree
Showing 43 changed files with 2,481 additions and 1,549 deletions.
2 changes: 1 addition & 1 deletion packages/@uppy/companion-client/src/Provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export default class Provider<M extends Meta, B extends Body>
}

list<ResBody>(
directory: string | undefined,
directory: string | null,
options: RequestOptions,
): Promise<ResBody> {
return this.get<ResBody>(`${this.id}/list/${directory || ''}`, options)
Expand Down
14 changes: 5 additions & 9 deletions packages/@uppy/core/src/Uppy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
123 changes: 96 additions & 27 deletions packages/@uppy/core/src/Uppy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,86 @@ export type UnknownPlugin<
PluginState extends Record<string, unknown> = Record<string, unknown>,
> = BasePlugin<any, M, B, PluginState>

// `OmitFirstArg<typeof someArray>` is the type of the returned value of `someArray.slice(1)`.
type OmitFirstArg<T> = 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).
Expand All @@ -89,8 +152,8 @@ export type UnknownProviderPlugin<
M extends Meta,
B extends Body,
> = UnknownPlugin<M, B, UnknownProviderPluginState> & {
rootFolderId: string | null
title: string
rootFolderId: string | null
files: UppyFile<M, B>[]
icon: () => h.JSX.Element
provider: CompanionClientProvider
Expand All @@ -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,
Expand Down Expand Up @@ -296,6 +353,9 @@ export interface UppyEventMap<M extends Meta, B extends Body>
'upload-start': (files: UppyFile<M, B>[]) => void
}

/** `OmitFirstArg<typeof someArray>` is the type of the returned value of `someArray.slice(1)`. */
type OmitFirstArg<T> = T extends [any, ...infer U] ? U : never

const defaultUploadState = {
totalProgress: 0,
allowNewUpload: true,
Expand Down Expand Up @@ -780,14 +840,23 @@ export class Uppy<M extends Meta, B extends Body = Record<string, never>> {
}
}

validateRestrictions(
file: ValidateableFile<M, B>,
files: ValidateableFile<M, B>[] = this.getFiles(),
): RestrictionError<M, B> | null {
validateSingleFile(file: ValidateableFile<M, B>): string | null {
try {
this.#restricter.validateSingleFile(file)
} catch (err) {
return err.message
}
return null
}

validateAggregateRestrictions(
files: ValidateableFile<M, B>[],
): 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
}
Expand Down
6 changes: 5 additions & 1 deletion packages/@uppy/core/src/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
background-color: $blue;
border-radius: 4px;

&:hover {
&:not(:disabled):hover {
background-color: darken($blue, 10%);
}

Expand All @@ -145,6 +145,10 @@

@include blue-border-focus--dark;
}

&.uppy-c-btn--disabled {
background-color: rgb(142, 178, 219);
}
}

.uppy-c-btn-link {
Expand Down
26 changes: 13 additions & 13 deletions packages/@uppy/facebook/src/Facebook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,19 @@ export default class Facebook<M extends Meta, B extends Body> 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)
}
}
20 changes: 12 additions & 8 deletions packages/@uppy/google-drive/src/DriveProviderViews.ts
Original file line number Diff line number Diff line change
@@ -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<M, B> {
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)
}
}
}
11 changes: 7 additions & 4 deletions packages/@uppy/google-photos/src/GooglePhotos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/@uppy/provider-views/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"p-queue": "^8.0.0",
"preact": "^10.5.13"
},
"devDependencies": {
"vitest": "^1.6.0"
},
"peerDependencies": {
"@uppy/core": "workspace:^"
},
Expand Down
52 changes: 17 additions & 35 deletions packages/@uppy/provider-views/src/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Fragment>
<button
type="button"
className="uppy-u-reset uppy-c-btn"
onClick={getFolder}
>
{title}
</button>
{!isLast ? ' / ' : ''}
</Fragment>
)
}

type BreadcrumbsProps<M extends Meta, B extends Body> = {
getFolder: ProviderView<M, B>['getFolder']
openFolder: ProviderView<M, B>['openFolder']
title: string
breadcrumbsIcon: h.JSX.Element
breadcrumbs: UnknownProviderPluginState['breadcrumbs']
breadcrumbs: PartialTreeFolder[]
}

export default function Breadcrumbs<M extends Meta, B extends Body>(
props: BreadcrumbsProps<M, B>,
) {
const { getFolder, title, breadcrumbsIcon, breadcrumbs } = props
): h.JSX.Element {
const { openFolder, title, breadcrumbsIcon, breadcrumbs } = props

return (
<div className="uppy-Provider-breadcrumbs">
<div className="uppy-Provider-breadcrumbsIcon">{breadcrumbsIcon}</div>
{breadcrumbs.map((directory, i) => (
<Breadcrumb
key={directory.id}
getFolder={() => getFolder(directory.requestPath, directory.name)}
title={i === 0 ? title : (directory.name as string)}
isLast={i + 1 === breadcrumbs.length}
/>
{breadcrumbs.map((folder, index) => (
<Fragment>
<button
key={folder.id}
type="button"
className="uppy-u-reset uppy-c-btn"
onClick={() => openFolder(folder.id)}
>
{folder.type === 'root' ? title : folder.data.name}
</button>
{breadcrumbs.length === index + 1 ? '' : ' / '}
</Fragment>
))}
</div>
)
Expand Down
Loading

0 comments on commit 4dc2860

Please sign in to comment.